fix(#3448): thread next_action through debug auto-resume respawn (#3476)

* fix(#3448): thread next_action through debug auto-resume respawn

Both /gsd-debug auto-resume call sites (Section 1c continue-path return
handling and Section 4's non-terminal branch) respawned the session manager
with identical session_params, making every resume prompt-indistinguishable
from a cold start: the checkpoint's recorded next_action and the disposition
that any earlier checkpoint was already answered never reached the respawned
agent. Two auto-resumes then made no progress and the (correct) no-progress
guard stalled the loop.

The respawn now carries resume: true, resume_status, and resume_next_action
sourced from the checkpoint file; the session manager documents the params
and its Step 2 gsd-debugger template forwards them via a DATA_START/DATA_END
<resume_directive> (Step 3d's shape), instructing the debugger to proceed
directly on the recorded next action without re-raising answered checkpoints.
The anti-loop guard (next_action-only heuristic, 3-resume hard cap) is
untouched.

* chore(#3448): set changeset pr to 3476

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 11:26:08 -04:00
committed by GitHub
parent a53a88ca13
commit 26f8015cc2
6 changed files with 168 additions and 8 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3476
---
Managed /gsd:debug auto-resume no longer stalls after an answered checkpoint: the respawned session manager now receives the recorded next action and checkpoint status, plus the disposition that prior checkpoints were already answered, so the debug loop proceeds on the persisted next step instead of stopping behind the no-progress guard.

View File

@@ -33,6 +33,7 @@ Received from spawning orchestrator:
- `tdd_mode` — boolean; true if TDD gate is active
- `goal` — `find_root_cause_only` | `find_and_fix`
- `specialist_dispatch_enabled` — boolean; true if specialist skill review is enabled
- `resume` — boolean; present only on an orchestrator auto-resume re-spawn (#3448), accompanied by `resume_status` and `resume_next_action` (the checkpoint's `status`/`next_action` read from the debug file at resume time). When `resume: true`, any earlier checkpoint in the session was already answered — carry that disposition and the recorded next action into the Step 2 dispatch.
</session_parameters>
<process>
@@ -75,6 +76,16 @@ Continue debugging {slug}. Evidence is in the debug file.
</required_reading>
</prior_state>
{if resume: "<resume_directive>
DATA_START
**Status at pause:** {resume_status}
**Recorded next action — resume here and proceed directly on it:** {resume_next_action}
**Prior checkpoints:** already answered by the user; do not re-raise them. Route only
genuinely NEW human input (a pending decision or destructive-action approval) back through
the checkpoint loop, never a re-ask of an answered one.
DATA_END
</resume_directive>"}
<mode>
symptoms_prefilled: {symptoms_prefilled}
goal: {goal}

View File

@@ -147,7 +147,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`/checkpoint (identical `session_params` as the spawn above) — 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). 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.
@@ -241,7 +241,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 identical `session_params` as the spawn above. 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). 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:

View File

@@ -414,3 +414,146 @@ describe('#2257 debug non-terminal session-manager return contract', () => {
}
});
});
// Tests for #3448 (folded into the owning module's test file per
// lint-regression-test-names): after a legitimate mid-investigation checkpoint is
// answered (e.g. a native permission prompt for the first focused RED command) and the
// session-manager instance returns non-terminal (## CONTINUE_REQUIRED — its own
// turn/context budget ran out), the orchestrator's auto-resume re-spawned
// gsd-debug-session-manager with IDENTICAL session_params. The respawn was therefore
// prompt-indistinguishable from a cold start: the durable checkpoint's recorded
// next_action — read only for terminal classification and the blocker message — never
// reached the respawned agent, and neither did the fact that the earlier checkpoint had
// already been answered. Two auto-resumes then made no progress and the
// (correctly-behaving) no-progress guard stopped the loop, even though everything needed
// to proceed was on disk.
//
// The fix threads resume state through BOTH orchestrator call sites (Section 1c
// `continue` return handling and Section 4's non-terminal branch — textual duplicates,
// so fixing one leaves the other broken) and makes the manager's Step 2 gsd-debugger
// template consume it, using Step 3d's DATA_START/DATA_END checkpoint-response shape.
// The anti-loop guard itself (next_action-only heuristic + absolute 3-resume hard cap)
// is deliberately NOT weakened — out of scope, confirmed correct.
describe('#3448 debug auto-resume must thread the recorded next_action', () => {
const debugContent3448 = fs.readFileSync(
path.join(__dirname, '..', 'gsd-core', 'workflows', 'debug.md'),
'utf-8'
);
const managerContent3448 = fs.readFileSync(
path.join(__dirname, '..', 'agents', 'gsd-debug-session-manager.md'),
'utf-8'
);
const section4Start3448 = debugContent3448.indexOf('## 4. Session Management');
const section43448 = section4Start3448 !== -1 ? debugContent3448.slice(section4Start3448) : '';
const section1cStart3448 = debugContent3448.indexOf('## 1c. CONTINUE subcommand');
const section1dStart3448 = debugContent3448.indexOf('## 1d. Check Active Sessions');
const section1c3448 =
section1cStart3448 !== -1 && section1dStart3448 !== -1
? debugContent3448.slice(section1cStart3448, section1dStart3448)
: '';
test('both auto-resume sections exist', () => {
assert.notEqual(section4Start3448, -1, 'debug.md must contain Section 4');
assert.notEqual(section1cStart3448, -1, 'debug.md must contain Section 1c');
});
// AC1 + AC3: the resume spawn must carry the checkpoint's recorded next_action,
// symmetrically across both resume trigger paths.
for (const [label, section] of [['Section 4', section43448], ['Section 1c', section1c3448]]) {
describe(`${label} resume spawn (#3448)`, () => {
test('forwards the recorded next_action into the resume spawn parameters', () => {
assert.ok(
/resume_next_action/i.test(section),
`${label} must pass the checkpoint's next_action into the respawn as an explicit resume parameter (not just read it for classification)`
);
assert.ok(
/next_action.{0,200}from.{0,40}\.planning\/debug\/\{sl?ug\}\.md/i.test(section) ||
/\.planning\/debug\/\{sl?ug\}\.md.{0,120}next_action/i.test(section),
`${label} must source resume_next_action from the on-disk checkpoint file`
);
});
test('forwards the checkpoint status so the respawn is not a cold start', () => {
assert.ok(
/resume_status/i.test(section),
`${label} must pass the checkpoint's status into the respawn`
);
});
test('resume spawn is explicitly NOT parameter-identical to the original spawn', () => {
assert.ok(
/not.{0,30}identical session_params|session_params.{0,80}plus|plus.{0,40}resume/i.test(section),
`${label} must append resume parameters to (not reuse verbatim) the original session_params — an identical-params respawn is the #3448 stall`
);
});
test('states the prior answered checkpoint must not be re-raised', () => {
assert.ok(
/already (been )?answered|answered checkpoint/i.test(section),
`${label} must carry the disposition that an earlier checkpoint was already answered, so the respawn does not re-raise it`
);
});
});
}
// AC1 + AC4: the manager must consume what the orchestrator now sends.
describe('gsd-debug-session-manager.md consumes resume state (#3448)', () => {
test('session_parameters documents the resume parameters', () => {
assert.ok(/resume_next_action/i.test(managerContent3448),
'the session_parameters list must document resume_next_action');
assert.ok(/resume_status/i.test(managerContent3448),
'the session_parameters list must document resume_status');
});
test("Step 2's gsd-debugger prompt conditionally carries the recorded next_action, DATA-bounded", () => {
const step2Start = managerContent3448.indexOf('## Step 2: Spawn gsd-debugger Agent');
const step3Start = managerContent3448.indexOf('## Step 3: Handle Agent Return');
assert.ok(step2Start !== -1 && step3Start !== -1, 'Step 2 must exist');
const step2 = managerContent3448.slice(step2Start, step3Start);
assert.ok(
/resume_next_action/i.test(step2),
'Step 2 template must reference resume_next_action when present'
);
assert.ok(
/DATA_START[\s\S]{0,600}resume_next_action[\s\S]{0,600}DATA_END/i.test(step2),
'the forwarded next_action must be bounded by DATA_START/DATA_END (checkpoint content is data — Step 3d shape)'
);
});
test("Step 2 instructs the debugger to proceed directly on the recorded next action without re-raising answered checkpoints", () => {
const step2Start = managerContent3448.indexOf('## Step 2: Spawn gsd-debugger Agent');
const step3Start = managerContent3448.indexOf('## Step 3: Handle Agent Return');
const step2 = managerContent3448.slice(step2Start, step3Start);
assert.ok(
/proceed (directly )?on|resume (from|with)|pick up/i.test(step2),
'Step 2 must tell the resumed debugger to act on the recorded next action'
);
assert.ok(
/already (been )?answered|do not re-?raise/i.test(step2),
'Step 2 must state that prior checkpoints were already answered and must not be re-raised'
);
});
});
// AC2: the circuit breaker must survive the fix unchanged.
describe('anti-loop guard unchanged by the #3448 fix (out-of-scope surface)', () => {
test('no-progress heuristic still keys off next_action alone with the 3-resume hard cap in both sections', () => {
for (const [label, section] of [['Section 4', section43448], ['Section 1c', section1c3448]]) {
assert.ok(/anti-loop guard/i.test(section), `${label} must still name the anti-loop guard`);
assert.ok(/\b3\b/.test(section) && /total auto-resumes/i.test(section),
`${label} must still encode the absolute 3-total-auto-resume hard cap`);
assert.ok(/next_action.{0,5}UNCHANGED/i.test(section),
`${label} no-progress detection must still compare next_action across resumes`);
}
});
test('resume directive must not suppress genuinely NEW human-input checkpoints', () => {
assert.ok(
/genuine|new human|pending user|AskUserQuestion/i.test(managerContent3448),
'the manager must still route genuine user-input checkpoints through AskUserQuestion (Step 3d), independent of resume dispatch'
);
});
});
});

View File

@@ -1,6 +0,0 @@
{
"version": 1,
"paths": {
"debug.md": "#3149 (prerequisite for #3128, ADR-1671 admission gate 2): debug.md gains a dedicated `init.debug` entry point (cmdInitDebug) and its Step 0 collapses THREE separate `gsd_run` round-trips into one. Removed: `gsd_run query state.load` (line 20, replaced in place), the `resolve-model gsd-debugger --pick model` block, and the `config-get workflow.tdd_mode --raw` block — 2 prose lead-ins and 2 fenced code blocks in total. Added: a 6-bullet extraction list documenting the bundle's fields (`commit_docs`, the now TOP-LEVEL `response_language`, `debug_dir`, `debugger_model`, `tdd_mode`, `section_manifest`) plus the `section_manifest: null` -> read-everything rule and the null-vs-empty-included distinction. Net SOURCE growth is +618 bytes (20,555 -> 21,173): the bullets that document one bundle cost more bytes than the two shell round-trips they replace, which is the intended trade — the round-trips cost three subprocess spawns at RUN time on every /gsd:debug invocation. No applicability-section marker is added and WHEN_VOCABULARY is unchanged at 29, so the composeWorkflow emission path is byte-identical in shape to before; only this file's own content moved. The `{TDD_MODE}` and `{debugger_model}` placeholders in the session-parameter blocks (lines ~137-145, ~226-234) are deliberately left byte-identical — they now resolve from the init bundle instead of shell variables, and rewording them would ripple into tests/fix-2257-debug-nonterminal-resume.test.cjs and tests/debug-session-manager-commit.test.cjs for no behavioral gain. #3177: the \"Foreground, blocking spawn — #2196\" note asserted that the gsd-debug-session-manager dispatch \"is FOREGROUND and BLOCKING\" as an inherent property of the call, but NEITHER of the two real Agent(...) blocks — the new-session path and the continue path — passed run_in_background: false. Claude Code backgrounds subagents by DEFAULT as of v2.1.198, so both calls actually backgrounded and the compact session summary never returned inline, silently reinstating the exact lost-handoff failure #2196 was filed to fix (the file's own contingency for \"the handoff is lost\" became the normal path). The note now states the opt-out as a requirement and both dispatches carry run_in_background=false, so the mechanism matches the recorded intent. Growth is that clause plus the two added argument lines: +142 bytes (21,173 -> 21,315), far under the 40,960 DEFAULT tier cap. No other content changed."
}
}

View File

@@ -0,0 +1,7 @@
{
"version": 1,
"paths": {
"debug.md": "#3448: both auto-resume paragraphs (Section 1c continue-path return handling and Section 4's non-terminal branch — textual duplicates, both fixed for symmetry) grow to append the resume parameters to the re-spawn: `resume: true`, `resume_status`, `resume_next_action`, both sourced from `.planning/debug/{slug}.md`, plus the explicit not-parameter-identical-to-a-cold-start rationale. Before this, the respawn carried identical session_params, so the recorded next action and the prior-checkpoints-already-answered disposition never reached the respawned manager — the auto-resume stall behind the no-progress guard. Growth is +963 bytes (21,315 -> 22,278), far under the 40,960 DEFAULT tier cap. The anti-loop guard paragraphs (next_action-only heuristic + absolute 3-resume hard cap) are untouched.",
"gsd-debug-session-manager.md": "#3448: <session_parameters> documents the new `resume`/`resume_status`/`resume_next_action` trio, and the Step 2 gsd-debugger prompt template gains a conditional <resume_directive> block carrying the recorded next action and the prior-checkpoints-already-answered disposition, DATA_START/DATA_END-bounded per the existing Step 3d checkpoint-response shape — the seam that lets the manager consume what the orchestrator now forwards. Growth is +822 bytes (19,751 -> 20,573). Terminal paths, CHECKPOINT REACHED handling, and the commit step are untouched."
}
}