Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
938 lines
56 KiB
JavaScript
938 lines
56 KiB
JavaScript
// allow-test-rule: source-text-is-the-product — see #2650
|
|
// Workflow markdown is the installed orchestration contract.
|
|
|
|
'use strict';
|
|
|
|
/**
|
|
* #2650 — plan-phase hangs after msd-planner writes all plans; completion never
|
|
* reaches orchestrator.
|
|
*
|
|
* plan-phase.md's five planner/plan-checker Agent() spawns (standard planner,
|
|
* chunked outline planner, chunked per-plan planner, plan-checker, and the
|
|
* revision-loop planner respawn) previously waited for a subagent's return
|
|
* with no time bound, no periodic check, and no config-driven threshold — the
|
|
* only recovery path (9a/11a "Filesystem Fallback") required Agent() to have
|
|
* already returned, so it could never fire when the call never returned
|
|
* control at all. This mirrors the already-shipped `executor.stall_*` fix for
|
|
* execute-phase.md (bug #3212, commit e7942c21b).
|
|
*
|
|
* The fix extracts the decision logic into a pure, unit-testable bash
|
|
* function (`msd_stall_should_recover`) embedded in the lazily-loaded
|
|
* `msd-core/workflows/plan-phase/steps/stall-detection-helpers.md` (kept out
|
|
* of plan-phase.md's own measured bytes — plan-phase.md is frozen under the
|
|
* ADR-857 Phase 6 `PRE_PHASE6` gate, `tests/phase6-capstone-conformance.test.cjs`,
|
|
* with ~36 bytes of headroom at baseline) and exercised here via the SAME
|
|
* extraction pattern already used by tests/worktree-cleanup.test.cjs
|
|
* (extractCwdGuardBash) and tests/quick-branching.test.cjs
|
|
* (extractStep25Bash) — the test runs the exact shipped bash, not a
|
|
* hand-copied duplicate (avoids the "Generative Fix Divergence" defect
|
|
* class).
|
|
*
|
|
* Seam: msd-core/workflows/plan-phase.md,
|
|
* msd-core/workflows/plan-phase/steps/stall-detection-helpers.md,
|
|
* src/config.cts (SCHEMA_DEFAULTS),
|
|
* msd-core/bin/shared/config-schema.manifest.json, docs/CONFIGURATION.md
|
|
*/
|
|
|
|
const { describe, test, mock } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
// Required as a MODULE OBJECT, not destructured, so `mock.method(processSeam, 'runHook', …)`
|
|
// can observe what runBashScript actually passes to the seam. tests/helpers.cjs:221-224
|
|
// documents this same pattern for runNode.
|
|
const processSeam = require('./helpers/process-seam.cjs');
|
|
const { runNode, OUTCOME } = processSeam;
|
|
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
|
|
const { PROBE_TIMEOUT_MS, HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const fc = require('fast-check');
|
|
const { cleanup, readFileNormalized, readWorkflowCombined, runMsdTools } = require('./helpers.cjs');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
const PLAN_PHASE_PATH = path.join(REPO_ROOT, 'msd-core', 'workflows', 'plan-phase.md');
|
|
const STALL_HELPERS_PATH = path.join(REPO_ROOT, 'msd-core', 'workflows', 'plan-phase', 'steps', 'stall-detection-helpers.md');
|
|
const CHUNKED_PLANNING_MODE_PATH = path.join(REPO_ROOT, 'msd-core', 'workflows', 'plan-phase', 'steps', 'chunked-planning-mode.md');
|
|
const CONFIG_SCHEMA_MANIFEST_PATH = path.join(REPO_ROOT, 'msd-core', 'bin', 'shared', 'config-schema.manifest.json');
|
|
const CONFIG_DEFAULTS_MANIFEST_PATH = path.join(REPO_ROOT, 'msd-core', 'bin', 'shared', 'config-defaults.manifest.json');
|
|
const CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'CONFIGURATION.md');
|
|
const PT_BR_CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'pt-BR', 'CONFIGURATION.md');
|
|
const ZH_CN_CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'zh-CN', 'CONFIGURATION.md');
|
|
const JA_JP_CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'ja-JP', 'CONFIGURATION.md');
|
|
const KO_KR_CONFIGURATION_DOCS_PATH = path.join(REPO_ROOT, 'docs', 'ko-KR', 'CONFIGURATION.md');
|
|
const ZH_CN_PLANNING_CONFIG_PATH = path.join(REPO_ROOT, 'docs', 'zh-CN', 'references', 'planning-config.md');
|
|
const SETTINGS_ADVANCED_PATH = path.join(REPO_ROOT, 'msd-core', 'workflows', 'settings-advanced.md');
|
|
|
|
function readPlanPhase() {
|
|
return readFileNormalized(PLAN_PHASE_PATH);
|
|
}
|
|
|
|
// #2993 relocated plan-phase.md's chunked-planning-mode spawn sites into this
|
|
// lazily-loaded step file. Read directly rather than via the generic
|
|
// readWorkflowCombined() blob when a test needs to slice a SPECIFIC section by
|
|
// heading-to-heading boundaries: chunked-planning-mode.md is small and
|
|
// self-contained (8.5.1 immediately followed by 8.5.2, nothing else), so its
|
|
// own heading boundaries stay precise, whereas the combined multi-file blob's
|
|
// ordering (host file, then every steps/*.md sorted by filename) would put an
|
|
// unrelated step file's content between "### 8.5.2 Per-Plan Tasks" and any
|
|
// downstream anchor a slice tried to search for.
|
|
function readChunkedPlanningMode() {
|
|
return readFileNormalized(CHUNKED_PLANNING_MODE_PATH);
|
|
}
|
|
|
|
function readStallHelpersDoc() {
|
|
return readFileNormalized(STALL_HELPERS_PATH);
|
|
}
|
|
|
|
/**
|
|
* Extract the ```bash fence that defines msd_stall_should_recover (and its
|
|
* sibling msd_stall_watch) from the lazily-loaded stall-detection-helpers.md
|
|
* step file. Throws with a clear message if the anchor or fence cannot be
|
|
* found — this is what makes row 1 of the test matrix a genuine failing-first
|
|
* regression test (pre-fix, the function does not exist anywhere in the repo).
|
|
*/
|
|
function extractStallHelpersBash() {
|
|
const content = readStallHelpersDoc();
|
|
|
|
const anchor = 'msd_stall_should_recover';
|
|
const anchorIdx = content.indexOf(anchor);
|
|
if (anchorIdx === -1) {
|
|
throw new Error(`extractStallHelpersBash: could not find "${anchor}" anywhere in ${STALL_HELPERS_PATH}`);
|
|
}
|
|
|
|
// Walk backward to the start of the fenced ```bash block containing the anchor.
|
|
const before = content.slice(0, anchorIdx);
|
|
const fenceOpenRe = /```bash\r?\n/g;
|
|
let lastOpen = -1;
|
|
let m;
|
|
while ((m = fenceOpenRe.exec(before)) !== null) {
|
|
lastOpen = m.index + m[0].length;
|
|
}
|
|
if (lastOpen === -1) {
|
|
throw new Error(`extractStallHelpersBash: "${anchor}" is not inside a \`\`\`bash fence in ${STALL_HELPERS_PATH}`);
|
|
}
|
|
|
|
const after = content.slice(lastOpen);
|
|
const closeIdx = after.indexOf('```');
|
|
if (closeIdx === -1) {
|
|
throw new Error('extractStallHelpersBash: unterminated ```bash fence');
|
|
}
|
|
|
|
const body = after.slice(0, closeIdx);
|
|
if (!body.includes('msd_stall_watch')) {
|
|
throw new Error('extractStallHelpersBash: sanity check failed — extracted block does not also define msd_stall_watch');
|
|
}
|
|
// readStallHelpersDoc() reads through helpers.cjs's readFileNormalized(),
|
|
// which strips \r\n -> \n at the read boundary before any slicing above
|
|
// runs. That guards against the repo's general CRLF-in-extracted-source
|
|
// defect class (#1700) and is worth keeping on its own merits (a bare \n
|
|
// regex against readFileSync content is fragile either way), but it is
|
|
// NOT what caused the #2650 Windows CI failure: .gitattributes forces
|
|
// `eol=lf` on this file, so a Windows checkout never receives CRLF here
|
|
// in the first place. The real cause, confirmed by evidence rather than
|
|
// argument: passing this file's ~73-line, quote-dense script body as a
|
|
// single `bash -c <script>` argv element does not survive Windows argv
|
|
// serialization (Node has no execve there; CreateProcess flattens the
|
|
// whole argv into one command-line string, and Git Bash's MSYS layer
|
|
// re-splits and unescapes it with its own rules — the script itself gets
|
|
// mangled in transit, not just the boundary around it). Proven by an
|
|
// A/B on real CI: converting only runShouldRecover() to the temp-file
|
|
// form below took Windows from 11 failures to 4, and flipped
|
|
// `full test (windows-latest, 22, shard 1/3)` and `shard 2/3` from fail
|
|
// to pass — while runWatch() (no extra positional args at all, values
|
|
// embedded directly in the script text) still failed identically to
|
|
// before, so the trailing-args theory is ruled out: it is script size
|
|
// and quote density, not argv-element count. `runBashScript()` below
|
|
// (used by every call site in this file) removes the script from `-c`
|
|
// transport entirely by writing it to a file and running it by path.
|
|
// tests/worktree-cleanup.test.cjs's extractCwdGuardBash/runGuard stays on
|
|
// `bash -c` and is green on Windows only because its script is small
|
|
// enough to round-trip that transport intact.
|
|
return body;
|
|
}
|
|
|
|
/**
|
|
* Write `script` to a fresh temp file and run it as `bash <file> <args...>`
|
|
* rather than `bash -c <script> <args...>` (#2650 Windows CI — see
|
|
* extractStallHelpersBash()'s doc comment for the full evidence trail: a
|
|
* quote-dense multi-line script does not survive Windows argv
|
|
* serialization when passed as a `-c` argv element, regardless of how many
|
|
* trailing positional args accompany it). Every bash-invoking call site in
|
|
* this file routes through this one seam so a future call site cannot
|
|
* silently reintroduce the transport bug in isolation. Cleans up the temp
|
|
* dir in `finally` regardless of outcome.
|
|
*
|
|
* @param {string} script the full bash script body (helpers + a final call)
|
|
* @param {string[]} [args] positional args passed to the script (become
|
|
* $1, $2, ... inside it) — empty when the caller embeds values directly
|
|
* into the script text instead (e.g. via JSON.stringify).
|
|
* @param {object} [opts] extra process-seam options (`{ timeoutMs, cwd, env, input,
|
|
* killSignal }`), merged over the defaults below. NOTE the key is `timeoutMs`, not
|
|
* spawnSync's `timeout` — the seam reads only its own documented options, so a stray
|
|
* `timeout` key is silently ignored rather than honoured.
|
|
*/
|
|
function runBashScript(script, args = [], opts = {}) {
|
|
const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2650-sh-'));
|
|
try {
|
|
const scriptPath = path.join(scriptDir, 'script.sh');
|
|
fs.writeFileSync(scriptPath, `#!/usr/bin/env bash\n${script}`, { mode: 0o755 });
|
|
// Through the process seam, never a hand-rolled spawnSync (CONTRIBUTING.md:
|
|
// "Anything that shells out goes through tests/helpers/process-seam.cjs"). Two
|
|
// things that buys, both of which the raw call lacked:
|
|
//
|
|
// 1. HOOK_FANOUT_TIMEOUT_MS — the class norm for a bash invocation that FANS OUT
|
|
// to nested subprocesses. This script does exactly that: the extracted fence
|
|
// opens with the runtime-launcher preamble, which resolves msd-tools.cjs and
|
|
// really runs two `msd_run query config-get` lines, i.e. two Node spawns
|
|
// (measured 236ms vs 2ms for the fallback shape). The old hard-coded 10000ms
|
|
// was sized for a cheap probe and was exceeded at 10006ms on
|
|
// `full test (windows-latest, 24, shard 1/3)`. timeouts.cjs records the same
|
|
// failure mode on PR #3285: a bound sized for the wrong class, not a slow machine.
|
|
//
|
|
// 2. A typed `outcome`. spawnSync reports a kill as `status: null`, so an exceeded
|
|
// bound reached the call sites as `null !== 0` — naming neither the timeout nor
|
|
// the bound. OUTCOME.TIMED_OUT names itself.
|
|
//
|
|
// Looked up on the module object (`processSeam.runHook`) rather than destructured, so
|
|
// a test can observe the options actually passed — same rationale as
|
|
// tests/helpers.cjs:221-224 for runNode.
|
|
const result = processSeam.runHook(scriptPath, args, {
|
|
interpreter: 'bash',
|
|
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
|
|
...opts,
|
|
});
|
|
// `status` is aliased from `exitCode` by toLegacyResult so the existing assertions in
|
|
// this file keep reading the shape they were written against; `outcome`/`timedOut`
|
|
// are additive, and are what make a bound failure self-describing.
|
|
return { ...toLegacyResult(result), outcome: result.outcome, timedOut: result.timedOut };
|
|
} finally {
|
|
cleanup(scriptDir);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run msd_stall_should_recover with the given args inside the extracted
|
|
* script and return its stdout (trimmed). No real sleeping happens — the
|
|
* function is pure and synchronous.
|
|
*/
|
|
function runShouldRecover(helpersBash, elapsedSeconds, thresholdMinutes, markerFound, artifactFresh) {
|
|
const script = `${helpersBash}\nmsd_stall_should_recover "$1" "$2" "$3" "$4"\n`;
|
|
const result = runBashScript(script,
|
|
[String(elapsedSeconds), String(thresholdMinutes), String(markerFound), String(artifactFresh)]);
|
|
assert.equal(result.status, 0, `msd_stall_should_recover exited non-zero: ${result.stderr}`);
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
describe('bug #2650 plan-phase stall detection — msd_stall_should_recover (pure decision function)', () => {
|
|
let helpersBash;
|
|
|
|
test('stall-detection-helpers.md defines msd_stall_should_recover inside a ```bash fence', () => {
|
|
helpersBash = extractStallHelpersBash();
|
|
assert.ok(helpersBash.length > 0);
|
|
});
|
|
|
|
test('boundary — one second under threshold keeps waiting (limit-1)', () => {
|
|
const result = runShouldRecover(helpersBash, 599, 10, 'false', 'false'); // 10min = 600s
|
|
assert.equal(result, 'waiting');
|
|
});
|
|
|
|
test('boundary — exactly at threshold stalls (limit)', () => {
|
|
const result = runShouldRecover(helpersBash, 600, 10, 'false', 'false');
|
|
assert.equal(result, 'stalled');
|
|
});
|
|
|
|
test('boundary — one second past threshold stalls (limit+1)', () => {
|
|
const result = runShouldRecover(helpersBash, 601, 10, 'false', 'false');
|
|
assert.equal(result, 'stalled');
|
|
});
|
|
|
|
test('marker found short-circuits regardless of elapsed time', () => {
|
|
assert.equal(runShouldRecover(helpersBash, 0, 10, 'true', 'false'), 'marker_received');
|
|
assert.equal(runShouldRecover(helpersBash, 99999, 10, 'true', 'false'), 'marker_received');
|
|
});
|
|
|
|
test('fresh artifact activity keeps waiting even past threshold (no false-fire while planner is actively writing)', () => {
|
|
assert.equal(runShouldRecover(helpersBash, 99999, 10, 'false', 'true'), 'active');
|
|
});
|
|
|
|
test('default threshold (10 min) does not false-fire on a normal 1-5 minute planner run (AC3)', () => {
|
|
// A normal run returns (marker_found=true) well before 300s (5 min).
|
|
assert.equal(runShouldRecover(helpersBash, 300, 10, 'true', 'false'), 'marker_received');
|
|
// And absent a marker, 5 minutes of pure silence is still "waiting", not "stalled".
|
|
assert.equal(runShouldRecover(helpersBash, 300, 10, 'false', 'false'), 'waiting');
|
|
});
|
|
|
|
test('property — stalled iff elapsed seconds >= threshold minutes*60 (when no marker, no fresh activity)', () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 0, max: 60 * 60 * 6 }),
|
|
fc.integer({ min: 1, max: 120 }),
|
|
(elapsedSeconds, thresholdMinutes) => {
|
|
const result = runShouldRecover(helpersBash, elapsedSeconds, thresholdMinutes, 'false', 'false');
|
|
const shouldStall = elapsedSeconds >= thresholdMinutes * 60;
|
|
return shouldStall ? result === 'stalled' : result === 'waiting';
|
|
},
|
|
),
|
|
{ numRuns: 25 },
|
|
);
|
|
});
|
|
|
|
test('a malformed threshold_minutes value degrades to the safe default instead of crashing the watcher', (t) => {
|
|
// A security review initially flagged this as a command-injection path
|
|
// (bash arithmetic recursively re-evaluating a `$(cmd)`-shaped string).
|
|
// Empirically disproven: bash's arithmetic evaluator hard-errors on such
|
|
// an operand ("syntax error: operand expected") rather than invoking it —
|
|
// verified directly against both macOS bash 3.2.57 and Docker bash:5; the
|
|
// payload command never runs on either. The REAL risk this guard closes
|
|
// is reliability, not RCE: without validation, a malformed
|
|
// `planner.stall_threshold_minutes` config value would abort the
|
|
// stall-watcher itself with that bash syntax error, silently defeating
|
|
// the exact hang-recovery this issue exists to ship. Prove the function
|
|
// degrades to a safe default instead of erroring.
|
|
const marker = `msd-2650-untouched-${process.pid}-${Date.now()}`;
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2650-malformed-'));
|
|
t.after(() => cleanup(tmp));
|
|
const payload = `$(touch ${path.join(tmp, marker)})`;
|
|
const result = runShouldRecover(helpersBash, 0, payload, 'false', 'false');
|
|
// Must not error (proves the guard prevents the bash-abort), must not
|
|
// have run the embedded command either way, and must fall back to the
|
|
// safe default classification (threshold_minutes -> 10 -> elapsed 0 < 600 -> waiting).
|
|
assert.equal(result, 'waiting');
|
|
assert.equal(fs.existsSync(path.join(tmp, marker)), false, 'payload must not execute (also true without the guard — bash hard-errors on it instead)');
|
|
});
|
|
});
|
|
|
|
describe('bug #2650 plan-phase stall detection — msd_stall_watch (real execution, not just the pure classifier)', () => {
|
|
// CORRECTION (#2650 follow-up): an earlier version of this comment claimed the
|
|
// extracted script runs WITHOUT `msd_run` defined, so the `|| echo "<default>"`
|
|
// fallback in the config-get lines fires. That is false, and it is why the spawn
|
|
// bound below was mis-sized. extractStallHelpersBash slices the ENTIRE ```bash
|
|
// fence, which opens with the runtime-launcher preamble; that preamble finds
|
|
// msd-core/bin/msd-tools.cjs from the repo root and DEFINES msd_run, so both
|
|
// config-get lines really spawn Node. Verified: `msd_run defined: function`,
|
|
// MSD_TOOLS=<repo>/msd-core/bin/msd-tools.cjs. The resolved values are then
|
|
// discarded anyway — runWatch overrides both PLANNER_STALL_* vars right after the
|
|
// helpers, and runShouldRecover passes them as arguments — so the two spawns are
|
|
// dead cost that the bound must nonetheless accommodate. They are deliberately NOT
|
|
// removed here: dropping them would change what the extracted script executes and
|
|
// weaken the "the shipped fence is runnable end to end" property these tests carry.
|
|
let helpersBash;
|
|
let tmp;
|
|
|
|
test('loads helpers', () => {
|
|
helpersBash = extractStallHelpersBash();
|
|
assert.ok(helpersBash.includes('msd_stall_watch()'));
|
|
});
|
|
|
|
// Routed through the shared runBashScript() helper (#2650 Windows CI —
|
|
// see extractStallHelpersBash()'s doc comment for the full evidence
|
|
// trail). The call line is still built with JSON.stringify exactly as
|
|
// before — that part was never the problem and correctly keeps Windows
|
|
// paths and the injection-guard payload intact; only the transport of
|
|
// the script itself changes.
|
|
function runWatch(intervalMinutes, thresholdMinutes, dispatchTs, outputFile, artifactGlob, markers) {
|
|
const overrides = `PLANNER_STALL_INTERVAL_MINUTES=${intervalMinutes}\nPLANNER_STALL_THRESHOLD_MINUTES=${thresholdMinutes}\n`;
|
|
const call = `msd_stall_watch ${JSON.stringify(String(dispatchTs))} ${JSON.stringify(outputFile)} ${JSON.stringify(artifactGlob)}` +
|
|
markers.map((m) => ` ${JSON.stringify(m)}`).join('');
|
|
const script = `${helpersBash}\n${overrides}${call}\n`;
|
|
return runBashScript(script, []);
|
|
}
|
|
|
|
test('marker present in the real output file (via real grep, interval=0 so sleep is instant) -> marker_received', (t) => {
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2650-watch-'));
|
|
t.after(() => cleanup(tmp));
|
|
const outputFile = path.join(tmp, 'agent-output.txt');
|
|
fs.writeFileSync(outputFile, 'some agent output\n## PLANNING COMPLETE\nmore text\n');
|
|
const glob = `${tmp.replace(/\\/g, '/')}/*-PLAN.md`;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const result = runWatch(0, 10, now, outputFile, glob, ['## PLANNING COMPLETE']);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.equal(result.stdout.trim(), 'marker_received');
|
|
});
|
|
|
|
test('no marker, no output file, dispatch far in the past, threshold=0 (via real find/date, interval=0) -> stalled', (t) => {
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2650-watch-'));
|
|
t.after(() => cleanup(tmp));
|
|
const missingOutputFile = path.join(tmp, 'never-written.txt');
|
|
const glob = `${tmp.replace(/\\/g, '/')}/*-PLAN.md`; // the tmp dir contains no *-PLAN.md files -> no fresh activity
|
|
const longAgo = Math.floor(Date.now() / 1000) - 999999;
|
|
const result = runWatch(0, 0, longAgo, missingOutputFile, glob, ['## PLANNING COMPLETE']);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.equal(result.stdout.trim(), 'stalled');
|
|
});
|
|
|
|
test('marker absent, dispatch just now, non-zero threshold (via real find/date, interval=0) -> waiting', (t) => {
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2650-watch-'));
|
|
t.after(() => cleanup(tmp));
|
|
const missingOutputFile = path.join(tmp, 'never-written.txt');
|
|
const glob = `${tmp.replace(/\\/g, '/')}/*-PLAN.md`;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const result = runWatch(0, 10, now, missingOutputFile, glob, ['## PLANNING COMPLETE']);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.equal(result.stdout.trim(), 'waiting');
|
|
});
|
|
|
|
test('real `find ... -mmin` correctly detects a fresh artifact -> active (CR: BSD find -newermt "@epoch" is unparseable on macOS)', (t) => {
|
|
// Regression for a production (not test-only) defect a review surfaced:
|
|
// the shipped freshness check used to be `find $glob -newermt "@$(( ...
|
|
// ))" ` — GNU find's "@<epoch>" shorthand for -newermt, which the
|
|
// BSD find(1) actually shipped on macOS does NOT understand ("Can't
|
|
// parse date/time: @<epoch>", verified live against /usr/bin/find). With
|
|
// the `2>/dev/null` beside it, that failed silently and permanently
|
|
// degraded artifact_fresh to "false" on every macOS run — a real
|
|
// plan-checker or planner actively writing plan files could still be
|
|
// reported "stalled". Fixed to `find $glob -mmin -N` ("modified less
|
|
// than N minutes ago"), which needs no date-string parsing and is
|
|
// supported identically by GNU find and BSD find.
|
|
//
|
|
// This runs the REAL shipped msd_stall_watch (not a hand-copied
|
|
// find invocation — see this file's header on Generative Fix
|
|
// Divergence), with `sleep` shadowed to a no-op bash function so the
|
|
// test does not actually wait a real PLANNER_STALL_INTERVAL_MINUTES;
|
|
// the `find ... -mmin` line itself still executes for real. threshold
|
|
// is set absurdly high so "stalled" cannot fire independently — the
|
|
// ONLY path to "active" is a correctly-working freshness check.
|
|
// Routed through runBashScript() (#2650 Windows CI) rather than a raw
|
|
// `bash -c` call — this test builds its own script inline (the `sleep`
|
|
// stub isn't something runWatch() supports), so it needs the same
|
|
// transport seam explicitly rather than inheriting it for free.
|
|
// Windows CR: production's own glob (plan-phase.md:895 et al.,
|
|
// `"${PHASE_DIR}"'/*-PLAN.md'`) is always forward-slash — PHASE_DIR is a
|
|
// POSIX-style `.planning/phases/NN-slug` value, never a native Windows
|
|
// path, and this all runs under Git Bash regardless of host OS. This
|
|
// test previously built the glob with `path.join(tmp, '*-PLAN.md')`,
|
|
// which on Windows yields a backslash path
|
|
// (`C:\Users\RUNNER~1\...\*-PLAN.md`). In bash pathname expansion a
|
|
// backslash escapes the next character, so that pattern can never
|
|
// match anything — `find` silently returned empty and the test failed
|
|
// with 'waiting' instead of 'active'. Confirmed as a TEST artifact, not
|
|
// a production defect: production never constructs the glob this way.
|
|
// Fixed by forward-slashing the tmp dir before building the glob — the
|
|
// same `.replace(/\\/g, '/')` idiom this repo already uses elsewhere —
|
|
// so the test matches what production actually passes, while still
|
|
// exercising the real shipped `find` line. Do not "simplify" this back
|
|
// to a bare `path.join`; that silently reintroduces the failure.
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2650-fresh-'));
|
|
t.after(() => cleanup(tmp));
|
|
fs.writeFileSync(path.join(tmp, 'x-PLAN.md'), 'freshly written\n');
|
|
const glob = `${tmp.replace(/\\/g, '/')}/*-PLAN.md`;
|
|
const missingOutputFile = path.join(tmp, 'never-written.txt');
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const overrides = 'sleep() { :; }\nPLANNER_STALL_INTERVAL_MINUTES=1\nPLANNER_STALL_THRESHOLD_MINUTES=99999\n';
|
|
const call = `msd_stall_watch ${JSON.stringify(String(now))} ${JSON.stringify(missingOutputFile)} ${JSON.stringify(glob)}` +
|
|
` ${JSON.stringify('## PLANNING COMPLETE')}`;
|
|
const script = `${helpersBash}\n${overrides}${call}\n`;
|
|
const result = runBashScript(script, []);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.equal(result.stdout.trim(), 'active');
|
|
});
|
|
// Note: this platform's real find(1) is exercised by the test above via a
|
|
// stubbed `sleep`, not a real ~60s wait. The mtime-based transition is
|
|
// ALSO covered deterministically at the pure-function level above
|
|
// ("fresh artifact activity keeps waiting...") for the classification
|
|
// logic downstream of a given artifact_fresh value.
|
|
});
|
|
|
|
describe('bug #2650 config schema — planner.stall_* keys mirror executor.stall_*', () => {
|
|
test('config schemas register planner stall detector keys', () => {
|
|
const { VALID_CONFIG_KEYS: cjsKeys } = require('../msd-core/bin/lib/config-schema.cjs');
|
|
const manifest = JSON.parse(fs.readFileSync(CONFIG_SCHEMA_MANIFEST_PATH, 'utf-8'));
|
|
const manifestKeys = new Set(manifest.validKeys);
|
|
|
|
for (const key of ['planner.stall_detect_interval_minutes', 'planner.stall_threshold_minutes']) {
|
|
assert.ok(cjsKeys.has(key), `CJS VALID_CONFIG_KEYS must include ${key}`);
|
|
assert.ok(manifestKeys.has(key), `Manifest validKeys must include ${key} (SDK sources from manifest)`);
|
|
}
|
|
});
|
|
|
|
test('configuration docs describe planner stall detector defaults', () => {
|
|
const docs = fs.readFileSync(CONFIGURATION_DOCS_PATH, 'utf-8');
|
|
assert.match(docs, /`planner\.stall_detect_interval_minutes`\s*\|\s*number\s*\|\s*`5`/);
|
|
assert.match(docs, /`planner\.stall_threshold_minutes`\s*\|\s*number\s*\|\s*`10`/);
|
|
});
|
|
|
|
test('config-get returns schema defaults for planner stall detector keys', (t) => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2650-'));
|
|
t.after(() => cleanup(tmp));
|
|
fs.mkdirSync(path.join(tmp, '.planning'));
|
|
fs.writeFileSync(path.join(tmp, '.planning/config.json'), '{}\n');
|
|
|
|
const toolsPath = path.join(REPO_ROOT, 'msd-core', 'bin', 'msd-tools.cjs');
|
|
const interval = toLegacyResult(runNode([toolsPath, 'config-get', 'planner.stall_detect_interval_minutes', '--raw'], { cwd: tmp, timeoutMs: PROBE_TIMEOUT_MS }));
|
|
const threshold = toLegacyResult(runNode([toolsPath, 'config-get', 'planner.stall_threshold_minutes', '--raw'], { cwd: tmp, timeoutMs: PROBE_TIMEOUT_MS }));
|
|
|
|
assert.equal(interval.status, 0, interval.stderr);
|
|
assert.equal(interval.stdout.trim(), '5');
|
|
assert.equal(threshold.status, 0, threshold.stderr);
|
|
assert.equal(threshold.stdout.trim(), '10');
|
|
});
|
|
});
|
|
|
|
describe('enhancement #4570 config contract — planner stall detection has a typed default-on opt-out', () => {
|
|
function makeProject(t, config = {}) {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4570-config-'));
|
|
t.after(() => cleanup(tmp));
|
|
fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true });
|
|
fs.writeFileSync(path.join(tmp, '.planning', 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
|
|
return tmp;
|
|
}
|
|
|
|
test('central schema and canonical defaults manifest register planner.stall_detection_enabled=true', () => {
|
|
const schema = JSON.parse(fs.readFileSync(CONFIG_SCHEMA_MANIFEST_PATH, 'utf8'));
|
|
const defaults = JSON.parse(fs.readFileSync(CONFIG_DEFAULTS_MANIFEST_PATH, 'utf8'));
|
|
assert.ok(schema.validKeys.includes('planner.stall_detection_enabled'));
|
|
assert.equal(defaults.planner?.stall_detection_enabled, true);
|
|
assert.equal(schema.validKeys.includes('executor.stall_detection_enabled'), false,
|
|
'the planner opt-out must not introduce an executor sibling outside approved scope');
|
|
});
|
|
|
|
test('config-get defaults absent values to true; config-set false round-trips as boolean false', (t) => {
|
|
const tmp = makeProject(t);
|
|
const env = { HOME: tmp, USERPROFILE: tmp };
|
|
|
|
const absent = runMsdTools(['config-get', 'planner.stall_detection_enabled', '--raw'], tmp, env);
|
|
assert.equal(absent.success, true, absent.error);
|
|
assert.equal(absent.output, 'true');
|
|
|
|
const noConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4570-no-config-'));
|
|
t.after(() => cleanup(noConfig));
|
|
const absentFile = runMsdTools(
|
|
['config-get', 'planner.stall_detection_enabled', '--raw'],
|
|
noConfig,
|
|
{ HOME: noConfig, USERPROFILE: noConfig },
|
|
);
|
|
assert.equal(absentFile.success, true, absentFile.error);
|
|
assert.equal(absentFile.output, 'true');
|
|
|
|
const set = runMsdTools(['config-set', 'planner.stall_detection_enabled', 'false'], tmp, env);
|
|
assert.equal(set.success, true, set.error);
|
|
const onDisk = JSON.parse(fs.readFileSync(path.join(tmp, '.planning', 'config.json'), 'utf8'));
|
|
assert.equal(onDisk.planner.stall_detection_enabled, false);
|
|
assert.equal(typeof onDisk.planner.stall_detection_enabled, 'boolean');
|
|
|
|
const roundTrip = runMsdTools(['config-get', 'planner.stall_detection_enabled', '--raw'], tmp, env);
|
|
assert.equal(roundTrip.success, true, roundTrip.error);
|
|
assert.equal(roundTrip.output, 'false');
|
|
});
|
|
|
|
test('property: every non-boolean CLI value is rejected without modifying config', (t) => {
|
|
const tmp = makeProject(t, { planner: { stall_detection_enabled: true }, sentinel: 'preserve' });
|
|
const configPath = path.join(tmp, '.planning', 'config.json');
|
|
const before = fs.readFileSync(configPath, 'utf8');
|
|
const printable = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789[]{}._- ';
|
|
const invalidValue = fc.array(fc.constantFrom(...printable), { minLength: 1, maxLength: 16 })
|
|
.map((chars) => chars.join(''))
|
|
.filter((value) => !['true', 'false', 'null'].includes(value));
|
|
|
|
fc.assert(
|
|
fc.property(invalidValue, (value) => {
|
|
const result = runMsdTools(
|
|
['config-set', 'planner.stall_detection_enabled', value],
|
|
tmp,
|
|
{ HOME: tmp, USERPROFILE: tmp },
|
|
);
|
|
return result.success === false && fs.readFileSync(configPath, 'utf8') === before;
|
|
}),
|
|
{ numRuns: 20 },
|
|
);
|
|
});
|
|
|
|
test('hand-edited non-booleans fail safely to true on both config-get and Config Loader reads', (t) => {
|
|
const tmp = makeProject(t, { planner: { stall_detection_enabled: 'false' } });
|
|
const result = runMsdTools(
|
|
['config-get', 'planner.stall_detection_enabled', '--raw'],
|
|
tmp,
|
|
{ HOME: tmp, USERPROFILE: tmp },
|
|
);
|
|
assert.equal(result.success, true, result.error);
|
|
assert.equal(result.output, 'true', 'a string "false" must not disable the watchdog');
|
|
|
|
const { loadConfig } = require('../msd-core/bin/lib/config-loader.cjs');
|
|
assert.equal(loadConfig(tmp).planner_stall_detection_enabled, true);
|
|
});
|
|
|
|
test('manifest skew cannot make a non-boolean planner setting disable detection', () => {
|
|
const { resolvePlannerStallDetectionEnabled } = require('../msd-core/bin/lib/config-loader.cjs');
|
|
for (const value of [undefined, null, 'false', 0, {}]) {
|
|
assert.equal(resolvePlannerStallDetectionEnabled(value), true);
|
|
}
|
|
});
|
|
|
|
test('root, MSD_PROJECT, and workstream reads retain canonical scope precedence', (t) => {
|
|
const tmp = makeProject(t, { planner: { stall_detection_enabled: false } });
|
|
const projectDir = path.join(tmp, '.planning', 'product-a');
|
|
const workstreamDir = path.join(tmp, '.planning', 'workstreams', 'alpha');
|
|
fs.mkdirSync(projectDir, { recursive: true });
|
|
fs.mkdirSync(workstreamDir, { recursive: true });
|
|
fs.writeFileSync(path.join(projectDir, 'config.json'), '{"planner":{"stall_detection_enabled":true}}\n');
|
|
fs.writeFileSync(path.join(workstreamDir, 'config.json'), '{"planner":{"stall_detection_enabled":true}}\n');
|
|
const home = { HOME: tmp, USERPROFILE: tmp };
|
|
|
|
assert.equal(runMsdTools(['config-get', 'planner.stall_detection_enabled', '--raw'], tmp, home).output, 'false');
|
|
assert.equal(runMsdTools(
|
|
['config-get', 'planner.stall_detection_enabled', '--raw'], tmp,
|
|
{ ...home, MSD_PROJECT: 'product-a' },
|
|
).output, 'true');
|
|
assert.equal(runMsdTools(
|
|
['config-get', 'planner.stall_detection_enabled', '--raw'], tmp,
|
|
{ ...home, MSD_WORKSTREAM: 'alpha' },
|
|
).output, 'true');
|
|
|
|
const { loadConfig } = require('../msd-core/bin/lib/config-loader.cjs');
|
|
assert.equal(loadConfig(tmp).planner_stall_detection_enabled, false);
|
|
assert.equal(loadConfig(tmp, { workstream: 'alpha' }).planner_stall_detection_enabled, true);
|
|
|
|
fs.writeFileSync(path.join(workstreamDir, 'config.json'), '{"planner":{}}\n');
|
|
assert.equal(runMsdTools(
|
|
['config-get', 'planner.stall_detection_enabled', '--raw'], tmp,
|
|
{ ...home, MSD_WORKSTREAM: 'alpha' },
|
|
).output, 'false', 'an omitted workstream value must inherit the root value');
|
|
assert.equal(loadConfig(tmp, { workstream: 'alpha' }).planner_stall_detection_enabled, false);
|
|
});
|
|
|
|
test('English and enumerating localized docs state default, CLI opt-out, effect, and recovery loss', () => {
|
|
for (const docsPath of [CONFIGURATION_DOCS_PATH, PT_BR_CONFIGURATION_DOCS_PATH, ZH_CN_CONFIGURATION_DOCS_PATH, JA_JP_CONFIGURATION_DOCS_PATH, KO_KR_CONFIGURATION_DOCS_PATH]) {
|
|
const docs = fs.readFileSync(docsPath, 'utf8');
|
|
assert.match(docs, /`planner\.stall_detection_enabled`\s*\|\s*boolean\s*\|\s*`true`/);
|
|
assert.match(docs, /config-set planner\.stall_detection_enabled false/);
|
|
assert.match(docs, /runtime-native|nativa do runtime|运行时原生|ランタイムネイティブ|런타임 네이티브/i);
|
|
assert.match(docs, /bounded recovery|recupera[cç][aã]o limitada|有界恢复|有界な復旧|제한된 복구/i);
|
|
}
|
|
assert.match(fs.readFileSync(ZH_CN_PLANNING_CONFIG_PATH, 'utf8'), /planner\.stall_detection_enabled/);
|
|
});
|
|
|
|
test('advanced settings warns about recovery loss before offering to persist false', () => {
|
|
const settings = fs.readFileSync(SETTINGS_ADVANCED_PATH, 'utf8');
|
|
assert.match(settings, /planner\.stall_detection_enabled/);
|
|
assert.match(settings, /default:\s*`true`/);
|
|
assert.match(settings, /bounded automatic recovery[\s\S]{0,500}false/i);
|
|
assert.match(settings, /config-set planner\.stall_detection_enabled false/);
|
|
});
|
|
});
|
|
|
|
describe('bug #2650 plan-phase — all five planner/plan-checker spawns dispatch in the background with bounded stall surveillance', () => {
|
|
let workflow;
|
|
|
|
test('loads', () => {
|
|
workflow = readPlanPhase();
|
|
assert.ok(workflow.length > 0);
|
|
});
|
|
|
|
test('plan-phase.md points at the lazily-loaded stall-detection-helpers.md step file (step 7.99)', () => {
|
|
assert.match(workflow, /msd-core\/workflows\/plan-phase\/steps\/stall-detection-helpers\.md/);
|
|
});
|
|
|
|
test('stall-detection-helpers.md resolves PLANNER_STALL_INTERVAL_MINUTES / PLANNER_STALL_THRESHOLD_MINUTES from config', () => {
|
|
const helpersDoc = readStallHelpersDoc();
|
|
assert.match(helpersDoc, /PLANNER_STALL_DETECTION_ENABLED=.*planner\.stall_detection_enabled/);
|
|
assert.match(helpersDoc, /PLANNER_STALL_INTERVAL_MINUTES=.*planner\.stall_detect_interval_minutes/);
|
|
assert.match(helpersDoc, /PLANNER_STALL_THRESHOLD_MINUTES=.*planner\.stall_threshold_minutes/);
|
|
});
|
|
|
|
test('invalid or absent toggle values normalize to default-on; only boolean false disables', (t) => {
|
|
const helpersBash = extractStallHelpersBash();
|
|
for (const [stored, expected] of [[undefined, 'true'], [true, 'true'], [false, 'false'], ['false', 'true'], [0, 'true'], [null, 'true']]) {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-4570-resolve-'));
|
|
t.after(() => cleanup(tmp));
|
|
fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true });
|
|
const config = stored === undefined ? {} : { planner: { stall_detection_enabled: stored } };
|
|
fs.writeFileSync(path.join(tmp, '.planning', 'config.json'), `${JSON.stringify(config)}\n`);
|
|
const result = runBashScript(
|
|
`${helpersBash}\nprintf '%s\\n' "$PLANNER_STALL_DETECTION_ENABLED"\n`,
|
|
[],
|
|
{
|
|
cwd: tmp,
|
|
env: { ...process.env, HOME: tmp, USERPROFILE: tmp, RUNTIME_DIR: REPO_ROOT, MSD_PROJECT: '', MSD_WORKSTREAM: '' },
|
|
},
|
|
);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.equal(result.stdout.trim(), expected, `stored ${JSON.stringify(stored)} resolved incorrectly`);
|
|
}
|
|
});
|
|
|
|
test('standard planner spawn (step 8) dispatches with run_in_background=true and calls msd_stall_watch', () => {
|
|
const idx = workflow.indexOf('## 8. Spawn msd-planner Agent');
|
|
assert.notEqual(idx, -1);
|
|
// #2993 moved "## 8.5. Chunked Planning Mode" itself out of plan-phase.md
|
|
// (now a <!-- msd:section --> pointer to steps/chunked-planning-mode.md,
|
|
// asserted separately below) — bound this slice at the next heading that
|
|
// still actually exists in plan-phase.md instead.
|
|
const nextSectionIdx = workflow.indexOf('## 9. Handle Planner Return', idx);
|
|
const section = workflow.slice(idx, nextSectionIdx === -1 ? undefined : nextSectionIdx);
|
|
assert.match(section, /run_in_background\s*=\s*true/, 'standard planner spawn must set run_in_background=true');
|
|
assert.match(section, /msd_stall_watch/, 'standard planner spawn must invoke the bounded stall watcher');
|
|
assert.match(section, /msd_stall_watch\s+"\$TS"\s+"\{outputFile\}"/, 'standard planner spawn must bind {outputFile} into the stall watcher call, not a dead bash variable');
|
|
});
|
|
|
|
test('plan-phase.md points at the lazily-loaded chunked-planning-mode.md step file (8.5, #2993)', () => {
|
|
// #2993 (epic #1671 Phase 6.2, unrelated to #2650) extracted the whole
|
|
// "Chunked Planning Mode" section into msd-core/workflows/plan-phase/steps/
|
|
// chunked-planning-mode.md, leaving a <!-- msd:section --> pointer behind.
|
|
// The two stall-watch spawn sites that used to live inline (8.5.1 outline,
|
|
// 8.5.2 per-plan) moved with it — asserted directly against that file below.
|
|
assert.match(workflow, /msd-core\/workflows\/plan-phase\/steps\/chunked-planning-mode\.md/);
|
|
});
|
|
|
|
test('chunked outline spawn (8.5.1) dispatches with run_in_background=true and calls msd_stall_watch', () => {
|
|
// Lives in the extracted steps/chunked-planning-mode.md since #2993, not
|
|
// in plan-phase.md itself — read that file directly (see
|
|
// readChunkedPlanningMode()'s doc comment for why not the generic
|
|
// combined-blob reader).
|
|
const chunkedDoc = readChunkedPlanningMode();
|
|
const idx = chunkedDoc.indexOf('### 8.5.1 Outline Phase');
|
|
assert.notEqual(idx, -1);
|
|
const nextSectionIdx = chunkedDoc.indexOf('### 8.5.2 Per-Plan Tasks', idx);
|
|
const section = chunkedDoc.slice(idx, nextSectionIdx === -1 ? undefined : nextSectionIdx);
|
|
assert.match(section, /run_in_background\s*=\s*true/, 'chunked outline spawn must set run_in_background=true');
|
|
assert.match(section, /msd_stall_watch/, 'chunked outline spawn must invoke the bounded stall watcher');
|
|
assert.match(section, /msd_stall_watch\s+"\$TS"\s+"\{outputFile\}"/, 'chunked outline spawn must bind {outputFile} into the stall watcher call, not a dead bash variable');
|
|
});
|
|
|
|
test('chunked per-plan spawn (8.5.2) dispatches with run_in_background=true and calls msd_stall_watch', () => {
|
|
// Same relocation as the outline spawn above (#2993) — read
|
|
// steps/chunked-planning-mode.md directly. 8.5.2 is the LAST section in
|
|
// that file, so an unbounded slice to EOF is precise here (unlike slicing
|
|
// the generic multi-file combined blob, which would run on into whatever
|
|
// step file sorts next after this one).
|
|
const chunkedDoc = readChunkedPlanningMode();
|
|
const idx = chunkedDoc.indexOf('### 8.5.2 Per-Plan Tasks');
|
|
assert.notEqual(idx, -1);
|
|
const section = chunkedDoc.slice(idx);
|
|
assert.match(section, /run_in_background\s*=\s*true/, 'chunked per-plan spawn must set run_in_background=true');
|
|
assert.match(section, /msd_stall_watch/, 'chunked per-plan spawn must invoke the bounded stall watcher');
|
|
assert.match(section, /msd_stall_watch\s+"\$TS"\s+"\{outputFile\}"/, 'chunked per-plan spawn must bind {outputFile} into the stall watcher call, not a dead bash variable');
|
|
});
|
|
|
|
test('plan-checker spawn (step 10) dispatches with run_in_background=true and calls msd_stall_watch', () => {
|
|
const idx = workflow.indexOf('## 10. Spawn msd-plan-checker Agent');
|
|
assert.notEqual(idx, -1);
|
|
const nextSectionIdx = workflow.indexOf('## 11. Handle Checker Return', idx);
|
|
const section = workflow.slice(idx, nextSectionIdx === -1 ? undefined : nextSectionIdx);
|
|
assert.match(section, /run_in_background\s*=\s*true/, 'plan-checker spawn must set run_in_background=true');
|
|
assert.match(section, /msd_stall_watch/, 'plan-checker spawn must invoke the bounded stall watcher');
|
|
assert.match(section, /msd_stall_watch\s+"\$TS"\s+"\{outputFile\}"/, 'plan-checker spawn must bind {outputFile} into the stall watcher call — this is the ONLY completion signal on a clean PASS, since a passing checker touches no *-PLAN.md files');
|
|
});
|
|
|
|
test('revision-loop planner respawn (step 12) dispatches with run_in_background=true and calls msd_stall_watch', () => {
|
|
const idx = workflow.indexOf('## 12. Revision Loop');
|
|
assert.notEqual(idx, -1);
|
|
const nextSectionIdx = workflow.indexOf('## 12.5. Plan Bounce', idx);
|
|
const section = workflow.slice(idx, nextSectionIdx === -1 ? undefined : nextSectionIdx);
|
|
assert.match(section, /run_in_background\s*=\s*true/, 'revision-loop planner respawn must set run_in_background=true');
|
|
assert.match(section, /msd_stall_watch/, 'revision-loop planner respawn must invoke the bounded stall watcher');
|
|
assert.match(section, /msd_stall_watch\s+"\$TS"\s+"\{outputFile\}"/, 'revision-loop planner respawn must bind {outputFile} into the stall watcher call, not a dead bash variable');
|
|
});
|
|
|
|
test('no spawn site references an unbound $PLANNER_OUTPUT_FILE / $CHECKER_OUTPUT_FILE bash variable', () => {
|
|
// Regression for the blocker an independent review found: the original
|
|
// design named PLANNER_OUTPUT_FILE/CHECKER_OUTPUT_FILE as bash variables
|
|
// in the msd_stall_watch calls, but nothing in plan-phase.md ever ASSIGNED
|
|
// them — with the variable permanently empty, `[ -f "$output_file" ]` is
|
|
// always false, marker_found can never become true, and marker_received is
|
|
// unreachable. Worse for the plan-checker spawn specifically: a checker
|
|
// that PASSES touches no *-PLAN.md files, so it has NO working completion
|
|
// signal at all without the marker path — a healthy, already-succeeded
|
|
// checker would be reported as stalled. The fix replaces the dead bash
|
|
// variable with the `{outputFile}` orchestrator-substitution token (the
|
|
// same convention docs-update.md:471 already uses for a real
|
|
// run_in_background=true Agent() return). This test proves the dead
|
|
// variable name is gone from every spawn site, not just that
|
|
// msd_stall_watch behaves correctly when handed a valid argument
|
|
// (tests/fix-2650-plan-phase-stall-detection.test.cjs's msd_stall_watch
|
|
// describe block below already covers that half — this covers the
|
|
// production wiring the previous tests never exercised).
|
|
assert.doesNotMatch(workflow, /\$PLANNER_OUTPUT_FILE\b/, 'plan-phase.md must not reference an unassigned $PLANNER_OUTPUT_FILE bash variable');
|
|
assert.doesNotMatch(workflow, /\$CHECKER_OUTPUT_FILE\b/, 'plan-phase.md must not reference an unassigned $CHECKER_OUTPUT_FILE bash variable');
|
|
// #2993 moved two of the five spawn sites into steps/chunked-planning-mode.md
|
|
// — check there too, not just plan-phase.md, now that it's a separate file.
|
|
const chunkedDoc = readChunkedPlanningMode();
|
|
assert.doesNotMatch(chunkedDoc, /\$PLANNER_OUTPUT_FILE\b/, 'chunked-planning-mode.md must not reference an unassigned $PLANNER_OUTPUT_FILE bash variable');
|
|
assert.doesNotMatch(chunkedDoc, /\$CHECKER_OUTPUT_FILE\b/, 'chunked-planning-mode.md must not reference an unassigned $CHECKER_OUTPUT_FILE bash variable');
|
|
});
|
|
|
|
test('exactly five msd_stall_watch spawn-site invocations exist across plan-phase.md and its steps/*.md files', () => {
|
|
// The whole point of #2650 is that EVERY planner/plan-checker spawn is
|
|
// bounded — not "at least one". #2993 relocated two of the five call
|
|
// sites (chunked outline, chunked per-plan) into
|
|
// steps/chunked-planning-mode.md; this counts across the combined
|
|
// surface so a future relocation can't silently drop a site without a
|
|
// test noticing (mirrors tests/plan-phase-drift-guard.test.cjs's #913
|
|
// ORCHESTRATOR RULE label count, which already does this).
|
|
const combined = readWorkflowCombined(PLAN_PHASE_PATH);
|
|
const callCount = (combined.match(/msd_stall_watch\s+"\$TS"\s+"\{outputFile\}"/g) || []).length;
|
|
assert.equal(callCount, 5,
|
|
`expected exactly 5 msd_stall_watch "$TS" "{outputFile}" spawn-site invocations across plan-phase.md + steps/*.md, found ${callCount}`);
|
|
});
|
|
|
|
test('all five spawn classes gate background surveillance and retain a runtime-native blocking result path', () => {
|
|
const mainSections = [
|
|
['standard planner', '## 8. Spawn msd-planner Agent', '## 9. Handle Planner Return'],
|
|
['plan-checker', '## 10. Spawn msd-plan-checker Agent', '## 11. Handle Checker Return'],
|
|
['revision planner', '## 12. Revision Loop', '## 12.5. Plan Bounce'],
|
|
];
|
|
const chunkedDoc = readChunkedPlanningMode();
|
|
const sections = mainSections.map(([label, start, end]) => {
|
|
const startAt = workflow.indexOf(start);
|
|
return [label, workflow.slice(startAt, workflow.indexOf(end, startAt))];
|
|
});
|
|
sections.push(
|
|
['chunked outline', chunkedDoc.slice(
|
|
chunkedDoc.indexOf('### 8.5.1 Outline Phase'),
|
|
chunkedDoc.indexOf('### 8.5.2 Per-Plan Tasks'),
|
|
)],
|
|
['chunked per-plan', chunkedDoc.slice(chunkedDoc.indexOf('### 8.5.2 Per-Plan Tasks'))],
|
|
);
|
|
|
|
for (const [label, section] of sections) {
|
|
assert.match(section, /PLANNER_STALL_DETECTION_ENABLED/, `${label}: missing toggle gate`);
|
|
assert.match(section, /`true`[\s\S]*run_in_background=true[\s\S]*msd_stall_watch/,
|
|
`${label}: default-on branch must retain background watcher behavior`);
|
|
assert.match(section, /`false`[\s\S]{0,700}(?:omit|without) `?run_in_background`?[\s\S]{0,700}(?:ordinary|runtime-native)[\s\S]{0,500}(?:return|result)/i,
|
|
`${label}: explicit-off branch must omit backgrounding and await the real runtime result`);
|
|
}
|
|
});
|
|
|
|
test('step 7.99 documents that {outputFile} must be bound from the real Agent() return (not passed literally)', () => {
|
|
const idx = workflow.indexOf('## 7.99. Bounded Stall-Detection Helpers');
|
|
assert.notEqual(idx, -1);
|
|
const nextSectionIdx = workflow.indexOf('## 8. Spawn msd-planner Agent', idx);
|
|
const section = workflow.slice(idx, nextSectionIdx === -1 ? undefined : nextSectionIdx);
|
|
assert.match(section, /\{outputFile\}/, 'step 7.99 must mention {outputFile} so a reader knows it is a binding token, not literal text');
|
|
// The full binding contract (docs-update.md precedent, why a bash variable
|
|
// does not work, and the plan-checker completion-signal implication) lives
|
|
// in the lazily-loaded reference file to stay under the PRE_PHASE6 cap —
|
|
// verify it is actually there, not just gestured at.
|
|
const helpersDoc = readStallHelpersDoc();
|
|
assert.match(helpersDoc, /\{outputFile\}/, 'stall-detection-helpers.md must explain the {outputFile} binding contract');
|
|
assert.match(helpersDoc, /docs-update\.md/i, 'stall-detection-helpers.md must cite the docs-update.md precedent for {outputFile} substitution');
|
|
assert.match(helpersDoc, /plan-checker/i, 'stall-detection-helpers.md must explain why binding {outputFile} is load-bearing for the plan-checker spawn specifically');
|
|
});
|
|
|
|
test('stall surveillance is not gated behind the teams-status guard (AC2)', () => {
|
|
// The only actual `query teams-status` CALL in plan-phase.md must stay
|
|
// scoped to the researcher spawn banner (its pre-existing, unrelated
|
|
// purpose) — the new stall blocks must not add a second call site or make
|
|
// their own behavior conditional on it. The helpers doc is allowed (and
|
|
// expected) to name "teams-status" in prose explaining that independence
|
|
// (AC2 self-documentation) — what must never appear is a SECOND `query
|
|
// teams-status` invocation, or any conditional gating on its result.
|
|
const teamsStatusCallOccurrences = workflow.split('query teams-status').length - 1;
|
|
assert.equal(teamsStatusCallOccurrences, 1, 'teams-status guard must remain scoped to its single pre-existing call site');
|
|
assert.doesNotMatch(readStallHelpersDoc(), /query teams-status/, 'stall-detection helpers must not add their own teams-status call site');
|
|
});
|
|
|
|
test('completion-marker contract is unchanged (AC4)', () => {
|
|
for (const marker of ['## PLANNING COMPLETE', '## CHECKPOINT REACHED', '## VERIFICATION PASSED', '## ISSUES FOUND', '## PLANNING INCONCLUSIVE']) {
|
|
assert.ok(workflow.includes(marker), `completion-marker contract must still include ${marker}`);
|
|
}
|
|
});
|
|
|
|
test('researcher (line ~404) and pattern-mapper (line ~681) spawns are untouched (out of scope)', () => {
|
|
const researcherIdx = workflow.indexOf('### Spawn msd-phase-researcher');
|
|
const patternMapperIdx = workflow.indexOf('## 7.8. Spawn msd-pattern-mapper Agent');
|
|
assert.notEqual(researcherIdx, -1);
|
|
assert.notEqual(patternMapperIdx, -1);
|
|
const researcherSection = workflow.slice(researcherIdx, workflow.indexOf('### Handle Researcher Return'));
|
|
const patternMapperSection = workflow.slice(patternMapperIdx, workflow.indexOf('## 7.9. Regenerate API-SURFACE.md'));
|
|
assert.doesNotMatch(researcherSection, /msd_stall_watch/, 'researcher spawn must remain a plain blocking call (out of scope per Agent Brief)');
|
|
assert.doesNotMatch(patternMapperSection, /msd_stall_watch/, 'pattern-mapper spawn must remain a plain blocking call (out of scope per Agent Brief)');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #2650 follow-up: the spawn bound was sized for the wrong CLASS of call.
|
|
//
|
|
// runBashScript hard-coded `timeout: 10000`. The script it runs is not a cheap
|
|
// probe: extractStallHelpersBash slices the ENTIRE ```bash fence, whose runtime-launcher
|
|
// preamble resolves msd-tools.cjs and really runs two `msd_run query config-get` lines —
|
|
// two full Node spawns (measured 236ms vs 2ms for the fallback the old comment claimed
|
|
// fires: 118x). On `full test (windows-latest, 24, shard 1/3)` that bound was exceeded at
|
|
// 10006ms and spawnSync's kill surfaced as `status: null`, which the call site asserted
|
|
// as `null !== 0` — a message naming neither the timeout nor the bound.
|
|
//
|
|
// tests/helpers/timeouts.cjs already owns this exact class (HOOK_FANOUT_TIMEOUT_MS), and
|
|
// its comment records the identical failure on PR #3285: "a bound sized for the wrong
|
|
// class, not a slow machine."
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#2650 follow-up: runBashScript bounds and reports a bash fan-out correctly', () => {
|
|
/**
|
|
* An arbitrary value proving `runBashScript`'s explicit timeoutMs
|
|
* argument overrides its own class-norm default (HOOK_FANOUT_TIMEOUT_MS).
|
|
*/
|
|
const RUN_BASH_SCRIPT_OVERRIDE_TIMEOUT_MS = 1234;
|
|
/**
|
|
* Deliberately tiny (not generous headroom) to force a real `sleep 5`
|
|
* command past the bound within this test's own lifetime, proving an
|
|
* exceeded bound reports TIMED_OUT, not a bare null status.
|
|
*/
|
|
const RUN_BASH_SCRIPT_FORCED_TIMEOUT_MS = 250;
|
|
/** CLAUDE.md boundary-coverage triple (limit-1/limit/limit+1) on runBashScript's own timeoutMs value-domain validation: a negative bound must be rejected. */
|
|
const RUN_BASH_SCRIPT_TIMEOUT_BOUNDARY_NEGATIVE_MS = -1;
|
|
/** CLAUDE.md boundary-coverage triple (limit-1/limit/limit+1) on runBashScript's own timeoutMs value-domain validation: zero must be rejected, never read as unbounded. Used at both occurrences in this test (the throw assertion and the later leak-check re-throw). */
|
|
const RUN_BASH_SCRIPT_TIMEOUT_BOUNDARY_ZERO_MS = 0;
|
|
/** CLAUDE.md boundary-coverage triple (limit-1/limit/limit+1) on runBashScript's own timeoutMs value-domain validation: the smallest positive bound is valid and must be accepted. */
|
|
const RUN_BASH_SCRIPT_TIMEOUT_BOUNDARY_ONE_MS = 1;
|
|
|
|
test('bounds the fan-out with the class norm, and an explicit override still wins', (t) => {
|
|
t.after(() => mock.restoreAll());
|
|
const seen = [];
|
|
mock.method(processSeam, 'runHook', (target, args, opts) => {
|
|
seen.push(opts);
|
|
return { outcome: OUTCOME.EXITED, exitCode: 0, stdout: '', stderr: '', timedOut: false, signal: null, killed: false, code: null };
|
|
});
|
|
|
|
runBashScript('echo hi\n');
|
|
assert.equal(seen.length, 1,
|
|
'runBashScript must route through the process seam (CONTRIBUTING.md: never a hand-rolled spawnSync in a suite)');
|
|
assert.equal(seen[0].timeoutMs, HOOK_FANOUT_TIMEOUT_MS,
|
|
`default bound must be the bash-fan-out class norm (${HOOK_FANOUT_TIMEOUT_MS}ms), not a probe-sized literal; got ${seen[0].timeoutMs}`);
|
|
assert.equal(seen[0].interpreter, 'bash', 'the seam must be told to run the script under bash');
|
|
|
|
runBashScript('echo hi\n', [], { timeoutMs: RUN_BASH_SCRIPT_OVERRIDE_TIMEOUT_MS });
|
|
assert.equal(seen[1].timeoutMs, RUN_BASH_SCRIPT_OVERRIDE_TIMEOUT_MS, 'an explicit timeoutMs must override the class norm');
|
|
});
|
|
|
|
test('an exceeded bound reports TIMED_OUT, not a bare null status', () => {
|
|
// A real sleep against a deliberately tiny bound. The assertion is on the
|
|
// CLASSIFICATION, never on elapsed time.
|
|
const result = runBashScript('sleep 5\n', [], { timeoutMs: RUN_BASH_SCRIPT_FORCED_TIMEOUT_MS });
|
|
assert.equal(result.outcome, OUTCOME.TIMED_OUT,
|
|
`an exceeded bound must name itself; got outcome=${result.outcome} status=${result.status}`);
|
|
assert.equal(result.timedOut, true, 'timedOut must be true when the bound is exceeded');
|
|
});
|
|
|
|
test('a genuine non-zero exit is EXITED, never confused with a timeout', () => {
|
|
const result = runBashScript('exit 3\n');
|
|
assert.equal(result.outcome, OUTCOME.EXITED,
|
|
'a prompt non-zero exit is an EXITED outcome, not a timeout');
|
|
assert.equal(result.status, 3, 'the real exit code must survive');
|
|
assert.equal(result.timedOut, false, 'a real exit must not be reported as timed out');
|
|
});
|
|
|
|
test('boundary: a non-positive bound is rejected, never silently run unbounded', () => {
|
|
// limit-1 / limit / limit+1 on the VALUE DOMAIN of the bound, not on wall-clock
|
|
// timing — the previous test already covers the exceeded-bound classification, and
|
|
// an exact-millisecond timing edge would be a race, not a boundary.
|
|
//
|
|
// Zero is the load-bearing case: spawnSync reads `timeout: 0` as "no timeout at
|
|
// all", which is precisely the unbounded-spawn hazard local/no-unbounded-spawn
|
|
// exists to prevent (CONTRIBUTING.md: "`timeout: 0` — Node reads zero as *no
|
|
// timeout*"). The seam rejects it instead of honouring it.
|
|
assert.throws(() => runBashScript('exit 0\n', [], { timeoutMs: RUN_BASH_SCRIPT_TIMEOUT_BOUNDARY_ZERO_MS }), TypeError,
|
|
'limit: zero must be rejected, never read as unbounded');
|
|
assert.throws(() => runBashScript('exit 0\n', [], { timeoutMs: RUN_BASH_SCRIPT_TIMEOUT_BOUNDARY_NEGATIVE_MS }), TypeError,
|
|
'limit-1: a negative bound must be rejected');
|
|
assert.doesNotThrow(() => runBashScript('exit 0\n', [], { timeoutMs: RUN_BASH_SCRIPT_TIMEOUT_BOUNDARY_ONE_MS }),
|
|
'limit+1: the smallest positive bound is valid and must be accepted');
|
|
|
|
// The helper must still clean up its temp dir when the seam throws — the throw
|
|
// escapes through runBashScript's `finally`, which is what makes the rejection safe
|
|
// to rely on rather than a resource leak.
|
|
const before = fs.readdirSync(os.tmpdir()).filter((n) => n.startsWith('msd-2650-sh-')).length;
|
|
assert.throws(() => runBashScript('exit 0\n', [], { timeoutMs: RUN_BASH_SCRIPT_TIMEOUT_BOUNDARY_ZERO_MS }), TypeError);
|
|
const after = fs.readdirSync(os.tmpdir()).filter((n) => n.startsWith('msd-2650-sh-')).length;
|
|
assert.equal(after, before, 'a rejected bound must not leak the script temp dir');
|
|
});
|
|
});
|