// 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