diff --git a/.changeset/brave-pandas-listen.md b/.changeset/brave-pandas-listen.md new file mode 100644 index 000000000..194b5e52e --- /dev/null +++ b/.changeset/brave-pandas-listen.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4718 +--- +**`/gsd:debug` no longer spawns duplicate debuggers that collide on the session file** — the debug session manager spawned its `gsd-debugger` without `run_in_background=false`, so Claude Code backgrounded it, the manager had no result to inspect, and it returned a non-terminal summary. The orchestrator's auto-resume then started a second debugger against the same `.planning/debug/.md`, up to the three-resume cap — which is why the collision showed up exactly three times per invocation. The spawn now blocks, matching the rule already applied one level up (#2196). (#4395) diff --git a/agents/gsd-debug-session-manager.compact.md b/agents/gsd-debug-session-manager.compact.md index f0343a7bb..ae8d84312 100644 --- a/agents/gsd-debug-session-manager.compact.md +++ b/agents/gsd-debug-session-manager.compact.md @@ -92,10 +92,25 @@ Agent( prompt=filled_prompt, subagent_type="gsd-debugger", model="{debugger_model}", - description="Debug {slug}" + description="Debug {slug}", + run_in_background=false ) ``` +**Foreground, blocking spawn — #4395.** `run_in_background: false` is REQUIRED, for the same +reason `/gsd:debug` requires it when spawning this agent (#2196): Claude Code backgrounds +subagents by default, and only that flag makes the spawn return the debugger's structured header +for Step 3 to classify. Backgrounded, Step 3 has nothing to inspect, so this agent returns +`CONTINUE_REQUIRED`, the orchestrator auto-resumes (#2257/#3448), and the resumed manager spawns a +SECOND debugger that races the first on `.planning/debug/{slug}.md`. Wait for it; do not background +it, and do not poll for it. Never pass an agent id to `TaskOutput` — an agent id is not a task id. + +**This rule is agent-wide, not per-call.** Every `Agent()` this agent issues carries +`run_in_background=false`, including the Step 3 continuation spawns. Most of those sites say +only "spawn continuation agent" without naming a format, so they inherit this rule rather than +a flag written at each one — which is exactly why Step 2 must remain the only `Agent()` spawn +literal in this file. + Resolve the debugger model before spawning (canonical `gsd_run` preamble — established once here, the single definition this agent carries): ```bash _GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; _gsd_at() { for _p; do if [ -f "$_p" ]; then GSD_TOOLS="$_p"; return 0; fi; done; return 1; }; if _gsd_at "${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif unset -f gsd_run; _G="$(command -v gsd_run)"; then GSD_TOOLS="$_G"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif _gsd_at "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; then gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd_run is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; GSD_IDENTITY_STATUS=unverified; case "$(gsd_run runtime-identity --raw 2>/dev/null || true)" in '{"packageName":"@opengsd/gsd-core"'*'}') GSD_IDENTITY_STATUS=ok;; esac; export GSD_IDENTITY_STATUS; [ "$GSD_IDENTITY_STATUS" = ok ] || echo "WARNING: \"$GSD_TOOLS\" did not prove it is @opengsd/gsd-core - it is either a different package or an @opengsd/gsd-core older than the runtime-identity verb. See docs/how-to/diagnose-a-foreign-gsd-tools.md" >&2; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi diff --git a/agents/gsd-debug-session-manager.md b/agents/gsd-debug-session-manager.md index f3be4cc2f..9f1ddcf4a 100644 --- a/agents/gsd-debug-session-manager.md +++ b/agents/gsd-debug-session-manager.md @@ -98,10 +98,25 @@ Agent( prompt=filled_prompt, subagent_type="gsd-debugger", model="{debugger_model}", - description="Debug {slug}" + description="Debug {slug}", + run_in_background=false ) ``` +**Foreground, blocking spawn — #4395.** `run_in_background: false` is REQUIRED, for the same +reason `/gsd:debug` requires it when spawning this agent (#2196): Claude Code backgrounds +subagents by default, and only that flag makes the spawn return the debugger's structured header +for Step 3 to classify. Backgrounded, Step 3 has nothing to inspect, so this agent returns +`CONTINUE_REQUIRED`, the orchestrator auto-resumes (#2257/#3448), and the resumed manager spawns a +SECOND debugger that races the first on `.planning/debug/{slug}.md`. Wait for it; do not background +it, and do not poll for it. Never pass an agent id to `TaskOutput` — an agent id is not a task id. + +**This rule is agent-wide, not per-call.** Every `Agent()` this agent issues carries +`run_in_background=false`, including the Step 3 continuation spawns. Most of those sites say +only "spawn continuation agent" without naming a format, so they inherit this rule rather than +a flag written at each one — which is exactly why Step 2 must remain the only `Agent()` spawn +literal in this file. + Resolve the debugger model before spawning: ```bash _GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; _gsd_at() { for _p; do if [ -f "$_p" ]; then GSD_TOOLS="$_p"; return 0; fi; done; return 1; }; if _gsd_at "${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif unset -f gsd_run; _G="$(command -v gsd_run)"; then GSD_TOOLS="$_G"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif _gsd_at "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; then gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd_run is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; GSD_IDENTITY_STATUS=unverified; case "$(gsd_run runtime-identity --raw 2>/dev/null || true)" in '{"packageName":"@opengsd/gsd-core"'*'}') GSD_IDENTITY_STATUS=ok;; esac; export GSD_IDENTITY_STATUS; [ "$GSD_IDENTITY_STATUS" = ok ] || echo "WARNING: \"$GSD_TOOLS\" did not prove it is @opengsd/gsd-core - it is either a different package or an @opengsd/gsd-core older than the runtime-identity verb. See docs/how-to/diagnose-a-foreign-gsd-tools.md" >&2; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi diff --git a/gsd-core/workflows/debug.md b/gsd-core/workflows/debug.md index 18fb6ff89..54330cdff 100644 --- a/gsd-core/workflows/debug.md +++ b/gsd-core/workflows/debug.md @@ -149,7 +149,7 @@ specialist_dispatch_enabled: true Display the compact summary returned by the session manager. -**Return handling — exhaustive, no fallthrough (#2257).** Apply the same three-way classification as Section 4 "Session Management" below: `DEBUG SESSION COMPLETE` and `ABANDONED` are the only two terminal shapes. ANYTHING ELSE — including the explicit `## CONTINUE_REQUIRED` marker and any unrecognized or malformed summary that is not one of the two terminal markers — is non-terminal. Read `.planning/debug/{SLUG}.md` for the current `status`/`next_action` and AUTO-RESUME by re-spawning `gsd-debug-session-manager` with the SAME `SLUG`/`debug_file_path` and the same `session_params` as the spawn above PLUS the resume parameters (#3448): `resume: true`, `resume_status: {status}`, `resume_next_action: {next_action}`, both sourced from `.planning/debug/{SLUG}.md`. The respawn must NOT be parameter-identical to a cold start: identical params drop the recorded next action and the disposition that any earlier checkpoint in the session was already answered, so the debugger re-derives — or stalls before — the very step the checkpoint already names (the #3448 auto-resume stall). Do NOT return control to the user, and do NOT report the session as complete. +**Return handling — exhaustive, no fallthrough (#2257).** Apply the same three-way classification as Section 4 "Session Management" below: `DEBUG SESSION COMPLETE` and `ABANDONED` are the only two terminal shapes. ANYTHING ELSE — including the explicit `## CONTINUE_REQUIRED` marker and any unrecognized or malformed summary that is not one of the two terminal markers — is non-terminal. Read `.planning/debug/{SLUG}.md` for the current `status`/`next_action` and AUTO-RESUME by re-spawning `gsd-debug-session-manager` with the SAME `SLUG`/`debug_file_path` and the same `session_params` as the spawn above PLUS the resume parameters (#3448): `resume: true`, `resume_status: {status}`, `resume_next_action: {next_action}`, both sourced from `.planning/debug/{SLUG}.md`. The respawn must NOT be parameter-identical to a cold start: identical params drop the recorded next action and the disposition that any earlier checkpoint in the session was already answered, so the debugger re-derives — or stalls before — the very step the checkpoint already names (the #3448 auto-resume stall). The re-spawn carries `run_in_background=false` exactly as the original spawn does (#2196): `session_params` is prompt content, not the spawn flag, so "the same `session_params`" does not carry it. Do NOT return control to the user, and do NOT report the session as complete. **Anti-loop guard.** Same two-stop policy as Section 4 "Session Management": (1) a no-progress heuristic keyed on `next_action` ALONE from `.planning/debug/{SLUG}.md` — never `updated`, which is overwritten on every checkpoint write (`agents/gsd-debugger.md`: "Update the file BEFORE taking action"), so it changes every cycle and can never signal no-progress. Two consecutive auto-resumes with `next_action` UNCHANGED stop the loop and print a blocker report to the user (checkpoint path, status, next_action, "N auto-resumes made no progress"). And (2) an absolute hard cap, independent of content: the orchestrator tracks a running total of auto-resume spawns for this `SLUG` within the current `/gsd:debug` invocation; after **3** total auto-resumes for the slug, STOP auto-resuming and emit the blocker report REGARDLESS of whether `next_action` changed. The hard cap is the guaranteed termination bound; the no-progress heuristic is only a faster early exit before the cap is reached. @@ -243,7 +243,7 @@ Display the compact summary returned by the session manager. 1. **Terminal — complete.** Summary shows `DEBUG SESSION COMPLETE` (without an `ABANDONED` status line): the session is finished. Stop. 2. **Terminal — abandoned.** Summary shows `ABANDONED`: note session saved at `.planning/debug/{slug}.md` for later `/gsd:debug continue {slug}`. Stop. -3. **Non-terminal — auto-resume.** ANYTHING ELSE — including the explicit `## CONTINUE_REQUIRED` marker and any unrecognized or malformed summary that is not one of the two terminal markers above — is non-terminal. Read `.planning/debug/{slug}.md` for the current `status` and `next_action`, then AUTO-RESUME by re-spawning `gsd-debug-session-manager` with the SAME `slug`/`debug_file_path` and the same `session_params` as the spawn above PLUS the resume parameters (#3448): `resume: true`, `resume_status: {status}`, `resume_next_action: {next_action}`, both read from `.planning/debug/{slug}.md`. The respawn must NOT be parameter-identical to a cold start: identical params drop the recorded next action and the disposition that any earlier checkpoint in the session was already answered, so the debugger re-derives — or stalls before — the very step the checkpoint already names (the #3448 auto-resume stall). Do NOT return control to the user; do NOT report the session as complete. +3. **Non-terminal — auto-resume.** ANYTHING ELSE — including the explicit `## CONTINUE_REQUIRED` marker and any unrecognized or malformed summary that is not one of the two terminal markers above — is non-terminal. Read `.planning/debug/{slug}.md` for the current `status` and `next_action`, then AUTO-RESUME by re-spawning `gsd-debug-session-manager` with the SAME `slug`/`debug_file_path` and the same `session_params` as the spawn above PLUS the resume parameters (#3448): `resume: true`, `resume_status: {status}`, `resume_next_action: {next_action}`, both read from `.planning/debug/{slug}.md`. The respawn must NOT be parameter-identical to a cold start: identical params drop the recorded next action and the disposition that any earlier checkpoint in the session was already answered, so the debugger re-derives — or stalls before — the very step the checkpoint already names (the #3448 auto-resume stall). The re-spawn carries `run_in_background=false` exactly as the original spawn does (#2196): `session_params` is prompt content, not the spawn flag, so "the same `session_params`" does not carry it. Do NOT return control to the user; do NOT report the session as complete. **Anti-loop guard.** Two independent stops apply; the orchestrator honors whichever trips first: diff --git a/tests/debug-manager-blocking-spawn.test.cjs b/tests/debug-manager-blocking-spawn.test.cjs new file mode 100644 index 000000000..ebfd5e4b8 --- /dev/null +++ b/tests/debug-manager-blocking-spawn.test.cjs @@ -0,0 +1,146 @@ +// Shipped agent Markdown is the installed contract: the runtime reads these +// files and performs the Agent() spawns they describe, so the shipped text is +// the subject under test. + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { readFileNormalized } = require('./helpers.cjs'); + +const ROOT = path.join(__dirname, '..'); +const AGENTS_DIR = path.join(ROOT, 'agents'); +const MANAGER = path.join(AGENTS_DIR, 'gsd-debug-session-manager.md'); +const MANAGER_COMPACT = path.join(AGENTS_DIR, 'gsd-debug-session-manager.compact.md'); +const DEBUG_WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'debug.md'); + +// Pull out each `Agent( … )` call literally, so a row asserts against the +// shipped spawn rather than against a pattern retyped from the fix. +function agentSpawnBlocks(content) { + const blocks = []; + // Multi-line form: Agent(\n … \n). Non-greedy, so it stops at the first + // line that is exactly ")". + const multi = /Agent\(\s*\n[\s\S]*?\n\)/g; + // Single-line form: Agent(…). Without this a one-line spawn evades the + // class guard in row 4 entirely — the guard would be vacuous against + // exactly the kind of future offender it exists to catch. + const single = /Agent\([^\n)]*\)/g; + let m; + while ((m = multi.exec(content)) !== null) blocks.push(m[0]); + while ((m = single.exec(content)) !== null) blocks.push(m[0]); + return blocks; +} + +function debuggerSpawn(content, label) { + const matches = agentSpawnBlocks(content).filter((b) => b.includes('subagent_type="gsd-debugger"')); + assert.equal(matches.length, 1, `${label}: expected exactly one gsd-debugger spawn block`); + return matches[0]; +} + +describe('debug session manager: the debugger spawn must block (#4395)', () => { + // ROW 1 + 2 — the reported defect, in both shipped variants. + for (const [label, file] of [['full', MANAGER], ['compact', MANAGER_COMPACT]]) { + test(`${label} variant spawns gsd-debugger with run_in_background: false`, () => { + const spawn = debuggerSpawn(readFileNormalized(file), `${label} variant`); + // Claude Code backgrounds subagents by DEFAULT — debug.md:209 (#2196) says + // so outright. Without the flag the manager's Step 3 ("Handle Agent + // Return") has no return to inspect, so it emits CONTINUE_REQUIRED, the + // orchestrator auto-resumes, and a second detached debugger races the + // first on .planning/debug/.md. + assert.match( + spawn, + /run_in_background\s*=\s*false/, + `${label} variant: the debugger spawn must be blocking, or the manager cannot see its result`, + ); + }); + } + + // ROW 3 — the pair cannot drift apart. + test('both variants agree on the debugger spawn flag', () => { + const full = debuggerSpawn(readFileNormalized(MANAGER), 'full variant'); + const compact = debuggerSpawn(readFileNormalized(MANAGER_COMPACT), 'compact variant'); + const flagOf = (s) => (s.match(/run_in_background\s*=\s*(true|false)/) || [])[1]; + // Assert the flag is PRESENT before asserting agreement: two missing flags + // are also "equal", so a bare equality check passes vacuously on the very + // tree this test exists to reject. + assert.ok(flagOf(full), 'full variant must declare the flag at all'); + assert.ok(flagOf(compact), 'compact variant must declare the flag at all'); + assert.equal( + flagOf(compact), + flagOf(full), + 'the compact sibling must not diverge from the canonical agent on spawn semantics', + ); + }); + + // ROW 4 — close the CLASS, not just this instance. + test('every subagent spawn under agents/ declares run_in_background explicitly', () => { + const offenders = []; + for (const name of fs.readdirSync(AGENTS_DIR)) { + if (!name.endsWith('.md')) continue; + const file = path.join(AGENTS_DIR, name); + for (const block of agentSpawnBlocks(readFileNormalized(file))) { + if (!block.includes('subagent_type=')) continue; + if (!/run_in_background\s*=\s*(true|false)/.test(block)) { + offenders.push(`${name}: ${block.split('\n').find((l) => l.includes('subagent_type=')).trim()}`); + } + } + } + assert.deepEqual( + offenders, + [], + 'An agent that spawns a subagent must DECIDE whether that spawn blocks. ' + + 'Leaving it unstated inherits the host default (Claude Code backgrounds ' + + 'subagents), which is how #4395 produced colliding debuggers.\n ' + + offenders.join('\n '), + ); + }); + + // ROW 5 — the fix one level up is undisturbed. + test('the orchestrator to session-manager hop still mandates a blocking spawn (#2196)', () => { + const workflow = readFileNormalized(DEBUG_WORKFLOW); + assert.match( + workflow, + /run_in_background:\s*false`\s*—\s*Claude Code backgrounds subagents by default/, + 'debug.md must keep the #2196 mandate that the session-manager spawn blocks', + ); + }); + + // ROW 6 — Step 2 must stay the SOLE spawn literal, in both variants. + // + // This is the invariant the fix actually rests on, and it is narrower than it + // first looks. The continuation sites do NOT all name Step 2: in the full + // variant, 8 sites say "spawn continuation agent" and exactly ONE adds + // "(see Step 2 format)"; the compact variant says "Step 2 format" without the + // "see". The other sites inherit the blocking flag only because there is no + // other Agent() spawn literal in the file to inherit from. Pin that, not the + // prose wording — if a second literal is ever added, those sites silently stop + // inheriting and #4395 comes back on whichever paths route through it. + for (const [label, file] of [['full', MANAGER], ['compact', MANAGER_COMPACT]]) { + test(`${label} variant keeps Step 2 as the sole Agent() spawn literal`, () => { + const content = readFileNormalized(file); + const spawnBlocks = agentSpawnBlocks(content).filter((b) => b.includes('subagent_type=')); + assert.equal( + spawnBlocks.length, + 1, + `${label} variant: exactly one Agent() spawn literal may exist — the continuation sites ` + + 're-spawn by reference to it, so a second literal would not carry the flag', + ); + assert.ok( + /Step 2 format/.test(content), + `${label} variant: at least one continuation site must still name Step 2 as the format source`, + ); + }); + } + + // ROW 7 — the non-terminal shape was not collaterally removed. + test('CONTINUE_REQUIRED and both terminal markers survive', () => { + const manager = readFileNormalized(MANAGER); + // CONTINUE_REQUIRED has a legitimate trigger unrelated to this defect: the + // manager genuinely exhausting its own turn budget mid-investigation. + assert.match(manager, /## CONTINUE_REQUIRED/, 'the non-terminal marker must remain'); + assert.match(manager, /## DEBUG SESSION COMPLETE/, 'the terminal marker must remain'); + assert.match(manager, /ABANDONED/, 'the abandoned disposition must remain'); + }); +}); diff --git a/tests/fixtures/compact-content-variant-benchmark-baseline.json b/tests/fixtures/compact-content-variant-benchmark-baseline.json index 058ed00b6..bd2c6ba60 100644 --- a/tests/fixtures/compact-content-variant-benchmark-baseline.json +++ b/tests/fixtures/compact-content-variant-benchmark-baseline.json @@ -23,9 +23,9 @@ "reductionPct": 20.79 }, "agents/gsd-code-fixer.md": { - "offTokens": 10741, + "offTokens": 10740, "onTokens": 6690, - "reductionPct": 37.72 + "reductionPct": 37.71 }, "agents/gsd-code-reviewer.md": { "offTokens": 4408, @@ -38,9 +38,9 @@ "reductionPct": 9.76 }, "agents/gsd-debug-session-manager.md": { - "offTokens": 4766, - "onTokens": 4477, - "reductionPct": 6.06 + "offTokens": 5027, + "onTokens": 4738, + "reductionPct": 5.75 }, "agents/gsd-doc-classifier.md": { "offTokens": 2901, @@ -169,8 +169,8 @@ } }, "aggregate": { - "offTokens": 118698, - "onTokens": 94341, - "reductionPct": 20.52 + "offTokens": 118958, + "onTokens": 94602, + "reductionPct": 20.47 } }