* 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>
5.9 KiB
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:
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
gsd config-set review.parallel_lanes true
Verify it took:
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
/gsd-review --phase 3 --all
or, for the convergence loop:
/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
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 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
· Reviewer instances
· /gsd-review command reference