Skip to content

Commit c707aa4

Browse files
committed
Treat parent interrupt as resume, not a successor spawn
A parent-initiated pause unblocks wait with stop_reason interrupted while the worker is often still live. Inviting spawn_agent on that stamp collided with resume_agent. Failed and incomplete-report stay on the one-successor path; operator-cancel still waits.
1 parent b585335 commit c707aa4

14 files changed

Lines changed: 152 additions & 20 deletions

CHANGELOG.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1515

1616
### Changed
1717

18-
- Skywalker may spawn one successor with a changed brief after a failed,
19-
incomplete-report, or interrupted-incomplete fleet worker. Operator-cancelled
20-
salvage still waits for the operator. Identical briefs stay refused. This
21-
reverses the 0.3.15 fail-path idle, not operator-cancel.
18+
- Skywalker may spawn one successor with a changed brief after a failed or
19+
incomplete-report fleet worker. A parent-initiated interrupt
20+
(`stop_reason: interrupted`) is a resumable pause — `resume_agent` or
21+
re-wait, not a successor — unless the session is no longer resumable.
22+
Operator-cancelled salvage still waits for the operator. Identical briefs
23+
stay refused at the prompt / spawn-handoff layer. `wait_agents` JSON
24+
includes `stop_reason` so interrupt vs cancelled vs incomplete-report stay
25+
distinct. This reverses the 0.3.15 fail-path idle, not operator-cancel.
2226

2327
## [0.3.18] - 2026-09-08
2428

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ Two directors, selected by role:
114114

115115
- **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, and multi-turn chat semantics. It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Auto mode is toggled by CLI flags (`--auto` / `--no-auto`); there is currently no in-session key to toggle it (default on; constrained envelope — workspace writes and unconstrained shell auto-allow; installs, recursive rm, force/uncontained worktree changes, sensitive-path and opaque-wrapper shell still ask; contained non-force `git worktree add`/`remove`/`prune` and `list` auto-allow; shell file-mutation denied). It is not a separate edit/plan mode.
116116
- **SubAgentDirector** (delegated work, `src/subagent/index.ts`) — Drives a dispatched worker until a turn arrives with no tool calls, then replies with the final assistant text and ends the run. A tool-less turn **after tools** completes only with the four-heading envelope (Summary, Findings, Blockers, Paths); a missing envelope nudges once (**incomplete-report**) and a second tool-less turn still without the envelope salvages as **incomplete-report-stop**. Explore/read-only workers that used tools then replied with findings remain normal completes; `requireEvidence` (off by default, set per director) additionally requires at least one read before a tool-less spawn-only reply can complete. Reads done through `run_shell` count as evidence too — `src/subagent/shell-evidence.ts` classifies shell reads (`cat`, `grep`, `sed` without `-i`, …) over the same subject expansion the auto-shell policy uses — but there is no corresponding shell-write evidence or file-write requirement: a run that never touches a file still completes normally once it replies with the envelope. There is no turn budget. Operator/parent cancel after any progress returns a **cancelled** salvage report (partial findings + tool activity) instead of a bare cancel string; cancel before progress still surfaces as cancelled-by-operator. There is no repetition/no-progress/never-acted/never-edited hard stop and no fingerprint-based re-dispatch block — a genuinely stuck worker runs until it completes, stalls, hits an opt-in wall-clock deadline, or is cancelled.
117-
`spawn_agent` starts each worker and records it in the caller's fleet mailbox; `wait_agents` collects terminal reports from that mailbox. Wait JSON includes `stop_reason` from the session when present so a salvage that is wait-`done` is not mistaken for a clean complete. Deadline salvage prepends an advisory parent hint suggesting continuation plus a longer deadline if more wall-clock time is warranted. Failed, incomplete-report, and interrupted-incomplete salvage tell the parent to diagnose from the report or error and MAY spawn one successor with a changed brief. Operator-cancelled salvage asks the parent to synthesize Findings and Paths and wait for the operator instead of auto-starting another specialist. Identical re-dispatch of the same brief stays refused at the prompt / spawn-handoff layer; there is no fingerprint-based re-dispatch hard-block. Deadline hints are advisory only — an identical re-dispatch is still admitted at runtime. Parent hints are prepended on salvage reports returned to the parent.
117+
`spawn_agent` starts each worker and records it in the caller's fleet mailbox; `wait_agents` collects terminal reports from that mailbox. Wait JSON includes `stop_reason` from the session when present so a salvage that is wait-`done` is not mistaken for a clean complete, and so parent-initiated interrupt (`interrupted`) is not mistaken for operator-cancel (`cancelled`). Deadline salvage prepends an advisory parent hint suggesting continuation plus a longer deadline if more wall-clock time is warranted. Failed and incomplete-report salvage tell the parent to diagnose from the report or error and MAY spawn one successor with a changed brief. A parent-initiated interrupt is a resumable pause: wait unblocks with `stop_reason: interrupted` (often while the session is still running and has no report); the parent should `resume_agent` or re-wait, and must not spawn a successor against a still-live worker. Successor only if that session is no longer resumable. Operator-cancelled salvage asks the parent to synthesize Findings and Paths and wait for the operator instead of auto-starting another specialist. Identical re-dispatch of the same brief stays refused at the prompt / spawn-handoff layer; there is no fingerprint-based re-dispatch hard-block. Deadline hints are advisory only — an identical re-dispatch is still admitted at runtime. Parent hints are prepended on salvage reports returned to the parent. The runtime does not auto-spawn successors.
118118

119119
#### Model-family policy (`src/agent/model-family-policy.ts`)
120120

src/agent/directors/skywalker/package.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,6 @@ describe("skywalkerPackage", () => {
149149
test("systemPrompt fail-then-successor is distinct from operator-cancel wait", () => {
150150
const p = skywalkerPackage.systemPrompt;
151151
expect(p).toContain("incomplete-report");
152-
expect(p).toContain("interrupted-incomplete");
153152
expect(p).toContain("MAY `spawn_agent` **one** successor");
154153
expect(p).toContain("changed** brief");
155154
expect(p).toContain("wait for the operator");
@@ -158,6 +157,17 @@ describe("skywalkerPackage", () => {
158157
expect(p).toContain("Operator-cancel is not a re-dispatch");
159158
expect(p).not.toContain("Then start the next worker");
160159
expect(p).not.toContain("if the job still needs doing");
160+
expect(p).not.toContain("interrupted-incomplete");
161+
});
162+
163+
test("systemPrompt treats parent interrupt as resume, not successor spawn", () => {
164+
const p = skywalkerPackage.systemPrompt;
165+
expect(p).toContain("Parent-initiated interrupt");
166+
expect(p).toContain("resume_agent");
167+
expect(p).toContain("still-live worker");
168+
expect(p).toContain("no longer resumable");
169+
expect(p).toContain("interrupt_agent");
170+
expect(p).toContain("stop_reason: interrupted");
161171
});
162172

163173
test("systemPrompt simple path skips explorer+critic for tiny work", () => {

src/agent/directors/skywalker/package.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ Do **not** turn a "why is this stalled / why no thinking / spawn looks broken" d
7777
- Answer from mounted tools + known architecture; at most **one** explorer worker if a single unknown path blocks the answer.
7878
- Never spawn parallel "parent UI / child UI / stream events / prompt guardrail / session dig" waves for the same question.
7979
- When workers stall or loop: synthesize what returned, report Blockers, and change approach — do **not** re-fan-out another diagnostic wave on the same topic.
80-
- Failed wait (\`status: failed\` plus \`error\`), salvage \`incomplete-report\`, or interrupted-incomplete (\`stop_reason: interrupted\`, not operator-cancel): diagnose from the wait report or error; MAY \`spawn_agent\` **one** successor with a **changed** brief (new \`success_criteria\` / \`do_not\` / continuation from Findings). Cap is one successor for that stall. Spawn the successor — do not search the repo as a substitute.
80+
- Failed wait (\`status: failed\` plus \`error\`) or salvage \`incomplete-report\`: diagnose from the wait report or error; MAY \`spawn_agent\` **one** successor with a **changed** brief (new \`success_criteria\` / \`do_not\` / continuation from Findings). Cap is one successor for that stall. Spawn the successor — do not search the repo as a substitute.
81+
- Parent-initiated interrupt (\`interrupt_agent\` / \`send_input\` with \`interrupt:true\`): wait unblocks with \`status: interrupted\` and \`stop_reason: interrupted\`. That is a resumable pause, not fail or incomplete-report. The worker is often still running and often has no report. Call \`resume_agent\` (changed follow-up into retained context) or re-wait. Do **not** \`spawn_agent\` a successor against a still-live worker. Successor only if the session is no longer resumable.
8182
- Operator-cancel (\`stop_reason\` cancelled, or Blockers that say wait for the operator): synthesize Findings and Paths, report Blockers, and **wait for the operator**. Do not auto-retry. Do not spawn a successor because the worker was cancelled.
8283
- Do **not** search the repo yourself after a worker stops without finishing.
8384
- Permission asks and long run_shell clocks on worker rows are not a signal to spawn more diggers.
@@ -89,6 +90,7 @@ Runtime requires success_criteria for implement/review and their default directo
8990
Re-dispatch after a blocker is a new handoff (new criteria / new do_not), not a retry of the old one-liner.
9091
Identical re-dispatch of the same brief stays refused.
9192
Operator-cancel is not a re-dispatch — wait for the operator.
93+
Parent-initiated interrupt is not a re-dispatch — resume_agent (or re-wait). Successor only if the session is no longer resumable.
9294
When the operator brief states a function signature or return shape, put that **verbatim** into implement success_criteria (including sync vs Promise if stated or implied by existing code/tests).
9395
9496
# Verify after ship

src/agent/prompts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ export function buildGuidelines(
170170
"Orchestration:",
171171
"- Break multi-step or parallel work into focused worker dispatches with distinct lenses; prefer `spawn_agent` (fire several in one turn when jobs are independent), then reply with who is running and end the turn — workers keep running while you are idle, and `wait_agents` / `list_agents` on a later turn collect their reports without holding this conversation blocked.",
172172
"- Pass the typed spawn contract: `intent`, `success_criteria` (done-when; required for implement/review and their default directors), `do_not` (scope fence), and `report_focus`. Free-form `prompt` without `success_criteria` fail-closes for implement/review and their default directors.",
173-
"- After workers return, classify fail / incomplete-report / interrupted-incomplete vs operator-cancel vs clean complete. Fail-path: diagnose from the report or error and MAY spawn one successor with a changed brief. Operator-cancel: wait for the operator; do not auto-retry. Identical brief: refuse. Merge Summary/Findings into a coherent answer for the operator; do not paste raw fleet-agent dumps.",
173+
"- After workers return, classify fail / incomplete-report vs parent-initiated interrupt vs operator-cancel vs clean complete. Fail-path (`status: failed` or salvage `incomplete-report`): diagnose from the report or error and MAY spawn one successor with a changed brief. Parent-initiated interrupt (`interrupt_agent` / `send_input` with `interrupt:true` unblocks wait with `stop_reason: interrupted`): the worker is often still running and often has no report — `resume_agent` or re-wait; do not `spawn_agent` a successor against a still-live worker. Successor only if that session is no longer resumable. Operator-cancel (`stop_reason` cancelled): wait for the operator; do not auto-retry. Identical brief: refuse. Merge Summary/Findings into a coherent answer for the operator; do not paste raw fleet-agent dumps.",
174174
"- Use manage_tasks for your own coordination checklist; spawning workers is `spawn_agent` / `wait_agents`, not manage_tasks.",
175175
"- If context is compacted automatically, do not stop tasks early due to token fear; persist progress via manage_tasks and worker reports.",
176176
]),

src/prompts.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,16 @@ test("orchestrator guidelines teach the typed task spawn contract", () => {
129129
expect(guidelines).not.toContain("weaker");
130130
});
131131

132-
test("primary chat prompt classifies fail-path successor vs operator-cancel wait", () => {
132+
test("primary chat prompt classifies fail-path successor vs interrupt resume vs operator-cancel wait", () => {
133133
const prompt = buildChatSystemPrompt();
134134
const guidelines = buildGuidelines({ sessionMode: "orchestrator" });
135135
expect(guidelines).toContain("MAY spawn one successor with a changed brief");
136136
expect(guidelines).toContain("wait for the operator");
137137
expect(guidelines).toContain("do not auto-retry");
138138
expect(guidelines).toContain("Identical brief: refuse");
139+
expect(guidelines).toContain("resume_agent");
140+
expect(guidelines).toContain("still-live worker");
141+
expect(guidelines).not.toContain("interrupted-incomplete");
139142
expect(guidelines).not.toContain("start the next worker");
140143
expect(prompt).toContain("MAY `spawn_agent` **one** successor");
141144
expect(prompt).toContain("wait for the operator");

src/subagent/agent-fleet.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,6 +1368,54 @@ describe("list_agents", () => {
13681368
gate.resolve({ report: "done" });
13691369
});
13701370

1371+
test("includes stop_reason after interrupt_agent", async () => {
1372+
const gate = deferred<RunSubAgentResult>();
1373+
const deps = makeDeps(async (params) => {
1374+
params.onAgentReady?.({
1375+
close: async () => {},
1376+
interrupt: () => {},
1377+
followup: async () => "",
1378+
deliver: () => {},
1379+
});
1380+
return gate.promise;
1381+
});
1382+
const spawn = createSpawnAgentTool(deps);
1383+
const interrupt = createInterruptAgentTool({
1384+
sessions: deps.sessions,
1385+
fleetRecords: deps.fleetRecords,
1386+
});
1387+
const list = createListAgentsTool({
1388+
sessions: deps.sessions,
1389+
fleetRecords: deps.fleetRecords,
1390+
});
1391+
const spawned = await callTool(spawn, {
1392+
description: "looping",
1393+
prompt: "do it",
1394+
intent: "explore",
1395+
});
1396+
const id = spawned.agent_id as string;
1397+
if (interrupt.kind !== "full") throw new Error("expected full tool");
1398+
await interrupt.handler(
1399+
{ id: "int-list-1", name: "interrupt_agent", arguments: { target: id } },
1400+
new AbortController().signal,
1401+
);
1402+
if (list.kind !== "full") throw new Error("expected full tool");
1403+
const raw = await list.handler(
1404+
{ id: "list-stop-1", name: "list_agents", arguments: {} },
1405+
new AbortController().signal,
1406+
);
1407+
const content = typeof raw.content === "string" ? raw.content : JSON.stringify(raw.content);
1408+
const parsed = JSON.parse(content) as {
1409+
agents: { agent_id: string; status: string; stop_reason?: string }[];
1410+
};
1411+
expect(parsed.agents).toHaveLength(1);
1412+
expect(parsed.agents[0]!.agent_id).toBe(id);
1413+
expect(parsed.agents[0]!.status).toBe("interrupted");
1414+
expect(parsed.agents[0]!.stop_reason).toBe("interrupted");
1415+
expect(list.definition.description).toContain("stop_reason");
1416+
gate.resolve({ report: "done" });
1417+
});
1418+
13711419
test("projects question and question_id while awaiting_director", async () => {
13721420
const gate = deferred<RunSubAgentResult>();
13731421
const deps = makeDeps(async (params) => {

src/subagent/agent-fleet.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -469,8 +469,9 @@ export const waitAgentsToolDefinition: ToolDefinition = {
469469
`wait_agents pair started — never every running session in the shared store. Default timeout ${DEFAULT_WAIT_TIMEOUT_MS}ms, ` +
470470
`clamped to a ${MAX_WAIT_TIMEOUT_MS}ms max. A timeout or parent-turn abort is NOT an error and never touches ` +
471471
`the workers — they keep running and remain waitable. Live wait status includes "queued" (waiting for a burst ` +
472-
`slot), "running", and "awaiting_director". interrupt_agent and close_agent unblock this wait immediately with ` +
473-
`status "interrupted". Terminal JSON includes stop_reason when the session recorded one ` +
472+
`slot), "running", and "awaiting_director". interrupt_agent unblocks this wait immediately with ` +
473+
`status "interrupted" (a parent-initiated pause — resume_agent, do not spawn_agent a successor against the still-live worker). ` +
474+
`close_agent also unblocks with status "interrupted" but is permanent. Terminal JSON includes stop_reason when the session recorded one ` +
474475
`(interrupted, cancelled, incomplete-report, and similar). awaiting_director is not terminal: re-wait while still pending re-delivers the same question. ` +
475476
`Answer with send_input (soft). Do not call this in a tight zero-progress loop: a timeout means the targets are still ` +
476477
`queued, running, or awaiting a director answer, not "try again right away" — do other work, reply to the operator, or change the brief. Calling again with the ` +
@@ -1424,7 +1425,7 @@ export const listAgentsToolDefinition: ToolDefinition = {
14241425
description:
14251426
"List the workers this session started with spawn_agent — the same fleet wait_agents " +
14261427
"collects. Does not list siblings or another orchestrator's workers. Each entry is id, " +
1427-
"director, description, wait status, lifecycle, and whether wait_agents already collected it. " +
1428+
"director, description, wait status, lifecycle, stop_reason when recorded, and whether wait_agents already collected it. " +
14281429
"When status is awaiting_director, the entry also includes question and question_id.",
14291430
inputSchema: {
14301431
type: "object",
@@ -1439,6 +1440,7 @@ export function createListAgentsTool(deps: WaitAgentsDeps): AgentTool {
14391440
const agents = deps.fleetRecords.ids().map((id) => {
14401441
const record = deps.fleetRecords.peek(id);
14411442
const session = deps.sessions.get(id);
1443+
const stopReason = record?.stopReason ?? session?.stopReason;
14421444
return {
14431445
agent_id: id,
14441446
status: record?.status ?? "unknown",
@@ -1450,6 +1452,7 @@ export function createListAgentsTool(deps: WaitAgentsDeps): AgentTool {
14501452
lifecycle: session.lifecycleStatus,
14511453
}
14521454
: {}),
1455+
...(stopReason !== undefined ? { stop_reason: stopReason } : {}),
14531456
...(record?.status === "awaiting_director" && record.question !== undefined
14541457
? { question: record.question }
14551458
: {}),

src/subagent/followup-live-agent.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,9 @@ describe("interrupt_agent / resume_agent reuse the same live agent", () => {
221221
expect(outcome.stopReason).toBe("interrupted");
222222
expect(outcome.stopReason).not.toBe("cancelled");
223223
expect(outcome.interrupted).toBe(true);
224-
expect(outcome.report).toContain("MAY spawn one successor");
225-
expect(outcome.report).toContain("changed brief");
224+
expect(outcome.report).toContain("resume_agent");
225+
expect(outcome.report).toContain("still-live");
226+
expect(outcome.report).not.toContain("MAY spawn one successor");
226227
expect(outcome.report).not.toContain("wait for the operator");
227228
});
228229

src/subagent/index.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -406,11 +406,13 @@ describe("sub-agent stop helpers", () => {
406406

407407
const interrupted = forcedStopReport("interrupted", "Partial work");
408408
const interruptedParsed = parseSubAgentReport(interrupted);
409-
expect(interruptedParsed.blockers).toContain("one successor");
410-
expect(interruptedParsed.blockers).toContain("changed brief");
409+
expect(interruptedParsed.blockers).toContain("resume_agent");
410+
expect(interruptedParsed.blockers).toContain("still-live");
411+
expect(interruptedParsed.blockers).not.toContain("MAY spawn one successor");
411412
expect(interruptedParsed.blockers).not.toContain("wait for the operator");
412413
const interruptedWithHint = appendSubAgentParentHints(interrupted, "interrupted");
413-
expect(interruptedWithHint).toContain("MAY spawn one successor");
414+
expect(interruptedWithHint).toContain("resume_agent");
415+
expect(interruptedWithHint).not.toContain("MAY spawn one successor");
414416
expect(interruptedWithHint).not.toContain("wait for the operator instead of auto-starting");
415417

416418
const stalled = forcedStopReport("stalled", "parked");

0 commit comments

Comments
 (0)