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

@@ -0,0 +1,8 @@
{
"version": 1,
"paths": {
"review.md": {
"reason": "#3034 adds opt-in concurrent reviewer-lane dispatch to the invoke_reviewers step. The growth is the feature plus the reasoning that has to travel with it, because this block is shipped shell that is read by an agent at runtime rather than by a compiler: the strict-equality guard and its fail-safe polarity (deliberately inverted relative to the commit_docs guard, so it does not read as an inconsistency to be tidied away), why per-lane result files replaced a shared append (concurrent O_APPEND is atomic only below PIPE_BUF, and write_reviews parses that JSONL to render the models:/model_sources: frontmatter), why aggregation walks selection order rather than completion order, why the slug list is de-duplicated before dispatch, and why the loop body was hoisted into one shared function instead of forking into two dispatch bodies. No prose was moved into an eagerly @-imported reference to shrink the measured file -- total loaded context grew by exactly this diff."
}
}
}

View File

@@ -0,0 +1,570 @@
'use strict';
/**
* Failing-first tests for #3034 (opt-in parallel reviewer lanes).
*
* Design: .gsd/phase/feat-3034-parallel-reviewer-lanes/40-design.md
* Test matrix: .gsd/phase/feat-3034-parallel-reviewer-lanes/50-test-matrix.md
*
* The unit under test is the real, shipped `<step name="invoke_reviewers">`
* fenced bash block in gsd-core/workflows/review.md — extracted and EXECUTED
* (never re-typed), with the single I/O seam `gsd_run` replaced by a shell
* function stub. The feature this file exercises (an opt-in
* `review.parallel_lanes` config key that backgrounds lane dispatch and
* joins before aggregation) does not exist yet, so several tests below are
* expected to be RED against the current shipped block.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {
createTempDir,
createTempProject,
cleanup,
readFileNormalized,
runGsdTools,
} = require('./helpers.cjs');
const { runHook } = require('./helpers/process-seam.cjs');
const { HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const REVIEW_MD_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'review.md');
const SELECTED_3 = 'codex,gemini,claude';
// ─── extraction (source-text-is-the-product) ──────────────────────────────
/**
* Reads review.md and extracts the fenced bash block inside
* `<step name="invoke_reviewers">`. Mirrors extractCwdGuardBash() in
* tests/worktree-cleanup.test.cjs: readFileNormalized() strips CRLF at the
* read boundary (what actually makes the captured body safe to hand to
* bash); the `\r?\n` in the fence regex below is redundant on that
* already-normalized input but kept anyway because a bare `\n` in a
* markdown-fence-shaped regex trips the local/no-crlf-fragile-split rule.
*/
function extractInvokeReviewersBash() {
const content = readFileNormalized(REVIEW_MD_PATH);
const stepMarker = '<step name="invoke_reviewers">';
const stepIdx = content.indexOf(stepMarker);
if (stepIdx === -1) {
throw new Error(`extractInvokeReviewersBash: could not find "${stepMarker}" in ${REVIEW_MD_PATH}`);
}
const afterStep = content.slice(stepIdx + stepMarker.length);
const endMarker = '</step>';
const endIdx = afterStep.indexOf(endMarker);
if (endIdx === -1) {
throw new Error(`extractInvokeReviewersBash: could not find closing "${endMarker}" after invoke_reviewers in ${REVIEW_MD_PATH}`);
}
const stepBody = afterStep.slice(0, endIdx);
const fenceRe = /```(?:bash|sh)\r?\n([\s\S]*?)```/;
const fenceMatch = fenceRe.exec(stepBody);
if (!fenceMatch) {
throw new Error(`extractInvokeReviewersBash: no \`\`\`bash fence found inside invoke_reviewers step in ${REVIEW_MD_PATH}`);
}
const block = fenceMatch[1];
if (!block.trim()) {
throw new Error('extractInvokeReviewersBash: extracted bash block is empty');
}
if (!block.includes('review-lane invoke')) {
throw new Error('extractInvokeReviewersBash: extracted block does not contain "review-lane invoke" — anchor may have drifted');
}
if (!block.includes('SELECTED_REVIEWERS')) {
throw new Error('extractInvokeReviewersBash: extracted block does not contain "SELECTED_REVIEWERS" — anchor may have drifted');
}
return block;
}
// ─── stub preamble ─────────────────────────────────────────────────────────
//
// The ONLY I/O seam the extracted block calls is `gsd_run`. Everything else
// executed by runDispatch() is the real shipped shell text. `barrier` is the
// positive, deterministic concurrency proof this suite uses in place of any
// elapsed-time assertion (CLAUDE.md bans wall-clock assertions).
const STUB_PREAMBLE = [
'arg_after() {',
' local flag="$1"; shift',
' while [ $# -gt 0 ]; do',
' if [ "$1" = "$flag" ]; then',
' printf %s "$2"',
' return 0',
' fi',
' shift',
' done',
'}',
'',
'in_list() {',
' local needle="$1" list="$2" item old_ifs="$IFS"',
' IFS=","',
' for item in $list; do',
' if [ "$item" = "$needle" ]; then',
' IFS="$old_ifs"',
' return 0',
' fi',
' done',
' IFS="$old_ifs"',
' return 1',
'}',
'',
'dep_of() {',
' local slug="$1" pair old_ifs="$IFS"',
' IFS=","',
' for pair in $STUB_DEPS; do',
' case "$pair" in',
' "$slug="*)',
' printf %s "${pair#*=}"',
' IFS="$old_ifs"',
' return 0',
' ;;',
' esac',
' done',
' IFS="$old_ifs"',
'}',
'',
'wait_for_file() {',
' local target="$1" i=0',
' while [ ! -f "$target" ] && [ "$i" -lt 200 ]; do',
' sleep 0.05',
' i=$((i + 1))',
' done',
'}',
'',
'barrier() {',
' local slug="$1" i=0 count',
' touch "$BARRIER_DIR/$slug"',
' count=$(ls -1 "$BARRIER_DIR" | wc -l)',
' while [ "$count" -lt "$LANE_COUNT" ] && [ "$i" -lt 200 ]; do',
' sleep 0.05',
' i=$((i + 1))',
' count=$(ls -1 "$BARRIER_DIR" | wc -l)',
' done',
' if [ "$count" -lt "$LANE_COUNT" ]; then',
' echo "barrier-timeout:$slug" >> "$TRACE"',
' return 1',
' fi',
' return 0',
'}',
'',
'gsd_run() {',
' if [ "$1" = "query" ] && [ "$2" = "config-get" ] && [ "$3" = "review.parallel_lanes" ]; then',
' if [ "$STUB_CONFIG_GET_FAILS" = "1" ]; then',
' return 1',
' fi',
' printf %s "$STUB_PARALLEL"',
' return 0',
' fi',
'',
' if [ "$1" = "query" ] && [ "$2" = "review-lane" ] && [ "$3" = "plan" ]; then',
' shift 3',
' local sel',
' sel="$(arg_after --selected "$@")"',
' if in_list "$sel" "$STUB_BUDGET_FAIL"; then',
' printf %s \'{"promptBudget": 10}\'',
' else',
' printf %s \'{"promptBudget": -1}\'',
' fi',
' return 0',
' fi',
'',
' if [ "$1" = "query" ] && [ "$2" = "prompt-budget" ]; then',
' shift 2',
' local out base slug',
' out="$(arg_after --output-prompt "$@")"',
' base="$(basename "$out")"',
' slug="${base#gsd-review-prompt-}"',
' slug="${slug%.md}"',
' if in_list "$slug" "$STUB_BUDGET_FAIL"; then',
' return 2',
' fi',
' : > "$out"',
' return 0',
' fi',
'',
' if [ "$1" = "query" ] && [ "$2" = "review-lane" ] && [ "$3" = "invoke" ]; then',
' shift 3',
' local slug dep pad',
' slug="$(arg_after --slug "$@")"',
' echo "start:$slug" >> "$TRACE"',
' if [ "$STUB_BARRIER" = "1" ]; then',
' barrier "$slug" || true',
' fi',
' dep="$(dep_of "$slug")"',
' if [ -n "$dep" ]; then',
' wait_for_file "$RUN_DIR/done-$dep"',
' fi',
' echo "stub review body for $slug" > "$RUN_DIR/gsd-review-$slug.md"',
' pad=""',
' if [ "$STUB_PAD_BYTES" -gt 0 ] 2>/dev/null; then',
' pad="$(head -c "$STUB_PAD_BYTES" /dev/zero | tr "\\0" "x")"',
' fi',
' if ! in_list "$slug" "$STUB_SILENT"; then',
' printf \'{"slug":"%s","pad":"%s"}\\n\' "$slug" "$pad"',
' fi',
' touch "$RUN_DIR/done-$slug"',
' echo "end:$slug" >> "$TRACE"',
' if in_list "$slug" "$STUB_FAIL"; then',
' return 1',
' fi',
' return 0',
' fi',
'',
' return 0',
'}',
].join('\n');
// ─── dispatch runner ───────────────────────────────────────────────────────
/**
* Build the stub env for a runDispatch() call from `opts`. Every key is
* always present (never omitted) so the generated `set -u` script never
* dereferences an unset variable — `opts.parallel === null` deliberately
* maps to the empty string, which is exactly how an unset config key reads
* back through `config-get --raw`.
*/
function buildEnv(opts, runDir, tracePath, barrierDir) {
const selected = opts.selected;
const laneCount = selected.split(',').filter((s) => s.length > 0).length;
const deps = opts.deps || {};
return {
...process.env,
SELECTED_REVIEWERS: selected,
EXPLICIT_FLAG: '',
STUB_PARALLEL: opts.parallel === null || opts.parallel === undefined ? '' : opts.parallel,
STUB_CONFIG_GET_FAILS: opts.configGetFails ? '1' : '0',
STUB_FAIL: (opts.failSlugs || []).join(','),
STUB_BUDGET_FAIL: (opts.budgetFailSlugs || []).join(','),
STUB_SILENT: (opts.silentSlugs || []).join(','),
STUB_BARRIER: opts.barrier ? '1' : '0',
STUB_DEPS: Object.entries(deps).map(([k, v]) => `${k}=${v}`).join(','),
STUB_PAD_BYTES: String(opts.padBytes || 0),
LANE_COUNT: String(laneCount),
TRACE: tracePath,
BARRIER_DIR: barrierDir,
};
}
/**
* Runs the real extracted invoke_reviewers bash block with the gsd_run stub
* spliced in front of it. `{run_dir}` is replaced globally with a real temp
* directory. Returns the dispatch outcome plus the artifacts it produced.
*/
function runDispatch(t, opts) {
const scriptDir = createTempDir('gsd-3034-script-');
const runDir = createTempDir('gsd-3034-rundir-');
const barrierDir = createTempDir('gsd-3034-barrier-');
t.after(() => {
cleanup(scriptDir);
cleanup(runDir);
cleanup(barrierDir);
});
const block = extractInvokeReviewersBash();
const tracePath = path.join(scriptDir, 'trace.log');
const env = buildEnv(opts, runDir, tracePath, barrierDir);
const scriptPath = path.join(scriptDir, 'dispatch.sh');
const script = [
'#!/usr/bin/env bash',
'set -u',
STUB_PREAMBLE,
block.split('{run_dir}').join(runDir),
].join('\n');
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
const result = runHook(scriptPath, [], {
interpreter: 'bash',
cwd: runDir,
env,
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
});
const jsonlPath = path.join(runDir, 'gsd-review-lane-results.jsonl');
const jsonl = fs.existsSync(jsonlPath) ? fs.readFileSync(jsonlPath, 'utf-8') : '';
const lines = jsonl.split('\n').filter((l) => l.trim() !== '');
const trace = fs.existsSync(tracePath)
? readFileNormalized(tracePath).split('\n').filter((l) => l.trim() !== '')
: [];
return {
outcome: result.outcome,
exitCode: result.exitCode,
stderr: result.stderr,
jsonl,
lines,
trace,
runDir,
};
}
/** Parsed slug order from JSONL lines — never assert on raw JSONL text. */
function slugOrder(lines) {
return lines.map((l) => JSON.parse(l).slug);
}
function serialTrace(slugs) {
return slugs.flatMap((s) => [`start:${s}`, `end:${s}`]);
}
// ─── #1/#2 — default and explicit-disabled serial dispatch ───────────────
describe('#3034 default and explicit-disabled dispatch stays serial', () => {
test('defaultsToSerialDispatchWhenKeyUnset', (t) => {
const result = runDispatch(t, { selected: SELECTED_3, parallel: null });
assert.deepEqual(result.trace, serialTrace(['codex', 'gemini', 'claude']));
});
test('staysSerialWhenExplicitlyDisabled', (t) => {
const result = runDispatch(t, { selected: SELECTED_3, parallel: 'false' });
assert.deepEqual(result.trace, serialTrace(['codex', 'gemini', 'claude']));
});
});
// ─── #3/#4 — opt-in concurrency and join-before-aggregate ─────────────────
describe('#3034 opt-in concurrency', () => {
test('dispatchesLanesConcurrentlyWhenEnabled', (t) => {
const result = runDispatch(t, { selected: SELECTED_3, parallel: 'true', barrier: true });
const timeouts = result.trace.filter((l) => l.startsWith('barrier-timeout:'));
assert.deepEqual(timeouts, [], 'no lane should hit the barrier timeout when lanes run concurrently');
});
test('joinsAllLanesBeforeAggregation', (t) => {
// barrier:true is load-bearing, not decoration. Without it the stub lanes
// finish instantly and a missing `wait` could still race to 3 lines,
// making this pass intermittently. Held at the barrier, a missing join
// deterministically aggregates ZERO lines.
const result = runDispatch(t, { selected: SELECTED_3, parallel: 'true', barrier: true });
assert.equal(result.lines.length, 3, 'dispatch must return only after every lane wrote its result');
});
});
// ─── #5/#6 — selection-order preservation ─────────────────────────────────
describe('#3034 JSONL preserves selection order, not completion order', () => {
test('preservesSelectionOrderSerial', (t) => {
const result = runDispatch(t, { selected: SELECTED_3, parallel: null });
assert.deepEqual(slugOrder(result.lines), ['codex', 'gemini', 'claude']);
});
test('preservesSelectionOrderParallelDespiteCompletionOrder', (t) => {
// codex waits on gemini; gemini waits on claude -> forces reverse
// completion order (claude, gemini, codex) while selection order stays
// codex, gemini, claude.
const result = runDispatch(t, {
selected: SELECTED_3,
parallel: 'true',
deps: { codex: 'gemini', gemini: 'claude' },
});
const endMarkers = result.trace.filter((l) => l.startsWith('end:'));
// Pin the fixture actually forced reverse completion first — otherwise
// the selection-order assertion below would pass vacuously.
assert.deepEqual(endMarkers, ['end:claude', 'end:gemini', 'end:codex']);
assert.deepEqual(slugOrder(result.lines), ['codex', 'gemini', 'claude']);
});
});
// ─── #7/#8 — PIPE_BUF boundary triple (plus one clearly-oversized case) ───
describe('#3034 oversized lane results stay intact under concurrency', () => {
test('keepsOversizedLaneResultsIntactUnderConcurrency', async (t) => {
for (const padBytes of [4095, 4096, 4097, 8192]) {
await t.test(`padBytes=${padBytes}`, (t2) => {
const result = runDispatch(t2, { selected: SELECTED_3, parallel: 'true', padBytes });
assert.equal(result.lines.length, 3);
for (const line of result.lines) {
assert.doesNotThrow(() => JSON.parse(line), `line failed to parse at padBytes=${padBytes}: ${line.slice(0, 80)}...`);
}
assert.deepEqual(slugOrder(result.lines), ['codex', 'gemini', 'claude']);
});
}
});
});
// ─── #9 — single-lane boundary ─────────────────────────────────────────────
describe('#3034 single-lane boundary', () => {
test('singleLaneParallelMatchesSerial', (t) => {
const serial = runDispatch(t, { selected: 'codex', parallel: null });
const parallel = runDispatch(t, { selected: 'codex', parallel: 'true' });
assert.equal(serial.lines.length, 1);
assert.equal(parallel.lines.length, 1);
assert.deepEqual(slugOrder(serial.lines), slugOrder(parallel.lines));
const serialMd = fs.readFileSync(path.join(serial.runDir, 'gsd-review-codex.md'), 'utf-8');
const parallelMd = fs.readFileSync(path.join(parallel.runDir, 'gsd-review-codex.md'), 'utf-8');
assert.equal(serialMd, parallelMd);
});
});
// ─── #10 — empty selection is a no-op ──────────────────────────────────────
describe('#3034 empty selection', () => {
test('emptySelectionIsANoOp', (t) => {
const result = runDispatch(t, { selected: '', parallel: 'true' });
assert.equal(result.outcome, 'exited');
assert.equal(result.exitCode, 0);
assert.deepEqual(result.lines, []);
assert.deepEqual(result.trace, []);
});
});
describe('#3034 duplicate slug in the selection', () => {
test('duplicateSlugDispatchesOnceAndWritesOneLine', (t) => {
// A slug repeated in SELECTED_REVIEWERS would otherwise put two
// concurrent background jobs on the same `>`-truncated per-slug result
// file, corrupting whichever one finishes last. DISPATCH_SLUGS
// de-duplicates before dispatch, so codex must run exactly once.
const result = runDispatch(t, { selected: 'codex,gemini,codex', parallel: 'true' });
assert.equal(result.outcome, 'exited');
assert.equal(result.trace.filter((l) => l === 'start:codex').length, 1);
assert.equal(result.trace.filter((l) => l === 'end:codex').length, 1);
assert.deepEqual(slugOrder(result.lines), ['codex', 'gemini']);
});
});
// ─── #11/#12 — lane failure does not abort siblings ────────────────────────
describe('#3034 lane failure does not abort sibling lanes', () => {
test('laneFailureDoesNotAbortSiblingLanes', (t) => {
const result = runDispatch(t, { selected: SELECTED_3, parallel: 'true', failSlugs: ['gemini'] });
assert.equal(result.lines.length, 3, 'a failing lane still contributes its stub result line');
assert.ok(
fs.existsSync(path.join(result.runDir, 'gsd-review-gemini.md')),
'the failing lane\'s diagnostic stub .md must be preserved',
);
assert.deepEqual(slugOrder(result.lines), ['codex', 'gemini', 'claude']);
});
test('laneFailureSerialUnchanged', (t) => {
const result = runDispatch(t, { selected: SELECTED_3, parallel: null, failSlugs: ['gemini'] });
assert.deepEqual(result.trace, serialTrace(['codex', 'gemini', 'claude']));
assert.equal(result.lines.length, 3);
});
});
// ─── #13 — budget-too-small skip emits no result line ─────────────────────
describe('#3034 budget-too-small skip', () => {
test('budgetSkipEmitsNoResultLine', (t) => {
const result = runDispatch(t, { selected: SELECTED_3, parallel: 'true', budgetFailSlugs: ['gemini'] });
assert.deepEqual(slugOrder(result.lines), ['codex', 'claude'], 'the budget-skipped lane contributes no result line');
const stub = fs.readFileSync(path.join(result.runDir, 'gsd-review-gemini.md'), 'utf-8');
assert.match(stub, /skipped/);
assert.match(stub, /prompt budget/);
});
});
// ─── #14/#15 — silent / absent lane contributes no line ───────────────────
describe('#3034 silent lane contributes nothing, not a blank line', () => {
test('silentLaneContributesNoLine', (t) => {
const result = runDispatch(t, { selected: SELECTED_3, parallel: 'true', silentSlugs: ['gemini'] });
assert.deepEqual(slugOrder(result.lines), ['codex', 'claude']);
// Folded row #15 (absent result file): budgetSkipEmitsNoResultLine above
// already exercises the "invoke never started, no file at all" shape via
// its `continue`. This assertion pins the sibling shape: a lane that DID
// run and wrote nothing must not leave a stray blank JSONL line.
assert.ok(!result.jsonl.includes('\n\n'), 'an empty lane result must not leave a blank JSONL line');
});
});
// ─── #16/#17 — non-canonical truthy values stay serial ────────────────────
describe('#3034 non-canonical truthy config values stay serial', () => {
test('nonCanonicalTruthyValuesStaySerial', async (t) => {
const nearMisses = ['TRUE', 'True', '1', 'yes', 'on', ' true', 'true '];
for (const value of nearMisses) {
await t.test(`parallel_lanes="${value}"`, (t2) => {
const result = runDispatch(t2, { selected: SELECTED_3, parallel: value });
assert.deepEqual(result.trace, serialTrace(['codex', 'gemini', 'claude']), `value "${value}" must not opt into parallel dispatch`);
});
}
});
});
// ─── #18 — config-get failure fails safe to serial ─────────────────────────
describe('#3034 broken config tooling fails safe to serial', () => {
test('configGetFailureFallsBackToSerial', (t) => {
const result = runDispatch(t, { selected: SELECTED_3, parallel: 'true', configGetFails: true });
assert.deepEqual(result.trace, serialTrace(['codex', 'gemini', 'claude']));
});
});
// ─── #19/#20/#21 — config-set registers review.parallel_lanes ─────────────
describe('#3034 review.parallel_lanes config key', () => {
test('configSetAcceptsAndPersistsParallelLanes', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const setResult = runGsdTools('config-set review.parallel_lanes true', tmpDir);
assert.ok(setResult.success, `config-set failed: ${setResult.error}`);
const configPath = path.join(tmpDir, '.planning', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.equal(config.review?.parallel_lanes, true);
assert.equal(typeof config.review?.parallel_lanes, 'boolean');
const getResult = runGsdTools('config-get review.parallel_lanes --raw', tmpDir);
assert.ok(getResult.success, `config-get failed: ${getResult.error}`);
assert.equal((getResult.output || '').trim(), 'true');
});
test('configSetPersistsBooleanFalse', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const setResult = runGsdTools('config-set review.parallel_lanes false', tmpDir);
assert.ok(setResult.success, `config-set failed: ${setResult.error}`);
const configPath = path.join(tmpDir, '.planning', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.equal(config.review?.parallel_lanes, false);
assert.equal(typeof config.review?.parallel_lanes, 'boolean');
});
test('rejectsUnregisteredNeighbouringKey', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
// Missing trailing "s" — proves the whitelist is load-bearing and the
// two tests above are not vacuous (they'd pass even for an unregistered
// key if config-set accepted anything).
const result = runGsdTools('config-set review.parallel_lane true', tmpDir);
assert.equal(result.success, false, 'an unregistered near-miss key must be rejected');
});
});
// ─── #22 — serial/parallel artifact equivalence ────────────────────────────
describe('#3034 serial and parallel dispatch produce equivalent artifacts', () => {
test('serialAndParallelProduceEquivalentArtifacts', (t) => {
const serial = runDispatch(t, { selected: SELECTED_3, parallel: null });
const parallel = runDispatch(t, { selected: SELECTED_3, parallel: 'true' });
assert.deepEqual(slugOrder(serial.lines), slugOrder(parallel.lines));
assert.deepEqual(
serial.lines.map((l) => JSON.parse(l)),
parallel.lines.map((l) => JSON.parse(l)),
);
for (const slug of ['codex', 'gemini', 'claude']) {
const serialMd = fs.readFileSync(path.join(serial.runDir, `gsd-review-${slug}.md`), 'utf-8');
const parallelMd = fs.readFileSync(path.join(parallel.runDir, `gsd-review-${slug}.md`), 'utf-8');
assert.equal(serialMd, parallelMd, `gsd-review-${slug}.md must be byte-identical between the two paths`);
}
});
});