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

@@ -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."
}
}