Files
msd-core/tests/plan-phase-background-wait-wakeup.test.cjs
Tom Boucher c27da5e993 fix(#4079): forbid ScheduleWakeup wake-tool escape hatch at background-wait sites (#4299)
* test(#4079): guard every background-wait site against ScheduleWakeup wake-tool escape hatch

Content-contract tests over plan-phase.md, stall-detection-helpers.md, and
manager.md: every wait-instruction site must forbid ScheduleWakeup (the
host /loop tool whose partial-args call surfaces the red validation error
in #4079), while the sanctioned wait mechanisms stay byte-preserved.

* fix(#4079): forbid ScheduleWakeup wake-tool escape hatch at every background-wait site

The orchestrator could literalize 'I'll wait' by calling the host's
ScheduleWakeup tool (/loop pacing surface) with partial arguments while a
background subagent was in flight, surfacing the red validation error
'`prompt` is required when `stop` is not true.' GSD never sanctioned a
wake call; now every wait-instruction site says so explicitly: the two
synchronous stop-and-wait ORCHESTRATOR RULEs in plan-phase.md, the
stall-detection-helpers.md fragment (loaded before every gsd_stall_watch
wait), and both manager.md background-dispatch rules. The sanctioned wait
mechanisms (blocking Agent() return, gsd_stall_watch polling, dashboard
loop) are unchanged. The regression test uses the shared
readFileNormalized helper (code-review finding).

Emitted-Drift-Ack-Growth: plan-phase.md — #4079 guard sentence at the two stop-and-wait rules (+396 B, within the phase6 shrink-only line)
Emitted-Drift-Ack-Growth: manager.md — #4079 guard sentence at both background-dispatch rules

* chore(#4079): add changeset fragment (pr placeholder, backfilled after PR open)

* chore(#4079): backfill PR number in changeset

---------

Co-authored-by: sim <sim@local>
2026-09-04 23:16:40 -04:00

152 lines
6.5 KiB
JavaScript

// allow-test-rule: source-text-is-the-product — see #2650, #4079
// Workflow markdown is the installed orchestration contract.
'use strict';
/**
* #4079 — background-wait pattern lets the orchestrator reach for the host's
* `ScheduleWakeup` tool with partial arguments.
*
* On Claude Code, a `ScheduleWakeup` tool is surfaced in-session (the /loop
* dynamic-pacing surface). While a GSD workflow waits on a background subagent
* (researcher / pattern-mapper synchronous Agent() stop-and-wait, the
* planner/checker/revision `gsd_stall_watch` polling waits, or the manager
* dashboard's background dispatch), the orchestrator model could spontaneously
* literalize "I'll wait" by calling `ScheduleWakeup` — a tool GSD never
* documents — with incomplete arguments, surfacing the host's red validation
* error: "`prompt` is required when `stop` is not true."
*
* The fix is contract-level, not code-level: every wait-instruction site now
* carries an explicit prohibition — never call `ScheduleWakeup` or any host
* wake/sleep-scheduling tool to literalize a wait; the sanctioned mechanisms
* (blocking Agent() return, `gsd_stall_watch` bash polling) ARE the wait.
*
* These are content-contract tests over the shipped workflow text (same
* "source text is the product" pattern as tests/plan-phase-stall-detection
* .test.cjs) — they fail on any future edit that drops the guard.
*
* Seam: gsd-core/workflows/plan-phase.md,
* gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md,
* gsd-core/workflows/manager.md
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { readFileNormalized } = require('./helpers.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const PLAN_PHASE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase.md');
const STALL_HELPERS_PATH = path.join(
REPO_ROOT, 'gsd-core', 'workflows', 'plan-phase', 'steps', 'stall-detection-helpers.md'
);
const MANAGER_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'manager.md');
// The phase6 shrink-only line for plan-phase.md (tests/phase6-capstone-
// conformance.test.cjs PRE_PHASE6) — mirrored here so a guard sentence can
// never quietly push the file past it.
const PLAN_PHASE_PHASE6_LINE = 94519;
function lfByteCount(p) {
return Buffer.byteLength(readFileNormalized(p), 'utf-8');
}
/** Split plan-phase.md into its ORCHESTRATOR RULE blocks. */
function orchestratorRules(content) {
return content.split('\n')
.filter((line) => line.includes('ORCHESTRATOR RULE'))
.map((line) => line.trim());
}
describe('#4079 background-wait wake-tool guard', () => {
test('every synchronous stop-and-wait ORCHESTRATOR RULE in plan-phase.md forbids ScheduleWakeup (#4079)', () => {
const content = readFileNormalized(PLAN_PHASE_PATH);
const stopAndWaitRules = orchestratorRules(content).filter(
(rule) => /stop working on this task immediately/.test(rule)
);
assert.ok(
stopAndWaitRules.length >= 2,
`expected at least 2 stop-and-wait ORCHESTRATOR RULEs (researcher + pattern mapper), found ${stopAndWaitRules.length}`
);
const missing = stopAndWaitRules.filter((rule) => !/ScheduleWakeup/.test(rule));
assert.deepEqual(
missing,
[],
`every synchronous stop-and-wait rule must forbid ScheduleWakeup (${missing.length} do not). ` +
'A model told only to "wait" reaches for any in-session scheduling tool with partial args (#4079).'
);
});
test('stall-detection-helpers.md forbids ScheduleWakeup between gsd_stall_watch cycles (#4079)', () => {
const content = readFileNormalized(STALL_HELPERS_PATH);
assert.match(
content,
/ScheduleWakeup/,
'the stall-wait fragment (read+executed at step 7.99 before every stall-watch wait) ' +
'must name ScheduleWakeup so the prohibition is loaded exactly when the wait happens'
);
assert.match(
content,
/gsd_stall_watch[\s\S]{0,400}ScheduleWakeup|ScheduleWakeup[\s\S]{0,400}gsd_stall_watch/,
'the prohibition must be tied to the gsd_stall_watch wait cycle, not float disconnected'
);
assert.match(
content,
/never part of|not part of|Do NOT call/i,
'the fragment must state the wait tool is never part of the sanctioned wait'
);
});
test('manager.md background-dispatch wait rules forbid ScheduleWakeup (#4079)', () => {
const content = readFileNormalized(MANAGER_PATH);
const dispatchRules = content.split('\n')
.filter((line) => line.includes('ORCHESTRATOR RULE — BACKGROUND DISPATCH'))
.map((line) => line.trim());
assert.ok(
dispatchRules.length >= 2,
`expected at least 2 BACKGROUND DISPATCH rules (plan + execute), found ${dispatchRules.length}`
);
const missing = dispatchRules.filter((rule) => !/ScheduleWakeup/.test(rule));
assert.deepEqual(
missing,
[],
'every background-dispatch wait rule must forbid ScheduleWakeup — the dashboard wait ' +
'is exactly where the model literalizes "I\'ll wait" with a partial-args wake call (#4079)'
);
});
test('plan-phase.md still uses gsd_stall_watch at all three stall-watch sites (negative space)', () => {
const content = readFileNormalized(PLAN_PHASE_PATH);
const stallWatchRules = orchestratorRules(content).filter(
(rule) => rule.includes('gsd_stall_watch')
);
assert.ok(
stallWatchRules.length >= 3,
`expected >= 3 gsd_stall_watch ORCHESTRATOR RULEs (planner, checker, revision), found ${stallWatchRules.length}`
);
// The sanctioned mechanism is unchanged — the guard only closes the escape hatch.
assert.match(content, /marker_received/);
assert.match(content, /stalled/);
});
test('plan-phase.md stays under the phase6 shrink-only line (#4079 guard)', () => {
const bytes = lfByteCount(PLAN_PHASE_PATH);
assert.ok(
bytes < PLAN_PHASE_PHASE6_LINE,
`plan-phase.md is ${bytes} LF bytes — must stay < ${PLAN_PHASE_PHASE6_LINE} ` +
'(tests/phase6-capstone-conformance.test.cjs PRE_PHASE6)'
);
});
test('manager.md background-dispatch semantics unchanged (FLATTEN gate preserved)', () => {
const content = readFileNormalized(MANAGER_PATH);
assert.match(content, /dispatch-should-flatten/, 'FLATTEN resolution must be preserved');
assert.match(
content,
/Return to the dashboard immediately and wait for the background agent to report back/,
'the dashboard-return wait semantics must be preserved'
);
});
});