* 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:
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
570
tests/review-parallel-lanes.test.cjs
Normal file
570
tests/review-parallel-lanes.test.cjs
Normal 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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user