diff --git a/.changeset/jolly-yaks-click.md b/.changeset/jolly-yaks-click.md new file mode 100644 index 000000000..5e28ade3d --- /dev/null +++ b/.changeset/jolly-yaks-click.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4006 +--- +**Interrupted executors can be resumed again** — execute-plan deleted `current-agent-id.txt` before the check that read it, so the interrupted-agent detection and its Task `resume` prompt were unreachable after a kill; the id is now captured before the stale marker is cleared. (#3795) diff --git a/gsd-core/workflows/execute-plan.md b/gsd-core/workflows/execute-plan.md index da0ad0069..a0d6c6a4b 100644 --- a/gsd-core/workflows/execute-plan.md +++ b/gsd-core/workflows/execute-plan.md @@ -155,11 +155,14 @@ Fresh context per subagent preserves peak quality. Main context stays lean. if [ ! -f .planning/agent-history.json ]; then echo '{"version":"1.0","max_entries":50,"entries":[]}' > .planning/agent-history.json fi -rm -f .planning/current-agent-id.txt +# #3795: read a SURVIVING id before clearing it — the clear used to run +# first, so the check below was always false and the resume prompt at this +# step's tail was never offered. if [ -f .planning/current-agent-id.txt ]; then INTERRUPTED_ID=$(cat .planning/current-agent-id.txt) echo "Found interrupted agent: $INTERRUPTED_ID" fi +rm -f .planning/current-agent-id.txt ``` If interrupted: ask user to resume (Task `resume` parameter) or start fresh. diff --git a/tests/execute-plan-interrupted-agent-guard.test.cjs b/tests/execute-plan-interrupted-agent-guard.test.cjs new file mode 100644 index 000000000..860fa5e45 --- /dev/null +++ b/tests/execute-plan-interrupted-agent-guard.test.cjs @@ -0,0 +1,50 @@ +'use strict'; + +// ───────────────────────────────────────────────────────────────────────────── +// #3795 — execute-plan's interrupted-agent detection must be REACHABLE. +// +// The init_agent_tracking step cleared `.planning/current-agent-id.txt` +// BEFORE the existence check that read it, so the interrupted-agent branch +// and the Task `resume` prompt it exists to offer could never execute: a +// kill -9 mid-executor left the file, and the very next run deleted it +// before looking. The read must precede the clear. +// ───────────────────────────────────────────────────────────────────────────── + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const EXECUTE_PLAN_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-plan.md'); + +// execute-plan.md is shipped workflow text — the bytes ARE what the runtime +// loads; a structural scan over the step's own fenced block tests the +// deployed contract (same shape as tests/config-get-raw-guard.test.cjs). +function initAgentTrackingBlock() { + const md = fs.readFileSync(EXECUTE_PLAN_MD, 'utf-8'); + const step = md.indexOf(''); + assert.ok(step > 0, 'execute-plan.md must contain the init_agent_tracking step'); + const end = md.indexOf('', step); + assert.ok(end > step, 'init_agent_tracking step must close'); + return md.slice(step, end); +} + +test('#3795: the interrupted-agent read must precede the stale-id clear', () => { + const block = initAgentTrackingBlock(); + const readCheck = block.indexOf('[ -f .planning/current-agent-id.txt ]'); + const clear = block.indexOf('rm -f .planning/current-agent-id.txt'); + assert.ok(readCheck > 0, 'the step must probe for a surviving current-agent-id.txt'); + assert.ok(clear > 0, 'the step must clear the stale id for the fresh run'); + assert.ok( + readCheck < clear, + '#3795: the existence check must run BEFORE the rm — delete-first made the interrupted-agent branch and its Task resume prompt unreachable', + ); + assert.ok( + /INTERRUPTED_ID=\$\(/.test(block), + 'the surviving id must be captured while the file still exists', + ); + assert.ok( + /resume/.test(block), + 'the resume-or-fresh prompt the read enables must stay present', + ); +}); diff --git a/tests/no-bare-gsd-tools-command-position.test.cjs b/tests/no-bare-gsd-tools-command-position.test.cjs index 1826a0405..0816c4035 100644 --- a/tests/no-bare-gsd-tools-command-position.test.cjs +++ b/tests/no-bare-gsd-tools-command-position.test.cjs @@ -107,7 +107,7 @@ const PROSE_ALLOWLIST = [ { file: 'agents/gsd-phase-researcher.md', line: 33, reason: 'package-legitimacy provenance rule names the command as the source of an OK verdict; descriptive' }, { file: 'agents/gsd-roadmapper.md', line: 642, reason: 'parenthetical "e.g." naming SDK queries a user *could* run; not an agent instruction' }, { file: 'agents/gsd-intel-updater.md', line: 40, reason: 'cross-platform note names the `gsd-tools intel ` CLI surface descriptively ("CLI invocations go through..."); not an agent instruction' }, - { file: 'gsd-core/workflows/execute-plan.md', line: 416, reason: 'describes the downstream SDK validation step (`validated downstream by ...`); names the mechanism, does not instruct the agent to type it' }, + { file: 'gsd-core/workflows/execute-plan.md', line: 419, reason: 'describes the downstream SDK validation step (`validated downstream by ...`); names the mechanism, does not instruct the agent to type it' }, ]; // Resolver-snippet definition lines / probes that must never be flagged. A line