Files
msd-core/tests/bug-853-bg-dispatch-runtime-gating.test.cjs
Tom Boucher cf2e66b39e feat(#1708): typed documentation-sourced #853 dispatch-flatten (ADR-1239 Phase B) (#1719)
* feat(#1708): typed documentation-sourced #853 dispatch-flatten

Graduate the #853 orchestrator-backgrounding decision from a scattered RUNTIME==='codex' prose check to a typed, documentation-sourced engine decision. Adds a backgroundDispatch dispatch sub-axis (sourced per host: codex+cursor documented true, 9 documented false, 5 undocumented), shouldFlattenDispatch(dispatch) (inline UNLESS background && backgroundDispatch, fail-closed), and a gsd_run query dispatch-should-flatten the plan/execute workflows call. Cursor is newly background-eligible per its docs (inline->background) — a documentation-justified behavior change. No RUNTIME-name residue for this decision.

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

* docs(#1708): backgroundDispatch citations in matrix + CONTEXT note

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

* fix(#1708): address review findings on typed dispatch-flatten

Code/adversarial review: convert the manager.md/autonomous.md Compound Action preamble from hardcoded 'On Codex' to FLATTEN-based branching (the handlers already use the query; the preamble contradicted them and was wrong for cursor); make shouldFlattenDispatch null-safe + type-honest (accepts raw 'undocumented' registry values); make backgroundDispatch a required descriptor field (matching its siblings, all 16 carry it); strengthen the config.runtime behavioral test; update the bug-853 prose-pin test + comment. Security review clean; Codex confirmed no fail-open.

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

* test(#1708): backfill backgroundDispatch in role:runtime test fixtures

Making backgroundDispatch a required descriptor field broke role:runtime fixtures in capability-manifest-version/capability-registry/host-integration-descriptors tests that build a dispatch object without it (caught by full gsd-test, not scoped npm test). Backfill backgroundDispatch:false into the well-formed fixtures; the deliberately-malformed 'required-field' test fixture is left malformed by design.

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

* test(#1708): update fix-1521 dispatch-gating assertion to the FLATTEN gate

fix-1521 pinned the codex-specific run_in_background prose that #1708 graduated to the typed dispatch-should-flatten/FLATTEN gate. Update its assertions to verify FLATTEN=false gating (not a runtime name) + that the old RUNTIME===codex gate is gone. Caught by full gsd-test.

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

* docs(#1708): add changeset for typed dispatch-flatten

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

* chore(#1708): remove stray temp PR-body file

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

* test(#1708): add issue ref to bug-853 allow-test-rule annotations

ADR-456 requires every allow-test-rule exemption to carry a see #NNN reference; the source-text-is-the-product annotations added when migrating the prose assertions lacked it (lint-tests CI gate). Add (see #1708).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:37:33 -04:00

230 lines
11 KiB
JavaScript

'use strict';
/**
* Regression guard — bug(#853): /gsd-manager and /gsd-autonomous --interactive
* silently skipped worktree isolation + independent verification because they
* dispatched Plan/Execute via Agent(run_in_background=true). On Claude Code a
* backgrounded agent has no Agent/Task tool, so it cannot spawn the nested
* subagents (worktree executors, plan-checker, verifier). The workflows must
* now resolve dispatch capability from the registry (#1708) and run inline
* everywhere except runtimes where dispatch.background && dispatch.backgroundDispatch
* are both true (currently: codex, cursor).
*
* Phase B (#1708): the prose `RUNTIME === 'codex'` rule is graduated to a typed
* `gsd_run query dispatch-should-flatten` query backed by shouldFlattenDispatch()
* from host-integration.cjs and the documentation-sourced capability registry.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup: cleanupDir, runGsdTools } = require('./helpers.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
// allow-test-rule: source-text-is-the-product (see #1708)
const MANAGER = fs.readFileSync(path.join(WORKFLOWS_DIR, 'manager.md'), 'utf8');
// allow-test-rule: source-text-is-the-product (see #1708)
const AUTONOMOUS = fs.readFileSync(path.join(WORKFLOWS_DIR, 'autonomous.md'), 'utf8');
describe('bug-853 — manager/autonomous gate background dispatch by runtime', () => {
test('manager.md resolves dispatch-should-flatten before dispatching plan/execute', () => {
// Two dispatch sites (plan + execute), each must use dispatch-should-flatten.
// allow-test-rule: source-text-is-the-product (see #1708)
const matches = MANAGER.match(/dispatch-should-flatten/g) || [];
assert.ok(matches.length >= 2, 'manager.md must use dispatch-should-flatten for both plan and execute dispatch');
});
test('manager.md documents why most runtimes cannot background-dispatch', () => {
// Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no)
// allow-test-rule: source-text-is-the-product (see #1708)
assert.match(MANAGER, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/);
});
test('manager.md gates background dispatch on FLATTEN=false and runs plan/execute inline otherwise', () => {
// Background path uses FLATTEN is false
// allow-test-rule: source-text-is-the-product (see #1708)
assert.match(MANAGER, /If `FLATTEN` is `false`[\s\S]{0,400}?run_in_background=true/);
// Inline is the default/else branch for plan — anchored on FLATTEN=true language (not runtime name)
assert.match(
MANAGER,
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/,
);
// Inline is the default/else branch for execute — anchored on FLATTEN=true language (not runtime name)
assert.match(
MANAGER,
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/,
);
});
test('manager.md compound action preamble uses FLATTEN language (not hardcoded runtime names)', () => {
// allow-test-rule: source-text-is-the-product (see #1708)
const compoundActionSection = MANAGER.match(
/### Compound Action \(background \+ inline\)[\s\S]*?Inline verification:/,
);
assert.ok(compoundActionSection, 'manager.md must document compound action runtime dispatch');
// Must gate on FLATTEN being false (not runtime name)
assert.match(
compoundActionSection[0],
/If `FLATTEN` is `false`[\s\S]{0,400}?Spawn all background agents first[\s\S]{0,300}?plan\/execute/,
);
// Otherwise / inline branch must reference FLATTEN being true
assert.match(
compoundActionSection[0],
/Otherwise[\s\S]{0,260}?`FLATTEN`[\s\S]{0,260}?`true`[\s\S]{0,260}?inline/,
);
// Must NOT still hardcode "On Codex:" in this section
assert.doesNotMatch(
compoundActionSection[0],
/\*\*On Codex:\*\*/,
);
// Must NOT still hardcode "On Claude Code or any other non-Codex runtime:"
assert.doesNotMatch(
compoundActionSection[0],
/On Claude Code or any other non-Codex runtime:/,
);
});
test('autonomous.md gates interactive background dispatch using dispatch-should-flatten', () => {
// Two dispatch sites (3b plan + 3c execute), each must use dispatch-should-flatten.
// allow-test-rule: source-text-is-the-product (see #1708)
const autoFlattenMatches = AUTONOMOUS.match(/dispatch-should-flatten/g) || [];
assert.ok(autoFlattenMatches.length >= 2, 'autonomous.md must use dispatch-should-flatten in both 3b (plan) and 3c (execute) interactive branches');
// Accept both old singular form (backgrounded agent has no) and new plural form (backgrounded agents have no)
assert.match(AUTONOMOUS, /backgrounded agents? ha(?:s|ve) no `Agent`\/`Task` tool/);
});
test('autonomous.md gates interactive background dispatch on FLATTEN=false; runs plan/execute inline otherwise', () => {
// Background block: run_in_background=true appears within the FLATTEN=false branch and gsd-plan-phase is nearby
// allow-test-rule: source-text-is-the-product (see #1708)
assert.match(AUTONOMOUS, /If `FLATTEN` is `false`[\s\S]{0,1200}?run_in_background=true[\s\S]{0,600}?gsd-plan-phase/);
// Background block: run_in_background=true appears within the FLATTEN=false branch and gsd-execute-phase is nearby
assert.match(AUTONOMOUS, /If `FLATTEN` is `false`[\s\S]{0,3000}?run_in_background=true[\s\S]{0,200}?gsd-execute-phase/);
// Inline is the otherwise/else branch for plan — anchored on FLATTEN=true language (not runtime name)
assert.match(
AUTONOMOUS,
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-plan-phase"/,
);
// Inline is the otherwise/else branch for execute — anchored on FLATTEN=true language (not runtime name)
assert.match(
AUTONOMOUS,
/Otherwise[\s\S]{0,100}?`FLATTEN`[\s\S]{0,400}?Skill\(skill="gsd-execute-phase"/,
);
});
});
describe('dispatch-should-flatten query — behavioral', () => {
// #853 / #1708: The typed query replaces prose-level RUNTIME===codex checks.
// shouldFlattenDispatch returns false only when both dispatch.background AND
// dispatch.backgroundDispatch are true in the capability registry.
//
// Registry values (from host-integration-capability-matrix.md):
// codex: background=true, backgroundDispatch=true → shouldFlatten=false (may background)
// claude: background=true, backgroundDispatch=false → shouldFlatten=true (must inline)
// cursor: background=true, backgroundDispatch=true → shouldFlatten=false (may background)
// unknown: no entry → fail-closed → shouldFlatten=true (must inline)
test('runtime=codex → shouldFlatten=false (background dispatch safe)', () => {
const tmpDir = createTempProject();
try {
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
GSD_RUNTIME: 'codex',
});
assert.ok(result.success, `Expected success, got error: ${result.error}`);
assert.strictEqual(result.output, 'false', `codex should return false (may background), got: ${result.output}`);
} finally {
cleanupDir(tmpDir);
}
});
test('runtime=claude → shouldFlatten=true (must inline)', () => {
const tmpDir = createTempProject();
try {
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
GSD_RUNTIME: 'claude',
});
assert.ok(result.success, `Expected success, got error: ${result.error}`);
assert.strictEqual(result.output, 'true', `claude should return true (must inline), got: ${result.output}`);
} finally {
cleanupDir(tmpDir);
}
});
test('runtime=cursor → shouldFlatten=false (background dispatch safe)', () => {
const tmpDir = createTempProject();
try {
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
GSD_RUNTIME: 'cursor',
});
assert.ok(result.success, `Expected success, got error: ${result.error}`);
assert.strictEqual(result.output, 'false', `cursor should return false (may background), got: ${result.output}`);
} finally {
cleanupDir(tmpDir);
}
});
test('unknown runtime → shouldFlatten=true (fail-closed → must inline)', () => {
// An unknown runtime has no registry entry → dispatch is null → fail-closed to true.
const tmpDir = createTempProject();
try {
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
GSD_RUNTIME: 'unknown-runtime-xyz',
});
// The query must succeed (exit 0) even for unknown runtimes — fail-closed not crash-closed.
assert.ok(result.success, `Expected success (fail-closed), got error: ${result.error}`);
assert.strictEqual(result.output, 'true', `unknown runtime should return true (fail-closed), got: ${result.output}`);
} finally {
cleanupDir(tmpDir);
}
});
test('--json flag returns structured { runtime, shouldFlatten, dispatch }', () => {
const tmpDir = createTempProject();
try {
const result = runGsdTools(['query', 'dispatch-should-flatten', '--json'], tmpDir, {
GSD_RUNTIME: 'codex',
});
assert.ok(result.success, `Expected success, got error: ${result.error}`);
let parsed;
try {
parsed = JSON.parse(result.output);
} catch {
assert.fail(`Expected valid JSON output, got: ${result.output}`);
}
assert.strictEqual(parsed.runtime, 'codex');
assert.strictEqual(parsed.shouldFlatten, false);
assert.ok(parsed.dispatch !== null && typeof parsed.dispatch === 'object', 'dispatch should be an object');
assert.strictEqual(parsed.dispatch.backgroundDispatch, true);
} finally {
cleanupDir(tmpDir);
}
});
test('config.runtime takes precedence when GSD_RUNTIME not set', () => {
// GSD_RUNTIME > config.runtime > 'claude'
// Write config.json with runtime=codex; no GSD_RUNTIME override.
const tmpDir = createTempProject();
try {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ runtime: 'codex' }),
'utf-8',
);
// Override GSD_RUNTIME to '' (empty string) so any ambient value is cleared.
// resolveRuntimeNameFromCandidates treats empty string as absent (normalizes
// to '' which is falsy → skipped → falls through to config.runtime=codex).
// This is the only way to suppress an ambient GSD_RUNTIME since runGsdTools
// merges { ...process.env, ...TEST_ENV_BASE, ...env } — passing '' as the
// override overwrites the ambient value at the correct merge position.
const result = runGsdTools(['query', 'dispatch-should-flatten', '--raw'], tmpDir, {
GSD_RUNTIME: '',
});
// config.runtime=codex with GSD_RUNTIME cleared → codex backgrounds → shouldFlatten=false
assert.ok(result.success, `Expected success, got error: ${result.error}`);
assert.strictEqual(result.output, 'false', `config.runtime=codex (GSD_RUNTIME cleared) should return false (may background), got: ${result.output}`);
} finally {
cleanupDir(tmpDir);
}
});
});