* test(#3777): add failing-first coverage for concurrent per-plan planner dispatch Extracts and executes the real bash blocks this PR is about to add to plan-phase.md and chunked-planning-mode.md (CHUNKED_PARALLEL resolution and the BATCH_PLAN_IDS dedup guard), plus config-set/config-get coverage for the new planning.chunked_parallel key. Expected RED against the current shipped workflow text — the extraction anchors do not exist yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(#3777): dispatch chunked mode's per-plan planners concurrently within a Wave Adds opt-in planning.chunked_parallel (default false, byte-identical to the existing serial loop). When true and the runtime's negotiated dispatch capacity (dispatch-capacity, #3673) is greater than 1, chunked planning's per-plan Tasks that share one outline Wave are issued together instead of one at a time; a later Wave still waits for the current one to be verified on disk and committed. A host with no declared maxConcurrency (most non-Claude runtimes today) stays serial regardless of the setting. Resolution and the Plan-ID dedup guard live in chunked-planning-mode.md itself (gated on the section's own CHUNKED_MODE skip-check) rather than in plan-phase.md, so a non-chunked run pays no extra gsd_run calls. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test(#3777): repoint extraction at chunked-planning-mode.md after the move CHUNKED_PARALLEL resolution moved out of plan-phase.md into chunked-planning-mode.md itself (see the preceding commit); update the test's extraction path and header comment to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3777): relocate the canonical runtime-launcher preamble before its first use The CHUNKED_PARALLEL resolution block's two gsd_run calls landed earlier in the file than the sole existing preamble (in the commit step), which tests/runtime-launcher-parity.test.cjs's (B) check requires to precede every gsd_run call in the file. Move the preamble (not duplicate it) to the top of the resolution block; the commit step's fenced block now just calls gsd_run directly. Caught by the GREEN checkpoint gsd-test run before push. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#3777): strip the canonical preamble from the extracted resolution block The CHUNKED_PARALLEL resolution fence now carries the relocated runtime-launcher preamble as its first line (previous commit). Extracting the whole fence and running it after the test's own gsd_run stub let the embedded preamble's own resolver logic `unset -f gsd_run` and exit 1 before reaching the resolution logic, since no real gsd-tools.cjs exists in the temp script dir — every test calling runChunkedParallelResolution() failed. Strip the preamble (sourced from gsd-core/workflows/_runtime-launcher.snippet.sh, the same file scripts/sync-runtime-launcher.cjs treats as canonical) before splicing in the stub, so this suite tests only the resolution logic it is actually about. Caught by the post-rebase gsd-test run before push. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#3777): add the How-To page the phase gate requires Enablement is 2 commands (config-set, then --chunked), which this repo's own doc-quadrant gate flags as how-to-owed: a reference table cannot carry a sequence. Covers enablement, the dispatch-capacity gate's honest "most runtimes today: no effect" case, and the two accepted trade-offs. An earlier reasoning pass (recorded in .gsd/phase/.../70-docs.json before this commit) had incorrectly claimed #3034 shipped with no equivalent how-to page, as precedent for skipping one here. That claim was false — docs/how-to/enable-parallel-reviewer-lanes.md exists and is indexed. The phase gate caught the omission before merge; corrected here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#3777): backfill changeset PR number Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
343 lines
14 KiB
JavaScript
343 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Failing-first tests for #3777 (opt-in concurrent per-plan planners in
|
|
* chunked mode).
|
|
*
|
|
* Design: .gsd/phase/feat-3777-chunked-parallel-planners/40-design.md
|
|
* Test matrix: .gsd/phase/feat-3777-chunked-parallel-planners/50-test-matrix.md
|
|
*
|
|
* Two real, shipped bash blocks are extracted and EXECUTED (never re-typed), both from
|
|
* chunked-planning-mode.md:
|
|
* 1. the `CHUNKED_PARALLEL` resolution (config x dispatch-capacity), read once per
|
|
* chunked run, ahead of §8.5.1, so a non-chunked run never pays for it.
|
|
* 2. the `BATCH_PLAN_IDS` dedup guard (§8.5.2 step 1).
|
|
* Wave grouping itself is orchestrator (LLM) comprehension, not a bash block —
|
|
* see the design doc's "Known limits" — so it has no extraction test here.
|
|
*/
|
|
|
|
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 { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
const CHUNKED_MODE_MD_PATH = path.join(
|
|
REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase', 'steps', 'chunked-planning-mode.md',
|
|
);
|
|
const RUNTIME_LAUNCHER_SNIPPET_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', '_runtime-launcher.snippet.sh');
|
|
|
|
// ─── extraction (source-text-is-the-product) ──────────────────────────────
|
|
|
|
/** Finds the first ```bash/```sh fence in `content` whose text contains every string in `mustInclude`. */
|
|
function extractBashFenceContaining(content, mustInclude, label, filePath) {
|
|
const lines = content.split(/\r?\n/);
|
|
for (const fenced of scanFencedBlocks(lines)) {
|
|
if (fenced.closeLineIdx === -1) continue;
|
|
if (!['bash', 'sh'].includes((fenced.infoString || '').trim())) continue;
|
|
const block = lines.slice(fenced.openLineIdx + 1, fenced.closeLineIdx).join('\n');
|
|
if (mustInclude.every((s) => block.includes(s))) return block;
|
|
}
|
|
throw new Error(`extractBashFenceContaining: no fence matching ${label} found in ${filePath} (looked for ${JSON.stringify(mustInclude)})`);
|
|
}
|
|
|
|
/**
|
|
* The canonical runtime-launcher preamble (source of truth for
|
|
* scripts/sync-runtime-launcher.cjs) sits as the first line of the
|
|
* CHUNKED_PARALLEL resolution fence in production (tests/runtime-launcher-parity.test.cjs
|
|
* owns verifying its placement/uniqueness). Strip it here so this suite tests
|
|
* only the resolution logic it's actually about, not the preamble's own
|
|
* gsd_run-shim-resolution behavior (which would stomp this file's `gsd_run`
|
|
* stub — see #3777 investigation).
|
|
*/
|
|
function stripRuntimeLauncherPreamble(block) {
|
|
const preamble = readFileNormalized(RUNTIME_LAUNCHER_SNIPPET_PATH).replace(/\n+$/, '');
|
|
if (!block.includes(preamble)) {
|
|
throw new Error('stripRuntimeLauncherPreamble: canonical preamble not found in extracted block — extraction anchor or preamble content may have drifted');
|
|
}
|
|
return block.split(preamble).join('').replace(/^\s+/, '');
|
|
}
|
|
|
|
function extractChunkedParallelResolution() {
|
|
const content = readFileNormalized(CHUNKED_MODE_MD_PATH);
|
|
const block = extractBashFenceContaining(
|
|
content,
|
|
['CHUNKED_PARALLEL_CFG', 'DISPATCH_CAPACITY', 'CHUNKED_PARALLEL='],
|
|
'#3777 CHUNKED_PARALLEL resolution',
|
|
CHUNKED_MODE_MD_PATH,
|
|
);
|
|
return stripRuntimeLauncherPreamble(block);
|
|
}
|
|
|
|
function extractBatchPlanIdsDedup() {
|
|
const content = readFileNormalized(CHUNKED_MODE_MD_PATH);
|
|
return extractBashFenceContaining(
|
|
content,
|
|
['BATCH_PLAN_IDS=', 'WAVE_PLAN_IDS'],
|
|
'#3777 BATCH_PLAN_IDS dedup guard',
|
|
CHUNKED_MODE_MD_PATH,
|
|
);
|
|
}
|
|
|
|
// ─── runners ────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Runs the real extracted CHUNKED_PARALLEL resolution block with a `gsd_run`
|
|
* stub spliced in front of it. Returns the resolved `CHUNKED_PARALLEL` value
|
|
* as a string ("true"/"false"), exactly as production code reads it.
|
|
*/
|
|
function runChunkedParallelResolution(t, opts) {
|
|
const scriptDir = createTempDir('gsd-3777-script-');
|
|
t.after(() => cleanup(scriptDir));
|
|
|
|
const block = extractChunkedParallelResolution();
|
|
|
|
const stub = [
|
|
'gsd_run() {',
|
|
' if [ "$1" = "query" ] && [ "$2" = "config-get" ] && [ "$3" = "planning.chunked_parallel" ]; then',
|
|
' if [ "$STUB_CONFIG_GET_FAILS" = "1" ]; then',
|
|
' return 1',
|
|
' fi',
|
|
' printf %s "$STUB_CONFIG_VALUE"',
|
|
' return 0',
|
|
' fi',
|
|
' if [ "$1" = "query" ] && [ "$2" = "dispatch-capacity" ]; then',
|
|
' if [ "$STUB_CAPACITY_FAILS" = "1" ]; then',
|
|
' return 1',
|
|
' fi',
|
|
' printf %s "$STUB_CAPACITY_VALUE"',
|
|
' return 0',
|
|
' fi',
|
|
' return 0',
|
|
'}',
|
|
].join('\n');
|
|
|
|
const script = [
|
|
'#!/usr/bin/env bash',
|
|
'set -u',
|
|
stub,
|
|
block,
|
|
'printf "RESULT:%s" "$CHUNKED_PARALLEL"',
|
|
].join('\n');
|
|
|
|
const scriptPath = path.join(scriptDir, 'resolve.sh');
|
|
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
|
|
|
|
const env = {
|
|
...process.env,
|
|
STUB_CONFIG_VALUE: opts.configValue === null || opts.configValue === undefined ? '' : opts.configValue,
|
|
STUB_CONFIG_GET_FAILS: opts.configGetFails ? '1' : '0',
|
|
STUB_CAPACITY_VALUE: opts.capacityValue === null || opts.capacityValue === undefined ? '' : String(opts.capacityValue),
|
|
STUB_CAPACITY_FAILS: opts.capacityFails ? '1' : '0',
|
|
};
|
|
|
|
const result = runHook(scriptPath, [], {
|
|
interpreter: 'bash',
|
|
cwd: scriptDir,
|
|
env,
|
|
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
|
|
});
|
|
|
|
const stdout = result.stdout || '';
|
|
const match = /RESULT:(\S*)/.exec(stdout);
|
|
return {
|
|
outcome: result.outcome,
|
|
exitCode: result.exitCode,
|
|
stderr: result.stderr,
|
|
chunkedParallel: match ? match[1] : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Runs the real extracted BATCH_PLAN_IDS dedup block with `WAVE_PLAN_IDS`
|
|
* seeded from `waveIds` (already space-separated, matching how the
|
|
* orchestrator populates it from outline rows).
|
|
*/
|
|
function runBatchDedup(t, waveIds) {
|
|
const scriptDir = createTempDir('gsd-3777-dedup-script-');
|
|
t.after(() => cleanup(scriptDir));
|
|
|
|
const block = extractBatchPlanIdsDedup();
|
|
|
|
const script = [
|
|
'#!/usr/bin/env bash',
|
|
'set -u',
|
|
`WAVE_PLAN_IDS='${waveIds}'`,
|
|
block,
|
|
'printf "RESULT:%s" "$BATCH_PLAN_IDS"',
|
|
].join('\n');
|
|
|
|
const scriptPath = path.join(scriptDir, 'dedup.sh');
|
|
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
|
|
|
|
const result = runHook(scriptPath, [], {
|
|
interpreter: 'bash',
|
|
cwd: scriptDir,
|
|
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
|
|
});
|
|
|
|
const stdout = result.stdout || '';
|
|
const match = /RESULT:(.*)$/.exec(stdout);
|
|
const batch = match ? match[1].trim() : '';
|
|
return {
|
|
outcome: result.outcome,
|
|
exitCode: result.exitCode,
|
|
stderr: result.stderr,
|
|
batchIds: batch.length > 0 ? batch.split(/\s+/) : [],
|
|
};
|
|
}
|
|
|
|
// ─── #1/#2 — default and explicit-disabled stay serial ────────────────────
|
|
|
|
describe('#3777 default and explicit-disabled CHUNKED_PARALLEL resolution stays serial', () => {
|
|
test('defaultsToSerialWhenKeyUnset', (t) => {
|
|
const result = runChunkedParallelResolution(t, { configValue: null, capacityValue: 20 });
|
|
assert.equal(result.outcome, 'exited');
|
|
assert.equal(result.chunkedParallel, 'false');
|
|
});
|
|
|
|
test('staysSerialWhenExplicitlyDisabled', (t) => {
|
|
const result = runChunkedParallelResolution(t, { configValue: 'false', capacityValue: 20 });
|
|
assert.equal(result.chunkedParallel, 'false');
|
|
});
|
|
});
|
|
|
|
// ─── #3 — opt-in with sufficient capacity ──────────────────────────────────
|
|
|
|
describe('#3777 opt-in with sufficient dispatch capacity', () => {
|
|
test('enablesParallelWhenConfigTrueAndCapacityAboveOne', (t) => {
|
|
const result = runChunkedParallelResolution(t, { configValue: 'true', capacityValue: 20 });
|
|
assert.equal(result.chunkedParallel, 'true');
|
|
});
|
|
});
|
|
|
|
// ─── #4/#5/#6 — capacity boundary (limit-1, limit, limit+1) ────────────────
|
|
|
|
describe('#3777 dispatch-capacity boundary gates the opt-in', () => {
|
|
test('staysSerialWhenCapacityIsOne', (t) => {
|
|
const result = runChunkedParallelResolution(t, { configValue: 'true', capacityValue: 1 });
|
|
assert.equal(result.chunkedParallel, 'false', 'capacity=1 (the fail-closed floor) must degrade to serial even when the config opts in');
|
|
});
|
|
|
|
test('enablesParallelAtCapacityTwo', (t) => {
|
|
const result = runChunkedParallelResolution(t, { configValue: 'true', capacityValue: 2 });
|
|
assert.equal(result.chunkedParallel, 'true', 'capacity=2 is already above the floor and must enable concurrent dispatch');
|
|
});
|
|
|
|
test('staysSerialWhenCapacityIsZero', (t) => {
|
|
// Defensive: routeDispatchCapacity never legitimately emits 0, but the
|
|
// resolution's own arithmetic comparison must not misbehave on it either.
|
|
const result = runChunkedParallelResolution(t, { configValue: 'true', capacityValue: 0 });
|
|
assert.equal(result.chunkedParallel, 'false');
|
|
});
|
|
});
|
|
|
|
// ─── #7 — non-canonical truthy values stay serial ──────────────────────────
|
|
|
|
describe('#3777 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(`chunked_parallel="${value}"`, (t2) => {
|
|
const result = runChunkedParallelResolution(t2, { configValue: value, capacityValue: 20 });
|
|
assert.equal(result.chunkedParallel, 'false', `value "${value}" must not opt into parallel dispatch`);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── #8/#9 — broken tooling fails safe to serial ───────────────────────────
|
|
|
|
describe('#3777 broken config/capacity tooling fails safe to serial', () => {
|
|
test('configGetFailureFallsBackToSerial', (t) => {
|
|
const result = runChunkedParallelResolution(t, { configValue: 'true', capacityValue: 20, configGetFails: true });
|
|
assert.equal(result.chunkedParallel, 'false');
|
|
});
|
|
|
|
test('capacityQueryFailureFallsBackToSerial', (t) => {
|
|
const result = runChunkedParallelResolution(t, { configValue: 'true', capacityValue: 20, capacityFails: true });
|
|
assert.equal(result.chunkedParallel, 'false');
|
|
});
|
|
});
|
|
|
|
// ─── #10/#11/#12 — BATCH_PLAN_IDS dedup guard ──────────────────────────────
|
|
|
|
describe('#3777 duplicate Plan ID in a Wave dispatches once', () => {
|
|
test('duplicatePlanIdInBatchDispatchesOnce', (t) => {
|
|
const result = runBatchDedup(t, '03-01 03-02 03-01');
|
|
assert.equal(result.outcome, 'exited');
|
|
assert.deepEqual(result.batchIds, ['03-01', '03-02'], 'no two plans in a parallel batch may declare the same output path');
|
|
});
|
|
});
|
|
|
|
describe('#3777 batch dedup preserves outline row order', () => {
|
|
test('preservesOutlineOrderInBatch', (t) => {
|
|
const result = runBatchDedup(t, '03-03 03-01 03-02');
|
|
assert.deepEqual(result.batchIds, ['03-03', '03-01', '03-02']);
|
|
});
|
|
});
|
|
|
|
describe('#3777 an empty Wave produces an empty batch', () => {
|
|
test('emptyWaveProducesEmptyBatch', (t) => {
|
|
const result = runBatchDedup(t, '');
|
|
assert.equal(result.outcome, 'exited');
|
|
assert.deepEqual(result.batchIds, []);
|
|
});
|
|
});
|
|
|
|
// ─── #13/#14/#15 — config-set registers planning.chunked_parallel ─────────
|
|
|
|
describe('#3777 planning.chunked_parallel config key', () => {
|
|
test('configSetAcceptsAndPersistsChunkedParallel', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const setResult = runGsdTools('config-set planning.chunked_parallel 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.planning?.chunked_parallel, true);
|
|
assert.equal(typeof config.planning?.chunked_parallel, 'boolean');
|
|
|
|
const getResult = runGsdTools('config-get planning.chunked_parallel --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 planning.chunked_parallel 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.planning?.chunked_parallel, false);
|
|
assert.equal(typeof config.planning?.chunked_parallel, 'boolean');
|
|
});
|
|
|
|
test('rejectsUnregisteredNeighbouringKey', (t) => {
|
|
const tmpDir = createTempProject();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
// Extra "l" — 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 planning.chunked_parallell true', tmpDir);
|
|
assert.equal(result.success, false, 'an unregistered near-miss key must be rejected');
|
|
});
|
|
});
|