feat(#3034): add opt-in parallel reviewer lanes (#3822)

* test(#3034): failing-first coverage for opt-in parallel reviewer lanes

Executes the real invoke_reviewers dispatch block from review.md against a
stubbed gsd_run seam rather than pattern-matching the workflow text, so the
two properties that actually carry risk are observable: that every lane is
joined before aggregation, and that concurrent lanes cannot tear a line in
gsd-review-lane-results.jsonl.

Concurrency is proven by a barrier fixture, not by elapsed time -- each stub
lane blocks until all lanes have checked in, which can only complete if they
overlap.

Red against the current sequential dispatch, by design.

Refs #3034

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(#3034): add opt-in parallel reviewer lanes

Reviewer lanes within one review pass inspect the same immutable plan
snapshot and have no dependency on one another, but were dispatched strictly
one at a time, so a multi-reviewer pass cost roughly the sum of its lanes.
The serialization is a deliberate protection against provider rate limits,
so it stays the default; review.parallel_lanes opts a project out of it.

The loop body is hoisted into run_review_lane so the sequential and
concurrent paths share one body -- two hand-synced dispatch bodies is the
divergence class ADR-2782 spent a phase deleting. Each lane writes a
slug-scoped result file, concatenated in selection order after the join:
concurrent O_APPEND is atomic only below PIPE_BUF, and write_reviews parses
that JSONL to render the models:/model_sources: frontmatter, so a torn line
is a broken REVIEWS.md rather than a cosmetic log defect. Aggregating in
selection order also keeps the artifact byte-identical between the two paths.

The guard is strict equality on "true" and falls back to sequential when
config-get fails -- the opposite polarity from the commit_docs guard,
because failing open here fires the very requests the default prevents.

Also corrects docs/COMMANDS.md and its four locale mirrors, which described
--all as running every configured reviewer in parallel when dispatch was in
fact sequential.

Closes #3034

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3034): de-duplicate dispatch slugs and scope lane locals

Review finding (Standards axis): a slug repeated in SELECTED_REVIEWERS would
put two concurrent background jobs on the same > -truncated per-lane result
file. The shared-append form this replaced could not corrupt itself that way,
so de-duplicating is what keeps the concurrent path no worse than the
sequential one.

Selection de-dupes today -- the roster is a Set and review.default_reviewers
normalizes lowercase-unique -- but reachability analysis is not a contract,
which is the same reason the roster derivation itself is guarded.

Splitting once into DISPATCH_SLUGS also removes the duplicated tr-split the
same review flagged: the dispatch and aggregation loops now share one list,
which is what guarantees they walk the same slugs in the same order. A plain
string accumulator rather than an array, because zsh and bash disagree on
array indexing and this block runs under both.

Also scopes run_review_lane's locals. Not a live fix -- each dispatched call
already forks its own subshell -- but it makes the isolation a property of the
function rather than of the dispatch mechanism happening to fork.

Refs #3034

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#3034): acknowledge review.md growth, drop spent 2295 ack

The differential attribution gate reported review.md growing 4173 bytes
(30712 -> 34885) with no live acknowledgment. Adds the per-PR fragment it
asks for, naming only the one path it reported.

Deleting tests/emitted-drift-acks/2295-resolved-model.json is required, not
opportunistic. That fragment declared review.md and nothing else, and its
ripple is already absorbed into the base, so it is spent -- it can no longer
clear anything, which is why the gate still reported review.md as
unacknowledged. It could not simply be left alone either: two ack sources may
never name the same path, so it blocked this PR's fragment outright.
CONTRIBUTING is explicit that a fragment whose last entry is removed gets
deleted with it, because an empty fragment signals nothing while its presence
reads as a live alarm.

Refs #3034

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3034): backfill changeset PR number

Refs #3034

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-24 15:25:22 -04:00
committed by GitHub
parent a84f756303
commit a2387a0545
14 changed files with 858 additions and 10 deletions

View File

@@ -267,7 +267,7 @@ Cross-AI plan convergence loop — replan with review feedback until no HIGH con
|-----------------|----------|-------------|
| `N` | **Yes** | Phase number to plan and review |
| Reviewer flags | No | Pass through every reviewer lane flag: `--gemini`, `--claude`, `--codex`, `--coderabbit`, `--opencode`, `--qwen`, `--cursor`, `--agy` / `--antigravity`, `--ollama`, `--lm-studio`, `--llama-cpp`, `--kimi-code` |
| `--all` | No | Run every configured reviewer in parallel |
| `--all` | No | Run every configured reviewer. Lanes are dispatched **sequentially by default**; set `review.parallel_lanes` to `true` to dispatch them concurrently within a single review pass |
| `--max-cycles N` | No | Override cycle cap (default 3) |
**Exit behavior:** Loop exits when both `current_high` and `current_actionable` hit zero. Stall detection warns when the total unresolved review count is not decreasing across cycles. Escalation gate asks the user to proceed or review manually when `--max-cycles` is hit with HIGH or actionable non-HIGH concerns still open.

View File

@@ -85,7 +85,8 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new
"review": {
"default_reviewers": null,
"reviewer_instances": {},
"models": {}
"models": {},
"parallel_lanes": false
},
"parallelization": {
"enabled": true,
@@ -293,6 +294,45 @@ Example:
}
```
### Parallel reviewer lanes for `/gsd-review` (#3034)
By default `/gsd-review` invokes reviewer lanes one at a time. That is deliberate: concurrent
invocation can trip provider rate limits, and a lane dropped to a rate limit is a review that
silently lost an opinion. A pass with several reviewers therefore costs roughly the sum of their
runtimes.
The lanes within one review pass have no data dependency on one another — they all inspect the
same immutable plan snapshot. If your providers can accept concurrent requests (independent
accounts, generous quota, or local model servers), you can opt in:
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `review.parallel_lanes` | boolean | `false` | When `true`, dispatch independent selected reviewer lanes concurrently within a single `/gsd-review` pass. All lanes are joined before `REVIEWS.md` and consensus are rendered. |
```bash
gsd config-set review.parallel_lanes true
/gsd-plan-review-convergence 3 --all
```
```json
{
"review": {
"parallel_lanes": true
}
}
```
**What this does not change.** Convergence cycles stay sequential — `review → replan → re-review`
has a real data dependency, so enabling this speeds up each pass, not the number of passes.
Per-lane timeouts, prompt budgets, diagnostic stubs, explicit-lane failure, trust/egress checks and
result-file layout are all unchanged.
**Before you enable it.** Every selected lane dispatches at once — there is no concurrency bound.
Selecting eleven lanes issues eleven concurrent requests. Two reviewer instances backed by the same
adapter (see below) also dispatch concurrently against that one provider, which is the most likely
way to hit a limit. If a lane does get rate-limited it fails the way any other failing lane does:
a diagnostic stub with captured stderr, never a silently dropped review.
### Reviewer instances for `/gsd-review` (#1517)
Use `review.reviewer_instances` to run one model-capable adapter as several independent
@@ -1204,6 +1244,7 @@ Configure per-CLI model selection for `/gsd-review`. When set, overrides the CLI
| `review.default_reviewers` | string[] \| null | (all detected reviewers) | Default reviewer subset for no-flag `/gsd-review`. Example: `["gemini","codex"]`. May include configured `review.reviewer_instances` names. Explicit flags and `--all` override this setting. |
| `review.max_prompt_tokens` | number\|null | null | Default maximum estimated tokens for the assembled review prompt. When set, the prompt is deterministically trimmed before being sent to each reviewer. Per-reviewer overrides via `review.max_prompt_tokens_per_reviewer` take precedence. null = no trim (current behavior). |
| `review.max_prompt_tokens_per_reviewer` | object | {} | Per-reviewer token budget overrides. Keys are reviewer slugs. Only lanes that declare a budget key accept one — today `ollama`, `lm_studio` and `llama_cpp`, the local model servers this exists for. Values override `review.max_prompt_tokens` for that reviewer. A per-lane value of `0` disables trimming for that lane specifically. |
| `review.parallel_lanes` | boolean | `false` | Dispatch independent reviewer lanes concurrently within a single `/gsd-review` pass. Default `false` keeps the sequential dispatch that protects against provider rate limits. Opt in only when your providers can accept concurrent requests. Convergence cycles stay sequential either way. |
| `review.ollama_host` | string | `http://localhost:11434` | Base URL of the Ollama server. Override when running Ollama on a non-default port or remote host: `gsd config-set review.ollama_host http://192.168.1.10:11434` |
| `review.lm_studio_host` | string | `http://localhost:1234` | Base URL of the LM Studio local server. Override when using a non-default port. |
| `review.llama_cpp_host` | string | `http://localhost:8080` | Base URL of the llama.cpp server (`llama-server`). Override when using a non-default port. |

View File

@@ -3529,3 +3529,25 @@ See [Resolve verify-command path findings](how-to/resolve-verify-command-path-fi
**Known limits:** no sandbox — once enabled, nothing constrains which origins are reached ([ADR-1244](adr/1244-capability-ecosystem.md) D5); DOM observation only, no screenshot diffing, accessibility audit, or performance tracing.
**Reference:** [Configuration](CONFIGURATION.md) · [Enable live-DOM verification](how-to/enable-live-dom-verification.md) · [Explanation](explanation/live-dom-uat-capability.md) · [Agents](AGENTS.md)
---
### 165. Opt-In Parallel Reviewer Lanes
**Command:** `/gsd-review`, `/gsd-plan-review-convergence`
**Config key:** `review.parallel_lanes` (default `false`)
**Purpose:** Reviewer lanes within one review pass have no data dependency on each other — they all inspect the same immutable plan snapshot — but were dispatched strictly one at a time, so a pass with Codex, Gemini and Claude cost roughly the sum of three long reviewer calls. The serialization was a deliberate, unconditional protection against provider rate limits, which made it a global policy imposed on users whose providers could comfortably take concurrent requests, or who run local model servers with no limits at all (#3034).
**Behavior:** With the key enabled, the `invoke_reviewers` step dispatches each selected lane as a background job and joins all of them before `REVIEWS.md` and consensus are rendered. Wall-clock cost falls toward the slowest lane rather than the sum. Default remains `false`, preserving the existing sequential dispatch and its rate-limit protection.
**The guard is strict equality, and it fails safe.** Only the exact value `true` opts in — `"1"`, `"yes"` and `"TRUE"` all stay sequential, so a mistyped config gets the conservative behavior rather than concurrent requests at a rate-limited provider. A failure to read the config falls back to sequential too. This polarity is deliberately the opposite of the `commit_docs` guard, which fails open: there, failing open preserves user intent; here it would fire the very requests the default exists to prevent.
**Result ordering is unchanged in both modes.** Per-lane results are written to slug-scoped files and concatenated in reviewer-selection order after the join, so `gsd-review-lane-results.jsonl` reads identically whether lanes ran sequentially or concurrently. Completion order never reaches the artifact. This also means concurrent lanes never share an append handle — a lane result larger than the pipe-atomicity bound cannot interleave and corrupt the `models:` / `model_sources:` frontmatter that `write_reviews` renders from that file.
**Per-lane semantics are untouched.** Timeouts, prompt budgets, the diagnostic stub for an empty or failed lane, explicit-lane failure ([ADR-2782](adr/2782-reviewer-lane-capability-surface.md) D4), trust/egress checks and result-file layout all behave exactly as they do sequentially. A failing lane does not abort its siblings.
**Known limits:** convergence cycles stay sequential by design (`review → replan → re-review` has a genuine data dependency), so this speeds up each pass rather than reducing the number of passes; there is no concurrency bound, so every selected lane dispatches at once; and reviewer instances sharing one adapter dispatch concurrently against that single provider, which is the most likely way to hit a limit.
**Reference:** [Configuration](CONFIGURATION.md#parallel-reviewer-lanes-for-gsd-review-3034) · [Enable parallel reviewer lanes](how-to/enable-parallel-reviewer-lanes.md) · [Commands](COMMANDS.md)

View File

@@ -36,6 +36,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
- [Plan a phase](how-to/plan-a-phase.md) — run research, decompose work, and verify plan quality
- [Verify a dependency-compatibility claim](how-to/verify-a-dependency-compatibility-claim.md) — act on a compatibility claim the researcher left `[ASSUMED]`, and tell "nothing declared" apart from "a constraint is declared" and "the lookup failed"
- [Execute a phase](how-to/execute-a-phase.md) — run plans in parallel waves with fresh-context subagents
- [Enable parallel reviewer lanes](how-to/enable-parallel-reviewer-lanes.md) — cut a multi-reviewer `/gsd-review` pass toward its slowest lane, and tell a rate-limited lane apart from one that was never selected
- [Verify and ship](how-to/verify-and-ship.md) — walk through completed work, diagnose failures, and create the PR
- [Catch complexity before it compounds](how-to/act-on-a-refactor-proposal.md) — enable the post-execute refactor hook, read a proposal's score vs. anchor delta, and accept or decline it
- [Run phases autonomously](how-to/run-phases-autonomously.md) — use autonomous mode for unattended phase execution

View File

@@ -0,0 +1,131 @@
# How to enable parallel reviewer lanes
Cut the wall-clock cost of a multi-reviewer `/gsd-review` pass from the sum of its lanes toward
its slowest lane — without losing the rate-limit protection the sequential default exists to
provide.
> **Default-off, and deliberately so.** Reviewer lanes are dispatched one at a time because
> concurrent invocation can trip provider rate limits, and a lane lost to a rate limit is a
> cross-AI review that quietly went blind in one eye. Turning this on is you asserting that your
> providers can take the concurrency. Nothing detects that for you.
**What you need:**
- Two or more reviewer lanes that actually run on this host. With one lane there is nothing to
overlap and the setting changes nothing.
- Provider capacity for concurrent requests — separate accounts, generous quota, or local model
servers (`ollama`, `lm-studio`, `llama-cpp`) that have no external limit at all.
---
## Step 1 — Check what your review pass actually runs
Parallelism only helps if several lanes are selected. Confirm the set first:
```bash
gsd config-get review.default_reviewers
```
If that returns `Key not found`, no-flag runs use every detected reviewer and `--all` is
redundant. If it names a single reviewer, stop here — enabling the key would change nothing.
---
## Step 2 — Turn the key on
```bash
gsd config-set review.parallel_lanes true
```
Verify it took:
```bash
gsd config-get review.parallel_lanes --raw
# → true
```
**The guard is strict equality.** Only the exact value `true` opts in. `"1"`, `"yes"`, `"on"` and
`"TRUE"` are all read as *not enabled* and leave dispatch sequential. This is intentional: a
mistyped config gets the conservative behavior rather than firing concurrent requests at a
rate-limited provider. If `config-get` shows anything other than `true`, the setting is off.
---
## Step 3 — Run a review and read the result
```bash
/gsd-review --phase 3 --all
```
or, for the convergence loop:
```bash
/gsd-plan-review-convergence 3 --all
```
Open `{phase_dir}/{padded_phase}-REVIEWS.md` and check the `reviewers:` frontmatter list. Every
lane you selected must appear there. That list is the contract: lanes are joined before the file
is rendered, so a missing reviewer means that lane did not produce a review — never that
aggregation ran early.
Section order in `REVIEWS.md`, and line order in the run's `gsd-review-lane-results.jsonl`, are
unchanged from sequential dispatch. They follow reviewer-selection order, not completion order,
so a diff of two runs shows no reordering churn.
---
## Telling the outcomes apart
The single most useful habit: **an empty or stub review is a dropped lane, not a clean review.**
That is true sequentially too, but concurrency gives you more ways to drop one at once.
| What you see | What it means | What to do |
|---|---|---|
| Every selected lane in `reviewers:`, all sections populated | Working as intended | Nothing |
| A lane's section carries the "failed or returned empty output" header | The lane ran and produced nothing usable. Read the captured stderr in the stub | If it names a rate limit or quota, your provider cannot take this concurrency — see below |
| A lane reports `probe_timeout` or `host_unreachable` | The lane could not be reached at all — a local server that is down, not a concurrency effect | Start the server; unrelated to this setting |
| A lane reports `budget_too_small` | Its prompt budget cannot fit the minimum review set | Raise `review.max_prompt_tokens_per_reviewer.<slug>`; unrelated to this setting |
| A lane reports `egress_host_changed` | The lane was consented to one destination and the config now names another. It is blocked, not redirected | Re-consent deliberately; unrelated to this setting |
| A lane is missing from `reviewers:` entirely | It was never selected | Check your flags and `review.default_reviewers` |
| Several lanes stub out at once, with provider errors | The concurrency is more than your account can take | Turn the key back off, or narrow `review.default_reviewers` |
**Rate-limited lanes fail loudly.** A lane that gets throttled goes down the same path as any
other failing lane — a diagnostic stub carrying its stderr, kept distinguishable from a real
review by its header. It is not silently dropped and it does not abort its sibling lanes.
---
## Turning it back off
```bash
gsd config-set review.parallel_lanes false
```
The next pass dispatches sequentially again. Nothing else changes: no artifact written under the
parallel setting needs migrating, because the output layout is identical in both modes.
---
## What this does not speed up
**Convergence cycles stay sequential.** `/gsd-plan-review-convergence` runs
`plan-phase → review → replan → re-review`, and each cycle genuinely depends on the previous
one's output. Enabling this key makes each *review pass* inside a cycle faster; it does not
reduce the number of cycles, and it does not overlap planning with reviewing. A three-cycle
convergence run is still three sequential rounds.
**There is no concurrency bound.** Every selected lane dispatches at once. Eleven selected lanes
means eleven concurrent requests. If that is more than you want, the control is the size of your
selected set — `review.default_reviewers` or explicit flags — not a throttle on this setting.
**Reviewer instances sharing an adapter are not grouped.** Two
[`review.reviewer_instances`](../CONFIGURATION.md#reviewer-instances-for-gsd-review-1517) entries
backed by the same CLI dispatch concurrently against that one provider. If you run several
same-provider instances, you are the most likely configuration to hit a limit, and this setting
gives you no way to serialize just those.
---
**See also:** [Configuration reference](../CONFIGURATION.md#parallel-reviewer-lanes-for-gsd-review-3034)
· [Reviewer instances](../CONFIGURATION.md#reviewer-instances-for-gsd-review-1517)
· [`/gsd-review` command reference](../COMMANDS.md)

View File

@@ -219,7 +219,7 @@ WebSearch から取得したパッケージは `[ASSUMED]`(`[VERIFIED]` では
|-----------------|----------|-------------|
| `N` | **Yes** | 計画およびレビューするフェーズ番号 |
| レビュアーフラグ | No | すべてのレビュアーレーンフラグをそのまま渡す: `--gemini`、`--claude`、`--codex`、`--coderabbit`、`--opencode`、`--qwen`、`--cursor`、`--agy` / `--antigravity`、`--ollama`、`--lm-studio`、`--llama-cpp`、`--kimi-code` |
| `--all` | No | 設定済みのすべてのレビュアーを並列で実行 |
| `--all` | No | 設定済みのすべてのレビュアーを実行。レーンはデフォルトでは**順次**ディスパッチされます。`review.parallel_lanes` を `true` にすると、1 回のレビューパス内で並行してディスパッチされます |
| `--max-cycles N` | No | サイクル上限を上書き(デフォルト3) |
**終了動作:** HIGH カウントがゼロになるとループが終了します。HIGH カウントがサイクル間で減少しない場合はストール検出が警告します。`--max-cycles` に達しても HIGH 懸念が残っている場合、エスカレーションゲートがユーザーに続行するか手動でレビューするかを確認します。

View File

@@ -219,7 +219,7 @@ WebSearch에서 가져온 패키지는 `[ASSUMED]`(`[VERIFIED]`가 아님)로
|-----------------|----------|-------------|
| `N` | **예** | 계획 및 리뷰할 단계 번호 |
| 리뷰어 플래그 | 아니요 | 모든 리뷰어 레인 플래그를 그대로 전달: `--gemini`, `--claude`, `--codex`, `--coderabbit`, `--opencode`, `--qwen`, `--cursor`, `--agy` / `--antigravity`, `--ollama`, `--lm-studio`, `--llama-cpp`, `--kimi-code` |
| `--all` | 아니요 | 구성된 모든 리뷰어를 병렬로 실행 |
| `--all` | 아니요 | 구성된 모든 리뷰어를 실행합니다. 레인은 기본적으로 **순차적으로** 디스패치되며, `review.parallel_lanes`를 `true`로 설정하면 단일 리뷰 패스 내에서 동시에 디스패치됩니다 |
| `--max-cycles N` | 아니요 | 사이클 상한 재정의 (기본값 3) |
**종료 동작:** HIGH 카운트가 0이 되면 루프 종료. 사이클 간 HIGH 카운트가 감소하지 않을 때 정체 감지 경고. `--max-cycles`에 도달해도 HIGH 우려사항이 남아 있으면 에스컬레이션 게이트가 계속 진행하거나 수동 리뷰를 요청합니다.

View File

@@ -219,7 +219,7 @@ Loop de convergência de planos cross-AI — replaneja com feedback de revisão
|------------------|-------------|-----------|
| `N` | **Sim** | Número da fase a planejar e revisar |
| Flags de revisor | Não | Repassa todas as flags de lane de revisor: `--gemini`, `--claude`, `--codex`, `--coderabbit`, `--opencode`, `--qwen`, `--cursor`, `--agy` / `--antigravity`, `--ollama`, `--lm-studio`, `--llama-cpp`, `--kimi-code` |
| `--all` | Não | Executa todos os revisores configurados em paralelo |
| `--all` | Não | Executa todos os revisores configurados. As lanes são despachadas **sequencialmente** por padrão; defina `review.parallel_lanes` como `true` para despachá-las simultaneamente em uma única passagem de revisão |
| `--max-cycles N` | Não | Substitui o limite de ciclos (padrão 3) |
**Comportamento de saída:** O loop termina quando a contagem HIGH chega a zero. A detecção de estagnação avisa quando a contagem HIGH não diminui entre ciclos. O portão de escalação solicita ao usuário que prossiga ou revise manualmente quando `--max-cycles` é atingido com preocupações HIGH ainda em aberto.

View File

@@ -219,7 +219,7 @@ v1.40 中,六个命名空间路由器作为第一阶段入口点随附发布
|-----------------|----------|-------------|
| `N` | **是** | 要规划和审查的阶段编号 |
| 审查者标志 | 否 | 原样传递所有审查者通道标志:`--gemini`、`--claude`、`--codex`、`--coderabbit`、`--opencode`、`--qwen`、`--cursor`、`--agy` / `--antigravity`、`--ollama`、`--lm-studio`、`--llama-cpp`、`--kimi-code` |
| `--all` | 否 | 并行运行所有已配置的审查者 |
| `--all` | 否 | 运行所有已配置的审查者。审查通道默认**顺序**分发;将 `review.parallel_lanes` 设为 `true` 可在单次审查中并发分发 |
| `--max-cycles N` | 否 | 覆盖循环上限(默认 3) |
**退出行为:** HIGH 计数归零时循环退出。停滞检测在 HIGH 计数在各循环间未减少时发出警告。当达到 `--max-cycles` 且仍有 HIGH 问题未解决时,升级门询问用户是继续还是手动审查。