* test(#4395): prove the manager spawns its debugger without blocking
Failing-first regression coverage for #4395.
debug.md:209 mandates the orchestrator to session-manager spawn carry
run_in_background: false, and says why outright: "Claude Code backgrounds
subagents by default, and only that flag makes the spawn return the
compact session summary directly" (#2196).
The session-manager to debugger spawn, one level down, carries no flag.
Measured: run_in_background appears nowhere under agents/ -- only in
gsd-core/workflows/. So by the rule #2196 itself states, that spawn is
backgrounded, Step 3 ("Handle Agent Return") has no return to inspect,
the manager emits CONTINUE_REQUIRED, the orchestrator auto-resumes per
#2257/#3448, and a second detached debugger races the first on
.planning/debug/<slug>.md.
Row 4 is the load-bearing one: it closes the CLASS by requiring every
subagent spawn under agents/ to declare run_in_background explicitly, so
the next agent that spawns one has to decide rather than inherit a silent
host default. It is scoped to agents/ precisely so it cannot misfire on
the workflows that deliberately use true for parallel fan-out.
Rows 5-7 are pins, not fixes: the #2196 mandate one level up, Step 2 as
the single spawn-format source that the eight continuation sites delegate
to, and the survival of CONTINUE_REQUIRED (which has a legitimate trigger
unrelated to this defect).
Red round: 4 of 7 rows fail. Row 3 needed hardening first -- asserting
only that the two variants AGREE passed vacuously, because two missing
flags are also equal; it now asserts each is present before comparing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(#4395): make the manager's own debugger spawn blocking
The orchestrator-to-manager hop already requires a blocking spawn and says
why (#2196, debug.md:209): Claude Code backgrounds subagents by default,
and only run_in_background=false makes the spawn return its summary. The
manager-to-debugger hop, one level down, carried no flag -- measured,
run_in_background appeared nowhere under agents/ at all.
So that spawn was backgrounded. Step 3 ("Handle Agent Return") opens
"Inspect the return output for the structured return header" -- with
nothing to inspect, the manager correctly declined to fabricate a terminal
summary and returned CONTINUE_REQUIRED; the orchestrator correctly
auto-resumed (#2257/#3448); the resumed manager reached Step 2 and spawned
a SECOND detached debugger. Both then raced on .planning/debug/<slug>.md.
Every observable in the report follows with no further assumption,
including the count: the reporter saw exactly three collisions in one
invocation, and debug.md:251 caps auto-resumes at three per slug -- one
collision per cycle.
Fixed at the cause, in both shipped variants, kept byte-consistent. The
eight continuation sites say "see Step 2 format", so they inherit it.
The issue offered two remedies. The second -- have the auto-resume path
reconcile a still-running debugger before spawning another -- is not taken:
it treats the symptom, and needs machinery that does not exist (no portable
way to enumerate or stop another runtime's live agents, plus an in-flight
sentinel with staleness and recovery rules, or an orphaned marker deadlocks
the session permanently). With the spawn blocking, the manager cannot reach
Step 4 while a debugger is live, so such a guard would also be unreachable.
#2257, #3448, the anti-loop heuristic, the cap of three, and the
CONTINUE_REQUIRED shape are all correct and untouched. CONTINUE_REQUIRED
keeps its legitimate trigger: the manager genuinely exhausting its own turn
budget mid-investigation.
Also corrects the red-round test to the canonical CALL form. debug.md
writes run_in_background=false inside Agent(...) and run_in_background:
false in prose; the first draft asserted the prose form, which the shipped
call would never have matched.
Emitted-Drift-Ack-Growth: gsd-debug-session-manager.md — the blocking spawn flag plus the note recording why an unstated flag produced colliding debuggers
Emitted-Drift-Ack-Growth: gsd-debug-session-manager.compact.md — same change as its full sibling, kept byte-consistent with it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(#4395): add changeset fragment
pr:0 placeholder is backfilled with the real number once the PR exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(#4395): refresh the variant benchmark baseline
Two entries move.
gsd-debug-session-manager.md 4766/4477 -> 4938/4649 is this change: the
blocking-spawn flag plus its explanatory note, added to BOTH variants to
keep them byte-consistent, so the compact sibling grows by the same amount
and the pair's reduction ratio dips 6.06 -> 5.85. The compact file remains
strictly smaller than its canonical sibling, which is what the variant
guard's size check actually requires.
gsd-code-fixer.md 10741 -> 10740 is NOT from this branch -- the file is
untouched here. It has scored 10740 since f334f277dd (#4324) reworded a
line without refreshing this fixture, so the stale number is sitting on
next. Fixed here rather than deferred; the #4350 branch carries the
identical one-token correction, so whichever lands first makes the other a
no-op.
Refreshed with the variant script's own --write.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(#4395): state the blocking rule agent-wide and correct two overclaims
Review round. Three substantive corrections, one of them to a claim I made
in the previous commit message.
1. The blocking rule is now stated AGENT-WIDE, not per-call. My earlier
claim that "the eight continuation sites say 'see Step 2 format', so
they inherit it" was false: exactly ONE of them names Step 2 (the
compact variant says "Step 2 format" without the "see", which is why
the first draft of the test matched it zero times there). The other
sites inherit only because Step 2 holds the sole Agent() spawn literal
in each file. Both variants now say so outright, and the test pins the
sole-literal invariant in BOTH variants rather than the prose wording
in one.
2. debug.md's two auto-resume buckets now restate run_in_background=false
for the re-spawn. "The same session_params" does not carry it --
session_params is prompt content, not the spawn flag.
3. The test extractor now also matches single-line Agent(...) calls. The
class guard was blind to exactly the shape a future offender is most
likely to take.
Also corrects the diagnosis: remedy 2 is NOT unreachable once the spawn
blocks. This agent's own retained CONTINUE_REQUIRED trigger is "turn
budget exhausted WHILE THE DEBUGGER IS STILL INVESTIGATING", so a harness
turn cutoff mid-wait still double-spawns; debug.md:209 names the same
class from the other side. What this fix removes is the SYSTEMATIC case --
every invocation, because the spawn was always backgrounded. The residual
turn-cutoff window survives, bounded by the existing three-resume cap, and
is stated in the PR rather than denied.
Emitted-Drift-Ack-Growth: gsd-debug-session-manager.md — blocking spawn flag, the note recording why an unstated flag produced colliding debuggers, and the agent-wide restatement the per-site inheritance actually depends on
Emitted-Drift-Ack-Growth: gsd-debug-session-manager.compact.md — same change as its full sibling, kept byte-consistent with it
Emitted-Drift-Ack-Growth: debug.md — both auto-resume buckets restate the spawn flag, since session_params does not carry it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(#4395): backfill the changeset PR number
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/brave-pandas-listen.md
Normal file
5
.changeset/brave-pandas-listen.md
Normal file
@@ -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/<slug>.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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
146
tests/debug-manager-blocking-spawn.test.cjs
Normal file
146
tests/debug-manager-blocking-spawn.test.cjs
Normal file
@@ -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/<slug>.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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user