From df40d09768d17645ed66f727e83e161a68943fc2 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 8 Sep 2026 22:12:53 -0700 Subject: [PATCH 1/7] Retry a failed fleet worker with a changed brief After 0.3.15 the parent idled on fail and incomplete-report as if the operator had cancelled. Fail-path salvage now invites one successor with a changed brief; operator-cancel still waits; identical briefs stay refused at the prompt layer. --- CHANGELOG.md | 7 ++ docs/ARCHITECTURE.md | 2 +- src/agent/directors/skywalker/package.test.ts | 14 +++ src/agent/directors/skywalker/package.ts | 6 +- src/agent/prompts.ts | 2 +- src/prompts.test.ts | 10 +- src/subagent/agent-fleet.test.ts | 93 ++++++++++++++++++- src/subagent/agent-fleet.ts | 11 ++- src/subagent/followup-live-agent.test.ts | 57 ++++++++++++ src/subagent/index.test.ts | 27 ++++++ src/subagent/nudge-director.test.ts | 3 + src/subagent/run.ts | 17 ++-- src/subagent/session-store.ts | 6 +- src/subagent/spawn-agent-worktree.test.ts | 6 +- src/subagent/stop-policy.ts | 40 +++++--- 15 files changed, 269 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91f6d29a6..4e18f239d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,13 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename workflow rails. Frozen-prefix matching ignores model-emitted copies of the marker. +### Changed + +- Skywalker may spawn one successor with a changed brief after a failed, + incomplete-report, or interrupted-incomplete fleet worker. Operator-cancelled + salvage still waits for the operator. Identical briefs stay refused. This + reverses the 0.3.15 fail-path idle, not operator-cancel. + ## [0.3.18] - 2026-09-08 ### Added diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8ef72ff31..ee90a69f3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -114,7 +114,7 @@ Two directors, selected by role: - **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. - **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. - `spawn_agent` starts each worker and records it in the caller's fleet mailbox; `wait_agents` collects terminal reports from that mailbox. Deadline salvage prepends an advisory parent hint suggesting continuation plus a longer deadline if more wall-clock time is warranted. Cancelled salvage asks the parent to synthesize Findings and Paths and wait for the operator instead of auto-starting another specialist. Deadline hints are advisory only — an identical re-dispatch is still admitted. + `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. #### Model-family policy (`src/agent/model-family-policy.ts`) diff --git a/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index 03ed6d9a0..ec59dc8f2 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -146,6 +146,20 @@ describe("skywalkerPackage", () => { expect(p).not.toContain("if the job still needs doing"); }); + test("systemPrompt fail-then-successor is distinct from operator-cancel wait", () => { + const p = skywalkerPackage.systemPrompt; + expect(p).toContain("incomplete-report"); + expect(p).toContain("interrupted-incomplete"); + expect(p).toContain("MAY `spawn_agent` **one** successor"); + expect(p).toContain("changed** brief"); + expect(p).toContain("wait for the operator"); + expect(p).toContain("Do not auto-retry"); + expect(p).toContain("Identical re-dispatch of the same brief stays refused"); + expect(p).toContain("Operator-cancel is not a re-dispatch"); + expect(p).not.toContain("Then start the next worker"); + expect(p).not.toContain("if the job still needs doing"); + }); + test("systemPrompt simple path skips explorer+critic for tiny work", () => { const p = skywalkerPackage.systemPrompt; expect(p).toContain("DIY on the parent"); diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 60284e49e..0e6a5f5d6 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -76,7 +76,9 @@ Do **not** turn a "why is this stalled / why no thinking / spawn looks broken" d - Classify digs, screenshots of worker rows, and "why/how does X work" as COMMUNICATION first. - Answer from mounted tools + known architecture; at most **one** explorer worker if a single unknown path blocks the answer. - Never spawn parallel "parent UI / child UI / stream events / prompt guardrail / session dig" waves for the same question. -- When workers stall, loop, or come back unfinished: synthesize what returned, report Blockers, and change approach — do **not** re-fan-out another diagnostic wave on the same topic. +- 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. +- 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. +- 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. - Do **not** search the repo yourself after a worker stops without finishing. - Permission asks and long run_shell clocks on worker rows are not a signal to spawn more diggers. @@ -85,6 +87,8 @@ Do **not** turn a "why is this stalled / why no thinking / spawn looks broken" d Child starts blank. Parent writes a complete packet: Goal, contracts copied verbatim, Scope/do_not, Done-when/success_criteria, What to report. Runtime requires success_criteria for implement/review and their default directors; recommended otherwise. Re-dispatch after a blocker is a new handoff (new criteria / new do_not), not a retry of the old one-liner. +Identical re-dispatch of the same brief stays refused. +Operator-cancel is not a re-dispatch — wait for the operator. 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). # Verify after ship diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 0f947f752..eb280820b 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -170,7 +170,7 @@ export function buildGuidelines( "Orchestration:", "- 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.", "- 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.", - "- After workers return, merge their Summary/Findings into a coherent answer for the operator; do not paste raw fleet-agent dumps.", + "- 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.", "- Use manage_tasks for your own coordination checklist; spawning workers is `spawn_agent` / `wait_agents`, not manage_tasks.", "- If context is compacted automatically, do not stop tasks early due to token fear; persist progress via manage_tasks and worker reports.", ]), diff --git a/src/prompts.test.ts b/src/prompts.test.ts index 1dad6d97f..dab6c0d13 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -129,11 +129,17 @@ test("orchestrator guidelines teach the typed task spawn contract", () => { expect(guidelines).not.toContain("weaker"); }); -test("primary chat prompt does not invite auto-starting the next worker after unfinished specialists", () => { +test("primary chat prompt classifies fail-path successor vs operator-cancel wait", () => { const prompt = buildChatSystemPrompt(); const guidelines = buildGuidelines({ sessionMode: "orchestrator" }); - expect(guidelines).not.toContain("change the brief rather than repeating it"); + expect(guidelines).toContain("MAY spawn one successor with a changed brief"); + expect(guidelines).toContain("wait for the operator"); + expect(guidelines).toContain("do not auto-retry"); + expect(guidelines).toContain("Identical brief: refuse"); expect(guidelines).not.toContain("start the next worker"); + expect(prompt).toContain("MAY `spawn_agent` **one** successor"); + expect(prompt).toContain("wait for the operator"); + expect(prompt).toContain("Identical re-dispatch of the same brief stays refused"); expect(prompt).not.toContain("Then start the next worker"); expect(prompt).not.toContain("if the job still needs doing"); }); diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index 01ae267d4..9997dc572 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -391,12 +391,14 @@ describe("spawn_agent + wait_agents", () => { agent_id: string; status: string; report?: string; + stop_reason?: string; }[]; expect(results).toHaveLength(1); expect(results[0]!.status).toBe("interrupted"); expect(results[0]!.report).toContain("## Summary"); expect(results[0]!.report).toContain("## Findings"); expect(results[0]!.report).toContain("gate.ts"); + expect(results[0]!.stop_reason).toBe("cancelled"); // Strip stays cancelled — salvage is for wait_agents, not a resurrection. expect(deps.sessions.get(id)?.status).toBe("cancelled"); expect(deps.sessions.get(id)?.lifecycle.state).toBe("cancelled"); @@ -433,6 +435,87 @@ describe("spawn_agent + wait_agents", () => { expect(results[0]!.error).toBeUndefined(); expect(deps.sessions.get(id)?.status).toBe("cancelled"); }); + + test("incomplete-report complete is wait done with stop_reason", async () => { + const deps = makeDeps(async () => ({ + report: forcedStopReport("incomplete-report", "Still narrating"), + stopReason: "incomplete-report", + })); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); + + const spawned = await callTool(spawn, { + description: "incomplete salvage", + prompt: "probe", + intent: "explore", + }); + const id = spawned.agent_id as string; + const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 }); + const results = waited.results as { + status: string; + report?: string; + error?: string; + stop_reason?: string; + }[]; + expect(results[0]!.status).toBe("done"); + expect(results[0]!.stop_reason).toBe("incomplete-report"); + expect(results[0]!.report).toContain("narrated instead of writing a report envelope"); + expect(results[0]!.error).toBeUndefined(); + }); + + test("failed spawn_agent wait_agents returns error not report", async () => { + const deps = makeDeps(async () => { + throw new Error("provider blew up"); + }); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); + + const spawned = await callTool(spawn, { + description: "failed run", + prompt: "probe", + intent: "explore", + }); + const id = spawned.agent_id as string; + const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 }); + const results = waited.results as { + status: string; + report?: string; + error?: string; + stop_reason?: string; + }[]; + expect(results[0]!.status).toBe("failed"); + expect(results[0]!.error).toContain("provider blew up"); + expect(results[0]!.report).toBeUndefined(); + expect(results[0]!.stop_reason).toBeUndefined(); + }); + + test("interrupt salvage wait_agents includes stop_reason interrupted", async () => { + const deps = makeDeps(async () => ({ + report: forcedStopReport("interrupted", "partial"), + stopReason: "interrupted", + interrupted: true, + })); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords }); + + const spawned = await callTool(spawn, { + description: "interrupt salvage", + prompt: "probe", + intent: "explore", + }); + const id = spawned.agent_id as string; + const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 }); + const results = waited.results as { + status: string; + report?: string; + error?: string; + stop_reason?: string; + }[]; + expect(results[0]!.status).toBe("interrupted"); + expect(results[0]!.stop_reason).toBe("interrupted"); + expect(results[0]!.report).toContain("interrupted before finishing"); + expect(results[0]!.error).toBeUndefined(); + }); }); describe("spawn_agent same-cwd concurrency", () => { @@ -2079,8 +2162,14 @@ describe("admission queue", () => { const elapsed = Date.now() - startedAt; expect(elapsed).toBeLessThan(200); expect(waited.timed_out).toBe(false); - const results = waited.results as { agent_id: string; status: string }[]; - expect(results).toEqual([{ agent_id: queuedId, status: "interrupted" }]); + const results = waited.results as { + agent_id: string; + status: string; + stop_reason?: string; + }[]; + expect(results).toEqual([ + { agent_id: queuedId, status: "interrupted", stop_reason: "cancelled" }, + ]); expect(started).toBe(1); gate.resolve({ report: "ok" }); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index d2f8b821c..85c097e9b 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -102,6 +102,7 @@ interface FleetRecord { status: WaitJSONStatus; report?: string; error?: string; + stopReason?: string; providerFailure?: true; /** Set once a wait_agents caller has been handed this result. */ collected?: boolean; @@ -344,6 +345,7 @@ class FleetMailbox { ...(overlay.hint !== undefined ? { hint: overlay.hint } : {}), ...(payload?.report !== undefined ? { report: payload.report } : {}), ...(payload?.error !== undefined && status === "failed" ? { error: payload.error } : {}), + ...(payload?.stopReason !== undefined ? { stopReason: payload.stopReason } : {}), ...(overlay.providerFailure === true ? { providerFailure: true } : {}), ...(ask !== undefined ? { question: ask.question, questionId: ask.questionId } : {}), ...(status === "awaiting_director" && session !== undefined @@ -1197,13 +1199,17 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { const followupLive = now?.lifecycle.state === "running" && overlay?.status === "interrupted"; if (!followupLive) { - deps.sessions.attachReport(session.id, result.report); + deps.sessions.attachReport(session.id, result.report, { + ...(result.stopReason !== undefined ? { stopReason: result.stopReason } : {}), + }); } return; } const alreadyCancelled = deps.sessions.get(session.id)?.status === "cancelled"; if (alreadyCancelled) { - deps.sessions.attachReport(session.id, result.report); + deps.sessions.attachReport(session.id, result.report, { + ...(result.stopReason !== undefined ? { stopReason: result.stopReason } : {}), + }); return; } const agentRetained = result.agentRetained === true; @@ -1397,6 +1403,7 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool { ? { report: taken.report } : {}), ...(taken.error !== undefined ? { error: taken.error } : {}), + ...(taken.stopReason !== undefined ? { stop_reason: taken.stopReason } : {}), ...(taken.providerFailure === true ? { provider_failure: true } : {}), ...(taken.hint !== undefined ? { hint: taken.hint } : {}), }; diff --git a/src/subagent/followup-live-agent.test.ts b/src/subagent/followup-live-agent.test.ts index 3417cc9b5..a0147766b 100644 --- a/src/subagent/followup-live-agent.test.ts +++ b/src/subagent/followup-live-agent.test.ts @@ -169,6 +169,63 @@ describe("interrupt_agent / resume_agent reuse the same live agent", () => { expect(outcome.reply).toBe("reply #2"); }); + test("interrupt_agent salvage is stopReason interrupted, not cancelled", async () => { + const cwd = await tmpCwd(); + let capturedAgent: ReturnType | undefined; + + const outcome = await withMockedModuleDuring( + import.meta.resolve("../agent/live-tool-dispatch.js"), + (real: typeof import("../agent/live-tool-dispatch.js")) => ({ + ...real, + createAgentWithLiveToolDispatch: async () => { + const stub = createStubAgent({ hangFromSend: 1 }); + capturedAgent = stub; + return stub as unknown as Awaited< + ReturnType + >; + }, + }), + async () => { + const { runSubAgent } = await import("./run.js"); + + let handles: + | { + close: (ms?: number) => Promise; + interrupt: () => void; + followup: (message: string) => Promise; + } + | undefined; + + const runPromise = runSubAgent({ + cwd, + workdirBase: join(cwd, ".ctx"), + permissionGate: testPermissionGate, + provider: { providerName: "test", baseURL: "http://localhost", model: "test-model" }, + description: "interrupt salvage stopReason probe", + prompt: "hang until interrupted", + persist: true, + onAgentReady: (h) => { + handles = h; + }, + }); + for (let i = 0; i < 500 && (capturedAgent?.sendLog.length ?? 0) < 1; i++) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + if (handles === undefined) throw new Error("onAgentReady never fired"); + handles.interrupt(); + return runPromise; + }, + ); + + expect(capturedAgent?.abortedSends[0]).toBe(true); + expect(outcome.stopReason).toBe("interrupted"); + expect(outcome.stopReason).not.toBe("cancelled"); + expect(outcome.interrupted).toBe(true); + expect(outcome.report).toContain("MAY spawn one successor"); + expect(outcome.report).toContain("changed brief"); + expect(outcome.report).not.toContain("wait for the operator"); + }); + test("interrupt_agent aborts the resumed followup agent.send", async () => { const cwd = await tmpCwd(); let constructions = 0; diff --git a/src/subagent/index.test.ts b/src/subagent/index.test.ts index d008d2190..c5fc0c1d2 100644 --- a/src/subagent/index.test.ts +++ b/src/subagent/index.test.ts @@ -355,6 +355,7 @@ describe("sub-agent stop helpers", () => { expect(cancelledParsed.findings).toContain("Partial findings"); expect(cancelledParsed.blockers).toContain("wait for the operator"); expect(cancelledParsed.blockers).not.toContain("parent may re-dispatch"); + expect(cancelledParsed.blockers).not.toContain("successor"); // Nested agent envelope in partial text must not clobber cancel Summary. const cancelledNested = [ @@ -392,6 +393,32 @@ describe("sub-agent stop helpers", () => { expect(cancelledWithHint).toContain("Findings and Paths"); expect(cancelledWithHint).toContain("wait for the operator"); expect(cancelledWithHint).not.toContain("re-dispatch only if"); + expect(cancelledWithHint).not.toContain("MAY spawn one successor"); + + const incomplete = forcedStopReport("incomplete-report", "Still narrating"); + const incompleteParsed = parseSubAgentReport(incomplete); + expect(incompleteParsed.blockers).toContain("one successor"); + expect(incompleteParsed.blockers).toContain("changed brief"); + expect(incompleteParsed.blockers).not.toContain("wait for the operator"); + const incompleteWithHint = appendSubAgentParentHints(incomplete, "incomplete-report"); + expect(incompleteWithHint).toContain("MAY spawn one successor"); + expect(incompleteWithHint).not.toContain("wait for the operator instead of auto-starting"); + + const interrupted = forcedStopReport("interrupted", "Partial work"); + const interruptedParsed = parseSubAgentReport(interrupted); + expect(interruptedParsed.blockers).toContain("one successor"); + expect(interruptedParsed.blockers).toContain("changed brief"); + expect(interruptedParsed.blockers).not.toContain("wait for the operator"); + const interruptedWithHint = appendSubAgentParentHints(interrupted, "interrupted"); + expect(interruptedWithHint).toContain("MAY spawn one successor"); + expect(interruptedWithHint).not.toContain("wait for the operator instead of auto-starting"); + + const stalled = forcedStopReport("stalled", "parked"); + const stalledParsed = parseSubAgentReport(stalled); + expect(stalledParsed.blockers).toContain("finish this lane"); + expect(stalledParsed.blockers).toContain("Do not start a diagnostic wave"); + expect(stalledParsed.blockers).not.toContain("MAY spawn one successor"); + expect(appendSubAgentParentHints(stalled, "stalled")).not.toContain("MAY spawn one successor"); // Paths section carries thrash salvage; empty prose with paths still informs Findings. const withPaths = forcedStopReport("cancelled", "", { diff --git a/src/subagent/nudge-director.test.ts b/src/subagent/nudge-director.test.ts index f7963d8ea..6451144ee 100644 --- a/src/subagent/nudge-director.test.ts +++ b/src/subagent/nudge-director.test.ts @@ -397,6 +397,9 @@ describe("SubAgentDirector incomplete-report wiring", () => { if (reply === undefined || reply.type !== "reply") throw new Error("expected reply action"); expect(reply.content).toContain("narrated instead of writing a report envelope"); expect(reply.content).toContain("Still narrating, no envelope."); + expect(reply.content).toContain("one successor"); + expect(reply.content).toContain("changed brief"); + expect(reply.content).not.toContain("wait for the operator"); expect(reply.content).toContain("## Paths"); expect(reply.content).toContain("read-1.ts"); }); diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 5abea39c8..e71e6a686 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -91,6 +91,7 @@ import { subAgentToolName, } from "./report.js"; import { + appendSubAgentParentHints, forcedStopReport, partialTextFromEvent, preferCompletedSubAgentReply, @@ -1182,6 +1183,8 @@ async function runSubAgentInner( if (runController.signal.aborted) throw abortError(runController.signal); }; const thisTurnInterrupt = interruptController; + const parentFacing = (body: string, reason?: ForcedStopReason): string => + appendActivitySummary(appendSubAgentParentHints(body, reason), toolNamesUsed); try { ensureNotAborted(); // Combine the run's own controller with the dedicated interrupt @@ -1221,7 +1224,7 @@ async function runSubAgentInner( // finally block still tears down for those. turnSucceeded = true; return withTelemetry({ - report: appendActivitySummary(report, toolNamesUsed), + report: parentFacing(report, directorForcedStopReason), ...(directorForcedStopReason !== undefined ? { stopReason: directorForcedStopReason } : {}), // Only this path skips teardown below when persist is set — tell // the caller so a salvage below is never mistaken for a still-live, @@ -1237,14 +1240,14 @@ async function runSubAgentInner( const abortedCycleText = await cycleRecorder.dispose("cancelled", { drain: streamPromise }); const tail = salvageFindingsText(accumulatedProse, lastPartialText, abortedCycleText); return withTelemetry({ - report: appendActivitySummary( - forcedStopReport("cancelled", tail, { + report: parentFacing( + forcedStopReport("interrupted", tail, { detail: "interrupted by interrupt_agent", paths: salvagePathsFromThrash(thrashState), }), - toolNamesUsed, + "interrupted", ), - stopReason: "cancelled", + stopReason: "interrupted", interrupted: true, }); } @@ -1285,12 +1288,12 @@ async function runSubAgentInner( ...(detail !== undefined ? { detail } : {}), }); return withTelemetry({ - report: appendActivitySummary( + report: parentFacing( forcedStopReport(reason, tail, { ...(detail !== undefined ? { detail } : {}), paths: salvagePathsFromThrash(thrashState), }), - toolNamesUsed, + reason, ), stopReason: reason, }); diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index e97e2e510..ca3cf8e72 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -277,7 +277,7 @@ export interface SubAgentSessionStore { * flip to interrupted rather than completed. Clears the in-flight-run bit * and notifies waiters. */ - attachReport(id: string, report: string): void; + attachReport(id: string, report: string, opts?: { stopReason?: ForcedStopReason }): void; /** True while a run or followup has not settled. */ isRunInFlight(id: string): boolean; /** @@ -1499,7 +1499,7 @@ export function createSubAgentSessionStore( } else pinCounts.set(id, next); }, - attachReport(id: string, report: string): void { + attachReport(id: string, report: string, opts?: { stopReason?: ForcedStopReason }): void { mutate(id, (session) => { const state = session.lifecycle.state; if (state === "completed" || state === "failed") { @@ -1510,6 +1510,7 @@ export function createSubAgentSessionStore( session.lifecycle = { state: "interrupted", report }; session.report = report; session.finishedAt = session.finishedAt ?? now(); + if (opts?.stopReason !== undefined) session.stopReason = opts.stopReason; pushEntry(session, { kind: "report", content: capText(report, maxEntryChars) }); } else if ( (state === "cancelled" || state === "interrupted" || state === "shutdown") && @@ -1517,6 +1518,7 @@ export function createSubAgentSessionStore( ) { session.report = report; session.lifecycle = { ...session.lifecycle, report }; + if (opts?.stopReason !== undefined) session.stopReason = opts.stopReason; pushEntry(session, { kind: "report", content: capText(report, maxEntryChars) }); } runInFlight.delete(id); diff --git a/src/subagent/spawn-agent-worktree.test.ts b/src/subagent/spawn-agent-worktree.test.ts index 3488085ae..74437a4e7 100644 --- a/src/subagent/spawn-agent-worktree.test.ts +++ b/src/subagent/spawn-agent-worktree.test.ts @@ -332,7 +332,7 @@ describe("spawn_agent worktree isolation", () => { error_count: 0, duration_ms: 1, model: "test-model", - terminal_reason: "cancelled" as const, + terminal_reason: "interrupted" as const, }); settlementCount += 1; settlementWasFrozen = Object.isFrozen(summary); @@ -359,7 +359,7 @@ describe("spawn_agent worktree isolation", () => { expect(sessions.interruptOne(agentId).ok).toBe(true); settle.resolve({ report: "## Summary\nStopped.\n## Findings\npartial\n## Blockers\ninterrupted\n## Paths\n", - stopReason: "cancelled", + stopReason: "interrupted", interrupted: true, }); await waitFor(() => events.some((event) => event.event === "subagent_end")); @@ -371,7 +371,7 @@ describe("spawn_agent worktree isolation", () => { expect(ends).toHaveLength(1); expect(ends[0]?.properties).toMatchObject({ status: "interrupted", - stop_reason: "cancelled", + stop_reason: "interrupted", }); expect(workerCwd).toBeDefined(); expect(await pathExists(workerCwd!)).toBe(true); diff --git a/src/subagent/stop-policy.ts b/src/subagent/stop-policy.ts index a7690501c..e47e930a2 100644 --- a/src/subagent/stop-policy.ts +++ b/src/subagent/stop-policy.ts @@ -181,7 +181,8 @@ export function partialTextFromEvent(event: ReactorEmittedEvent): string | null return text.length > 0 ? text : null; } -export type ForcedStopReason = "cancelled" | "deadline" | "stalled" | "incomplete-report"; +export type ForcedStopReason = + "cancelled" | "deadline" | "stalled" | "incomplete-report" | "interrupted"; /** Optional detail / Paths payload for a forced-stop salvage envelope. */ export interface ForcedStopReportOptions { @@ -201,8 +202,26 @@ const FORCED_STOP_SUMMARIES: Record = { stalled: "Stopped after a long silence with no tool activity. The parent can re-dispatch or check the background work directly.", "incomplete-report": "Stopped: worker narrated instead of writing a report envelope.", + interrupted: "Stopped: interrupted before finishing.", }; +const FAIL_THEN_SUCCESSOR_BLOCKERS = + "Diagnose from Findings; MAY spawn one successor with a changed brief. Do not repeat the same brief. Do not start a diagnostic wave."; + +function forcedStopBlockers(reason: ForcedStopReason): string { + switch (reason) { + case "cancelled": + return "Operator or parent cancelled the worker mid-run; synthesize the partial findings below, report Blockers, and wait for the operator."; + case "deadline": + return "Worker wall-clock deadline elapsed mid-run; parent may re-dispatch with a longer deadline or a narrower scope for the remaining work."; + case "stalled": + return "Worker went quiet (e.g. parked on a long-running background command) past the stall timeout after an initial nudge; parent may re-dispatch to finish this lane or check on the background work directly. Do not start a diagnostic wave."; + case "interrupted": + case "incomplete-report": + return FAIL_THEN_SUCCESSOR_BLOCKERS; + } +} + function normalizeSalvagePaths(paths: ForcedStopReportOptions["paths"]): string { if (paths === undefined) return ""; if (typeof paths === "string") return paths.trim(); @@ -227,14 +246,7 @@ export function forcedStopReport( const detail = options.detail; const pathText = normalizeSalvagePaths(options.paths); const summary = FORCED_STOP_SUMMARIES[reason]; - const blockers = - reason === "cancelled" - ? "Operator or parent cancelled the worker mid-run; synthesize the partial findings below, report Blockers, and wait for the operator." - : reason === "deadline" - ? "Worker wall-clock deadline elapsed mid-run; parent may re-dispatch with a longer deadline or a narrower scope for the remaining work." - : reason === "stalled" - ? "Worker went quiet (e.g. parked on a long-running background command) past the stall timeout after an initial nudge; parent may re-dispatch to finish or check on the background work directly." - : "Worker ended a tool-using run with a tool-less turn that had no four-heading report envelope (Summary/Findings/Blockers/Paths) after a wrap-up nudge. Findings below are the narration, not a structured report."; + const blockers = forcedStopBlockers(reason); // Demote nested report-section headings so runSubAgent's parse/format pass // cannot clobber this outer Summary/Blockers with an agent-shaped envelope // stuffed into Findings (cancel after a structured partial). @@ -260,6 +272,9 @@ const DEADLINE_PARENT_HINT = const CANCELLED_PARENT_HINT = "[Sub-agent was cancelled before finishing. Synthesize Findings and Paths rather than redoing completed work; wait for the operator instead of auto-starting another specialist.]"; +const FAIL_THEN_SUCCESSOR_PARENT_HINT = + "[Sub-agent stopped before finishing. Diagnose from Findings; MAY spawn one successor with a changed brief. Do not repeat the same brief. Do not start a diagnostic wave.]"; + /** Options for parent-hint stacking (session re-dispatch ledger state). */ export interface SubAgentParentHintOptions { /** @@ -272,8 +287,8 @@ export interface SubAgentParentHintOptions { /** * Prepend the parent-facing salvage hint for `reason`, chosen from the * structured ForcedStopReason the run reported directly — never by parsing - * `report`'s prose. Reasons with no dedicated hint (stalled, incomplete-report, - * or a normal complete) pass `report` through unchanged. + * `report`'s prose. Stalled salvage and a normal complete pass `report` + * through unchanged. */ export function appendSubAgentParentHints( report: string, @@ -285,6 +300,9 @@ export function appendSubAgentParentHints( return `${DEADLINE_PARENT_HINT}\n\n${report}`; case "cancelled": return `${CANCELLED_PARENT_HINT}\n\n${report}`; + case "interrupted": + case "incomplete-report": + return `${FAIL_THEN_SUCCESSOR_PARENT_HINT}\n\n${report}`; default: return report; } From 19697ad64587121617a17684e4ccfa69d0fd0b8c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 08:53:43 -0700 Subject: [PATCH 2/7] Include interrupted stop reason on in-flight wait results interrupt_agent flipped wait to interrupted while the run was still in flight, so wait JSON omitted stop_reason. Skywalker classifies interrupted-incomplete only on that field. --- docs/TELEMETRY.md | 4 ++-- src/subagent/agent-fleet.test.ts | 17 +++++++++++++---- src/subagent/agent-fleet.ts | 9 +++++++-- src/subagent/session-store.test.ts | 9 +++++++++ src/subagent/session-store.ts | 2 ++ 5 files changed, 33 insertions(+), 8 deletions(-) diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index beda60d23..c8d424bdf 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -111,8 +111,8 @@ a truthy value (`1`, `true`, …) to restore per-call spans for debugging. worker ending during an active parent turn carries that turn's `parent_trace_id`. Pre-progress operator aborts settle with `status=cancelled` and `stop_reason=cancelled` even when the worker promise rejects. An interrupt that -keeps a worker resumable settles with `status=interrupted` and the same -`stop_reason=cancelled`; terminal events never report a still-running status. +keeps a worker resumable settles with `status=interrupted` and +`stop_reason=interrupted`; terminal events never report a still-running status. A deterministic synthetic fixture captures 10 parent generations, 80 parent tool spans, and 4 worker start/end pairs. The comparable former shape is 98 diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index 9997dc572..f07a1ac8e 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -999,10 +999,15 @@ describe("interrupt_agent unblocks wait_agents", () => { const waited = await waiting; expect(waited.timed_out).toBe(false); - const results = waited.results as { agent_id: string; status: string }[]; - expect(results).toEqual([{ agent_id: id, status: "interrupted" }]); + const results = waited.results as { + agent_id: string; + status: string; + stop_reason?: string; + }[]; + expect(results).toEqual([{ agent_id: id, status: "interrupted", stop_reason: "interrupted" }]); expect(deps.sessions.get(id)?.lifecycleStatus).toBe("interrupted"); expect(deps.sessions.get(id)?.status).toBe("running"); + expect(deps.sessions.get(id)?.stopReason).toBe("interrupted"); }); test("an interrupted run result terminalizes a still-running fleet record", async () => { @@ -1135,8 +1140,12 @@ describe("interrupt_agent unblocks wait_agents", () => { const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 }); expect(waited.timed_out).toBe(false); - const results = waited.results as { agent_id: string; status: string }[]; - expect(results).toEqual([{ agent_id: id, status: "interrupted" }]); + const results = waited.results as { + agent_id: string; + status: string; + stop_reason?: string; + }[]; + expect(results).toEqual([{ agent_id: id, status: "interrupted", stop_reason: "interrupted" }]); expect(deps.fleetRecords.peek(id)?.status).toBe("interrupted"); expect(deps.fleetRecords.peek(id)?.collected).toBe(true); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 85c097e9b..ee29a5108 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -337,6 +337,10 @@ class FleetMailbox { overlay.tombstoned !== true && session !== undefined && sessionWait === status ? session : undefined; + const stopReason = + overlay.tombstoned !== true && !isLiveWaitStatus(status) + ? (payload?.stopReason ?? session?.stopReason) + : payload?.stopReason; const ask = status === "awaiting_director" ? this.sessions.peekAsk(id) : undefined; return { status, @@ -345,7 +349,7 @@ class FleetMailbox { ...(overlay.hint !== undefined ? { hint: overlay.hint } : {}), ...(payload?.report !== undefined ? { report: payload.report } : {}), ...(payload?.error !== undefined && status === "failed" ? { error: payload.error } : {}), - ...(payload?.stopReason !== undefined ? { stopReason: payload.stopReason } : {}), + ...(stopReason !== undefined ? { stopReason } : {}), ...(overlay.providerFailure === true ? { providerFailure: true } : {}), ...(ask !== undefined ? { question: ask.question, questionId: ask.questionId } : {}), ...(status === "awaiting_director" && session !== undefined @@ -466,7 +470,8 @@ export const waitAgentsToolDefinition: ToolDefinition = { `clamped to a ${MAX_WAIT_TIMEOUT_MS}ms max. A timeout or parent-turn abort is NOT an error and never touches ` + `the workers — they keep running and remain waitable. Live wait status includes "queued" (waiting for a burst ` + `slot), "running", and "awaiting_director". interrupt_agent and close_agent unblock this wait immediately with ` + - `status "interrupted". awaiting_director is not terminal: re-wait while still pending re-delivers the same question. ` + + `status "interrupted". Terminal JSON includes stop_reason when the session recorded one ` + + `(interrupted, cancelled, incomplete-report, and similar). awaiting_director is not terminal: re-wait while still pending re-delivers the same question. ` + `Answer with send_input (soft). Do not call this in a tight zero-progress loop: a timeout means the targets are still ` + `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 ` + `same targets is a real timed wait, not a spin, but wastes turns if nothing has changed.`, diff --git a/src/subagent/session-store.test.ts b/src/subagent/session-store.test.ts index 88d74a5d7..1016c2599 100644 --- a/src/subagent/session-store.test.ts +++ b/src/subagent/session-store.test.ts @@ -299,6 +299,15 @@ describe("terminal stop reasons", () => { store.cancel(bare.id); expect(store.get(bare.id)?.stopReason).toBe("cancelled"); }); + + test("interruptOne records stopReason interrupted", () => { + const store = createSubAgentSessionStore(); + const session = store.start({ description: "d", agentId: "a", brief: "b" }); + store.markRunning(session.id); + store.registerInterrupt(session.id, () => {}); + expect(store.interruptOne(session.id).ok).toBe(true); + expect(store.get(session.id)?.stopReason).toBe("interrupted"); + }); }); describe("CL-6943 reusable worker sessions", () => { diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index ca3cf8e72..bb105c2c0 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -1382,6 +1382,7 @@ export function createSubAgentSessionStore( ...(s.report !== undefined ? { report: s.report } : {}), }; s.finishedAt = s.finishedAt ?? now(); + s.stopReason = "interrupted"; }); pruneRetained(); return { ok: true }; @@ -1396,6 +1397,7 @@ export function createSubAgentSessionStore( ...(s.report !== undefined ? { report: s.report } : {}), }; s.finishedAt = s.finishedAt ?? now(); + s.stopReason = "interrupted"; }); pruneRetained(); return { ok: true }; From eb434549208756ca443d9165e0e21cb042c1408b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 09:17:16 -0700 Subject: [PATCH 3/7] Clear leftover interrupted stop reason on follow-up begin A successful resume kept the prior interrupt stamp, so wait_agents returned done with stop_reason interrupted. That looked like interrupted-incomplete and could start another successor. --- src/subagent/lifecycle-tools.test.ts | 53 ++++++++++++++++++++++++++++ src/subagent/session-store.ts | 1 + 2 files changed, 54 insertions(+) diff --git a/src/subagent/lifecycle-tools.test.ts b/src/subagent/lifecycle-tools.test.ts index aee86311b..8c6f29ba4 100644 --- a/src/subagent/lifecycle-tools.test.ts +++ b/src/subagent/lifecycle-tools.test.ts @@ -369,6 +369,59 @@ describe("resume_agent", () => { expect(results[0]!.report).toBe("second report"); }); + test("interrupt then successful resume wait is done without leftover interrupted stop_reason", async () => { + const sessions = createSubAgentSessionStore(); + const fleetRecords = createFleetMailbox(sessions); + const worker = sessions.start({ + description: "worker", + agentId: "a", + brief: "b", + retained: true, + }); + sessions.markRunning(worker.id); + sessions.registerInterrupt(worker.id, () => {}); + let finish: (reply: string) => void = () => {}; + sessions.registerFollowup( + worker.id, + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + fleetRecords.register(worker.id); + + const interruptAgent = createInterruptAgentTool({ sessions, fleetRecords }); + const resumeAgent = createResumeAgentTool({ sessions, fleetRecords }); + const wait = createWaitAgentsTool({ sessions, fleetRecords }); + + const interruptWaiting = callTool(wait, { targets: [worker.id], timeout_ms: 2000 }); + await callTool(interruptAgent, { target: worker.id }); + const interruptedWait = await interruptWaiting; + expect(interruptedWait.timed_out).toBe(false); + const interruptedResults = interruptedWait.results as { + status: string; + stop_reason?: string; + }[]; + expect(interruptedResults[0]!.status).toBe("interrupted"); + expect(interruptedResults[0]!.stop_reason).toBe("interrupted"); + + const resumed = await callTool(resumeAgent, { target: worker.id, message: "continue" }); + expect(resumed.status).toBe("running"); + + const waiting = callTool(wait, { targets: [worker.id], timeout_ms: 2000 }); + finish("resumed report"); + const collected = await waiting; + expect(collected.timed_out).toBe(false); + const results = collected.results as { + status: string; + report?: string; + stop_reason?: string; + }[]; + expect(results[0]!.status).toBe("done"); + expect(results[0]!.report).toBe("resumed report"); + expect(results[0]!.stop_reason).not.toBe("interrupted"); + }); + test("resume followup rejection invokes close; close_agent tears down leftover", async () => { const sessions = createSubAgentSessionStore(); const fleetRecords = createFleetMailbox(sessions); diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index bb105c2c0..6efcd2c0d 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -753,6 +753,7 @@ export function createSubAgentSessionStore( mutate(id, (s) => { s.lifecycle = { state: "running" }; delete s.finishedAt; + delete s.stopReason; }); }; const endFollowupTurn = (id: string, restore: "completed" | "interrupted"): void => { From b13141e09fa89e475e3486c8db3d7eeea9c72bfc Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 10:22:26 -0700 Subject: [PATCH 4/7] 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. --- CHANGELOG.md | 12 +++-- docs/ARCHITECTURE.md | 2 +- src/agent/directors/skywalker/package.test.ts | 12 ++++- src/agent/directors/skywalker/package.ts | 4 +- src/agent/prompts.ts | 2 +- src/prompts.test.ts | 5 +- src/subagent/agent-fleet.test.ts | 48 +++++++++++++++++++ src/subagent/agent-fleet.ts | 9 ++-- src/subagent/followup-live-agent.test.ts | 5 +- src/subagent/index.test.ts | 8 ++-- src/subagent/lifecycle-tools.test.ts | 33 ++++++++++++- src/subagent/session-store.test.ts | 18 +++++++ src/subagent/session-store.ts | 4 ++ src/subagent/stop-policy.ts | 10 +++- 14 files changed, 152 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e18f239d..67ef15144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,10 +37,14 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ### Changed -- Skywalker may spawn one successor with a changed brief after a failed, - incomplete-report, or interrupted-incomplete fleet worker. Operator-cancelled - salvage still waits for the operator. Identical briefs stay refused. This - reverses the 0.3.15 fail-path idle, not operator-cancel. +- Skywalker may spawn one successor with a changed brief after a failed or + incomplete-report fleet worker. A parent-initiated interrupt + (`stop_reason: interrupted`) is a resumable pause — `resume_agent` or + re-wait, not a successor — unless the session is no longer resumable. + Operator-cancelled salvage still waits for the operator. Identical briefs + stay refused at the prompt / spawn-handoff layer. `wait_agents` JSON + includes `stop_reason` so interrupt vs cancelled vs incomplete-report stay + distinct. This reverses the 0.3.15 fail-path idle, not operator-cancel. ## [0.3.18] - 2026-09-08 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ee90a69f3..cc52f41cf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -114,7 +114,7 @@ Two directors, selected by role: - **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. - **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. - `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. + `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. #### Model-family policy (`src/agent/model-family-policy.ts`) diff --git a/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index ec59dc8f2..378dac0c5 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -149,7 +149,6 @@ describe("skywalkerPackage", () => { test("systemPrompt fail-then-successor is distinct from operator-cancel wait", () => { const p = skywalkerPackage.systemPrompt; expect(p).toContain("incomplete-report"); - expect(p).toContain("interrupted-incomplete"); expect(p).toContain("MAY `spawn_agent` **one** successor"); expect(p).toContain("changed** brief"); expect(p).toContain("wait for the operator"); @@ -158,6 +157,17 @@ describe("skywalkerPackage", () => { expect(p).toContain("Operator-cancel is not a re-dispatch"); expect(p).not.toContain("Then start the next worker"); expect(p).not.toContain("if the job still needs doing"); + expect(p).not.toContain("interrupted-incomplete"); + }); + + test("systemPrompt treats parent interrupt as resume, not successor spawn", () => { + const p = skywalkerPackage.systemPrompt; + expect(p).toContain("Parent-initiated interrupt"); + expect(p).toContain("resume_agent"); + expect(p).toContain("still-live worker"); + expect(p).toContain("no longer resumable"); + expect(p).toContain("interrupt_agent"); + expect(p).toContain("stop_reason: interrupted"); }); test("systemPrompt simple path skips explorer+critic for tiny work", () => { diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 0e6a5f5d6..85759fab1 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -77,7 +77,8 @@ Do **not** turn a "why is this stalled / why no thinking / spawn looks broken" d - Answer from mounted tools + known architecture; at most **one** explorer worker if a single unknown path blocks the answer. - Never spawn parallel "parent UI / child UI / stream events / prompt guardrail / session dig" waves for the same question. - 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. -- 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. +- 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. +- 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. - 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. - Do **not** search the repo yourself after a worker stops without finishing. - 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 Re-dispatch after a blocker is a new handoff (new criteria / new do_not), not a retry of the old one-liner. Identical re-dispatch of the same brief stays refused. Operator-cancel is not a re-dispatch — wait for the operator. +Parent-initiated interrupt is not a re-dispatch — resume_agent (or re-wait). Successor only if the session is no longer resumable. 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). # Verify after ship diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index eb280820b..d9646f217 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -170,7 +170,7 @@ export function buildGuidelines( "Orchestration:", "- 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.", "- 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.", - "- 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.", + "- 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.", "- Use manage_tasks for your own coordination checklist; spawning workers is `spawn_agent` / `wait_agents`, not manage_tasks.", "- If context is compacted automatically, do not stop tasks early due to token fear; persist progress via manage_tasks and worker reports.", ]), diff --git a/src/prompts.test.ts b/src/prompts.test.ts index dab6c0d13..dfbb72147 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -129,13 +129,16 @@ test("orchestrator guidelines teach the typed task spawn contract", () => { expect(guidelines).not.toContain("weaker"); }); -test("primary chat prompt classifies fail-path successor vs operator-cancel wait", () => { +test("primary chat prompt classifies fail-path successor vs interrupt resume vs operator-cancel wait", () => { const prompt = buildChatSystemPrompt(); const guidelines = buildGuidelines({ sessionMode: "orchestrator" }); expect(guidelines).toContain("MAY spawn one successor with a changed brief"); expect(guidelines).toContain("wait for the operator"); expect(guidelines).toContain("do not auto-retry"); expect(guidelines).toContain("Identical brief: refuse"); + expect(guidelines).toContain("resume_agent"); + expect(guidelines).toContain("still-live worker"); + expect(guidelines).not.toContain("interrupted-incomplete"); expect(guidelines).not.toContain("start the next worker"); expect(prompt).toContain("MAY `spawn_agent` **one** successor"); expect(prompt).toContain("wait for the operator"); diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index f07a1ac8e..b04dce8da 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -1368,6 +1368,54 @@ describe("list_agents", () => { gate.resolve({ report: "done" }); }); + test("includes stop_reason after interrupt_agent", async () => { + const gate = deferred(); + const deps = makeDeps(async (params) => { + params.onAgentReady?.({ + close: async () => {}, + interrupt: () => {}, + followup: async () => "", + deliver: () => {}, + }); + return gate.promise; + }); + const spawn = createSpawnAgentTool(deps); + const interrupt = createInterruptAgentTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const list = createListAgentsTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const spawned = await callTool(spawn, { + description: "looping", + prompt: "do it", + intent: "explore", + }); + const id = spawned.agent_id as string; + if (interrupt.kind !== "full") throw new Error("expected full tool"); + await interrupt.handler( + { id: "int-list-1", name: "interrupt_agent", arguments: { target: id } }, + new AbortController().signal, + ); + if (list.kind !== "full") throw new Error("expected full tool"); + const raw = await list.handler( + { id: "list-stop-1", name: "list_agents", arguments: {} }, + new AbortController().signal, + ); + const content = typeof raw.content === "string" ? raw.content : JSON.stringify(raw.content); + const parsed = JSON.parse(content) as { + agents: { agent_id: string; status: string; stop_reason?: string }[]; + }; + expect(parsed.agents).toHaveLength(1); + expect(parsed.agents[0]!.agent_id).toBe(id); + expect(parsed.agents[0]!.status).toBe("interrupted"); + expect(parsed.agents[0]!.stop_reason).toBe("interrupted"); + expect(list.definition.description).toContain("stop_reason"); + gate.resolve({ report: "done" }); + }); + test("projects question and question_id while awaiting_director", async () => { const gate = deferred(); const deps = makeDeps(async (params) => { diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index ee29a5108..b5a2f7046 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -469,8 +469,9 @@ export const waitAgentsToolDefinition: ToolDefinition = { `wait_agents pair started — never every running session in the shared store. Default timeout ${DEFAULT_WAIT_TIMEOUT_MS}ms, ` + `clamped to a ${MAX_WAIT_TIMEOUT_MS}ms max. A timeout or parent-turn abort is NOT an error and never touches ` + `the workers — they keep running and remain waitable. Live wait status includes "queued" (waiting for a burst ` + - `slot), "running", and "awaiting_director". interrupt_agent and close_agent unblock this wait immediately with ` + - `status "interrupted". Terminal JSON includes stop_reason when the session recorded one ` + + `slot), "running", and "awaiting_director". interrupt_agent unblocks this wait immediately with ` + + `status "interrupted" (a parent-initiated pause — resume_agent, do not spawn_agent a successor against the still-live worker). ` + + `close_agent also unblocks with status "interrupted" but is permanent. Terminal JSON includes stop_reason when the session recorded one ` + `(interrupted, cancelled, incomplete-report, and similar). awaiting_director is not terminal: re-wait while still pending re-delivers the same question. ` + `Answer with send_input (soft). Do not call this in a tight zero-progress loop: a timeout means the targets are still ` + `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 = { description: "List the workers this session started with spawn_agent — the same fleet wait_agents " + "collects. Does not list siblings or another orchestrator's workers. Each entry is id, " + - "director, description, wait status, lifecycle, and whether wait_agents already collected it. " + + "director, description, wait status, lifecycle, stop_reason when recorded, and whether wait_agents already collected it. " + "When status is awaiting_director, the entry also includes question and question_id.", inputSchema: { type: "object", @@ -1439,6 +1440,7 @@ export function createListAgentsTool(deps: WaitAgentsDeps): AgentTool { const agents = deps.fleetRecords.ids().map((id) => { const record = deps.fleetRecords.peek(id); const session = deps.sessions.get(id); + const stopReason = record?.stopReason ?? session?.stopReason; return { agent_id: id, status: record?.status ?? "unknown", @@ -1450,6 +1452,7 @@ export function createListAgentsTool(deps: WaitAgentsDeps): AgentTool { lifecycle: session.lifecycleStatus, } : {}), + ...(stopReason !== undefined ? { stop_reason: stopReason } : {}), ...(record?.status === "awaiting_director" && record.question !== undefined ? { question: record.question } : {}), diff --git a/src/subagent/followup-live-agent.test.ts b/src/subagent/followup-live-agent.test.ts index a0147766b..3ea308c0e 100644 --- a/src/subagent/followup-live-agent.test.ts +++ b/src/subagent/followup-live-agent.test.ts @@ -221,8 +221,9 @@ describe("interrupt_agent / resume_agent reuse the same live agent", () => { expect(outcome.stopReason).toBe("interrupted"); expect(outcome.stopReason).not.toBe("cancelled"); expect(outcome.interrupted).toBe(true); - expect(outcome.report).toContain("MAY spawn one successor"); - expect(outcome.report).toContain("changed brief"); + expect(outcome.report).toContain("resume_agent"); + expect(outcome.report).toContain("still-live"); + expect(outcome.report).not.toContain("MAY spawn one successor"); expect(outcome.report).not.toContain("wait for the operator"); }); diff --git a/src/subagent/index.test.ts b/src/subagent/index.test.ts index c5fc0c1d2..5aafcab21 100644 --- a/src/subagent/index.test.ts +++ b/src/subagent/index.test.ts @@ -406,11 +406,13 @@ describe("sub-agent stop helpers", () => { const interrupted = forcedStopReport("interrupted", "Partial work"); const interruptedParsed = parseSubAgentReport(interrupted); - expect(interruptedParsed.blockers).toContain("one successor"); - expect(interruptedParsed.blockers).toContain("changed brief"); + expect(interruptedParsed.blockers).toContain("resume_agent"); + expect(interruptedParsed.blockers).toContain("still-live"); + expect(interruptedParsed.blockers).not.toContain("MAY spawn one successor"); expect(interruptedParsed.blockers).not.toContain("wait for the operator"); const interruptedWithHint = appendSubAgentParentHints(interrupted, "interrupted"); - expect(interruptedWithHint).toContain("MAY spawn one successor"); + expect(interruptedWithHint).toContain("resume_agent"); + expect(interruptedWithHint).not.toContain("MAY spawn one successor"); expect(interruptedWithHint).not.toContain("wait for the operator instead of auto-starting"); const stalled = forcedStopReport("stalled", "parked"); diff --git a/src/subagent/lifecycle-tools.test.ts b/src/subagent/lifecycle-tools.test.ts index 8c6f29ba4..72ae5b86c 100644 --- a/src/subagent/lifecycle-tools.test.ts +++ b/src/subagent/lifecycle-tools.test.ts @@ -419,7 +419,38 @@ describe("resume_agent", () => { }[]; expect(results[0]!.status).toBe("done"); expect(results[0]!.report).toBe("resumed report"); - expect(results[0]!.stop_reason).not.toBe("interrupted"); + expect(results[0]!.stop_reason).toBeUndefined(); + }); + + test("followup throw after interrupt wait still has stop_reason interrupted", async () => { + const sessions = createSubAgentSessionStore(); + const fleetRecords = createFleetMailbox(sessions); + const worker = sessions.start({ + description: "worker", + agentId: "a", + brief: "b", + retained: true, + }); + sessions.markRunning(worker.id); + sessions.registerInterrupt(worker.id, () => {}); + sessions.registerFollowup(worker.id, async () => { + throw new Error("send failed"); + }); + fleetRecords.register(worker.id); + + const sendInput = createSendInputTool({ sessions, fleetRecords }); + const wait = createWaitAgentsTool({ sessions, fleetRecords }); + + await callTool(sendInput, { target: worker.id, message: "stop that", interrupt: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const collected = await callTool(wait, { targets: [worker.id], timeout_ms: 1000 }); + expect(collected.timed_out).toBe(false); + const results = collected.results as { + status: string; + stop_reason?: string; + }[]; + expect(results[0]!.status).toBe("interrupted"); + expect(results[0]!.stop_reason).toBe("interrupted"); }); test("resume followup rejection invokes close; close_agent tears down leftover", async () => { diff --git a/src/subagent/session-store.test.ts b/src/subagent/session-store.test.ts index 1016c2599..5c2386237 100644 --- a/src/subagent/session-store.test.ts +++ b/src/subagent/session-store.test.ts @@ -477,6 +477,24 @@ describe("CL-6943 reusable worker sessions", () => { expect(store.interruptOne(session.id)).toEqual({ ok: false, status: "completed" }); }); + test("rejected followup after interrupt restamps stopReason interrupted", async () => { + const store = createSubAgentSessionStore(); + const session = store.start({ description: "d", agentId: "a", brief: "b", retained: true }); + store.markRunning(session.id); + store.registerInterrupt(session.id, () => {}); + store.registerFollowup(session.id, async () => { + throw new Error("send failed"); + }); + expect(store.sendInputOne(session.id, "stop that", { interrupt: true })).toEqual({ + ok: true, + status: "interrupted", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const after = store.get(session.id); + expect(after?.lifecycle.state).toBe("interrupted"); + expect(after?.stopReason).toBe("interrupted"); + }); + test("interrupt then abort does not overwrite interrupted stamp to completed", async () => { let rejectFollowup: (err: unknown) => void = () => {}; const store = createSubAgentSessionStore(); diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index 6efcd2c0d..cd34a4698 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -760,6 +760,9 @@ export function createSubAgentSessionStore( mutate(id, (s) => { if (s.lifecycle.state !== "running" && s.lifecycle.state !== "pending_init") { s.finishedAt = s.finishedAt ?? now(); + if (restore === "interrupted" && s.stopReason === undefined) { + s.stopReason = "interrupted"; + } return; } if (restore === "interrupted") { @@ -767,6 +770,7 @@ export function createSubAgentSessionStore( state: "interrupted", ...(s.report !== undefined ? { report: s.report } : {}), }; + s.stopReason = "interrupted"; } else { s.lifecycle = { state: "completed", report: s.report ?? "" }; } diff --git a/src/subagent/stop-policy.ts b/src/subagent/stop-policy.ts index e47e930a2..acbbf7eb1 100644 --- a/src/subagent/stop-policy.ts +++ b/src/subagent/stop-policy.ts @@ -208,6 +208,9 @@ const FORCED_STOP_SUMMARIES: Record = { const FAIL_THEN_SUCCESSOR_BLOCKERS = "Diagnose from Findings; MAY spawn one successor with a changed brief. Do not repeat the same brief. Do not start a diagnostic wave."; +const INTERRUPT_RESUME_BLOCKERS = + "Parent-initiated pause; the worker is still resumable. Call resume_agent (changed follow-up into retained context) or re-wait. Do not spawn_agent a successor against this still-live session. Successor only if the session is no longer resumable."; + function forcedStopBlockers(reason: ForcedStopReason): string { switch (reason) { case "cancelled": @@ -217,6 +220,7 @@ function forcedStopBlockers(reason: ForcedStopReason): string { case "stalled": return "Worker went quiet (e.g. parked on a long-running background command) past the stall timeout after an initial nudge; parent may re-dispatch to finish this lane or check on the background work directly. Do not start a diagnostic wave."; case "interrupted": + return INTERRUPT_RESUME_BLOCKERS; case "incomplete-report": return FAIL_THEN_SUCCESSOR_BLOCKERS; } @@ -272,8 +276,9 @@ const DEADLINE_PARENT_HINT = const CANCELLED_PARENT_HINT = "[Sub-agent was cancelled before finishing. Synthesize Findings and Paths rather than redoing completed work; wait for the operator instead of auto-starting another specialist.]"; -const FAIL_THEN_SUCCESSOR_PARENT_HINT = - "[Sub-agent stopped before finishing. Diagnose from Findings; MAY spawn one successor with a changed brief. Do not repeat the same brief. Do not start a diagnostic wave.]"; +const FAIL_THEN_SUCCESSOR_PARENT_HINT = `[Sub-agent stopped before finishing. ${FAIL_THEN_SUCCESSOR_BLOCKERS}]`; + +const INTERRUPT_RESUME_PARENT_HINT = `[Sub-agent was interrupted before finishing. ${INTERRUPT_RESUME_BLOCKERS}]`; /** Options for parent-hint stacking (session re-dispatch ledger state). */ export interface SubAgentParentHintOptions { @@ -301,6 +306,7 @@ export function appendSubAgentParentHints( case "cancelled": return `${CANCELLED_PARENT_HINT}\n\n${report}`; case "interrupted": + return `${INTERRUPT_RESUME_PARENT_HINT}\n\n${report}`; case "incomplete-report": return `${FAIL_THEN_SUCCESSOR_PARENT_HINT}\n\n${report}`; default: From 61740b7d090ceb9f32fcbe84146edb1feff5de5a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 11:27:27 -0700 Subject: [PATCH 5/7] Stamp interrupted stop reason on send_input interrupt send_input with interrupt:true already unblocked wait_agents as interrupted, but only the overlay flipped. Stamp session.stopReason after the follow-up queues so that wait JSON includes stop_reason interrupted, matching interrupt_agent. --- src/subagent/agent-fleet.test.ts | 9 +++++++-- src/subagent/session-store.test.ts | 13 +++++++++++++ src/subagent/session-store.ts | 6 ++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index b04dce8da..654ac84a3 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -1103,8 +1103,13 @@ describe("interrupt_agent unblocks wait_agents", () => { await callTool(sendInput, { target: id, message: "stop that", interrupt: true }); const waited = await waiting; expect(waited.timed_out).toBe(false); - const results = waited.results as { status: string }[]; - expect(results[0]!.status).toBe("interrupted"); + const results = waited.results as { + agent_id: string; + status: string; + stop_reason?: string; + }[]; + expect(results).toEqual([{ agent_id: id, status: "interrupted", stop_reason: "interrupted" }]); + expect(deps.sessions.get(id)?.stopReason).toBe("interrupted"); followupGate.resolve("later"); }); diff --git a/src/subagent/session-store.test.ts b/src/subagent/session-store.test.ts index 5c2386237..918304681 100644 --- a/src/subagent/session-store.test.ts +++ b/src/subagent/session-store.test.ts @@ -308,6 +308,19 @@ describe("terminal stop reasons", () => { expect(store.interruptOne(session.id).ok).toBe(true); expect(store.get(session.id)?.stopReason).toBe("interrupted"); }); + + test("sendInputOne interrupt records stopReason interrupted", () => { + const store = createSubAgentSessionStore(); + const session = store.start({ description: "d", agentId: "a", brief: "b", retained: true }); + store.markRunning(session.id); + store.registerInterrupt(session.id, () => {}); + store.registerFollowup(session.id, () => new Promise(() => {})); + expect(store.sendInputOne(session.id, "stop that", { interrupt: true })).toEqual({ + ok: true, + status: "interrupted", + }); + expect(store.get(session.id)?.stopReason).toBe("interrupted"); + }); }); describe("CL-6943 reusable worker sessions", () => { diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index cd34a4698..808597166 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -1303,6 +1303,12 @@ export function createSubAgentSessionStore( queueFollowupTurn(id, message, "interrupted", { ...(opts.onFollowupReply !== undefined ? { onReply: opts.onFollowupReply } : {}), }); + // After beginFollowupTurn, which clears leftover stopReason. Stamp + // here so an in-flight wait_agents overlay can project interrupted + // without flipping lifecycle off the live follow-up. + mutate(id, (s) => { + s.stopReason = "interrupted"; + }); pruneRetained(); return { ok: true, status: "interrupted" }; } From 31f1f141f9c27f89d5f81d129765a631d87b7750 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 12:46:29 -0700 Subject: [PATCH 6/7] Clear interrupted stamp after follow-up succeeds send_input interrupt restamps interrupted for in-flight wait; a completed follow-up must not keep that stamp on done. --- src/subagent/lifecycle-tools.test.ts | 45 ++++++++++++++++++++++++++++ src/subagent/session-store.ts | 1 + 2 files changed, 46 insertions(+) diff --git a/src/subagent/lifecycle-tools.test.ts b/src/subagent/lifecycle-tools.test.ts index 72ae5b86c..f0788c344 100644 --- a/src/subagent/lifecycle-tools.test.ts +++ b/src/subagent/lifecycle-tools.test.ts @@ -422,6 +422,51 @@ describe("resume_agent", () => { expect(results[0]!.stop_reason).toBeUndefined(); }); + test("send_input interrupt then successful follow-up wait is done without leftover interrupted stop_reason", async () => { + const sessions = createSubAgentSessionStore(); + const fleetRecords = createFleetMailbox(sessions); + const worker = sessions.start({ + description: "worker", + agentId: "a", + brief: "b", + retained: true, + }); + sessions.markRunning(worker.id); + sessions.registerInterrupt(worker.id, () => {}); + let finish: (reply: string) => void = () => {}; + sessions.registerFollowup( + worker.id, + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + fleetRecords.register(worker.id); + + const sendInput = createSendInputTool({ sessions, fleetRecords }); + const wait = createWaitAgentsTool({ sessions, fleetRecords }); + + await callTool(sendInput, { target: worker.id, message: "stop that", interrupt: true }); + const inflight = fleetRecords.peek(worker.id); + expect(inflight?.status).toBe("interrupted"); + expect(inflight?.stopReason).toBe("interrupted"); + expect(sessions.get(worker.id)?.stopReason).toBe("interrupted"); + expect(sessions.get(worker.id)?.lifecycleStatus).toBe("running"); + + finish("followup report"); + await new Promise((resolve) => setTimeout(resolve, 0)); + const collected = await callTool(wait, { targets: [worker.id], timeout_ms: 1000 }); + expect(collected.timed_out).toBe(false); + const results = collected.results as { + status: string; + report?: string; + stop_reason?: string; + }[]; + expect(results[0]!.status).toBe("done"); + expect(results[0]!.report).toBe("followup report"); + expect(results[0]!.stop_reason).toBeUndefined(); + }); + test("followup throw after interrupt wait still has stop_reason interrupted", async () => { const sessions = createSubAgentSessionStore(); const fleetRecords = createFleetMailbox(sessions); diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index 808597166..a7272cd3f 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -818,6 +818,7 @@ export function createSubAgentSessionStore( s.lifecycle = { state: "completed", report: reply }; s.finishedAt = now(); s.report = reply; + delete s.stopReason; pushEntry(s, { kind: "report", content: capText(reply, maxEntryChars) }); }); runInFlight.delete(id); From fcdd49d342b81de40b1f8cb4974b45c770d16404 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 15:16:52 -0700 Subject: [PATCH 7/7] Move send_input interrupt leftover tests out of resume_agent The leftover interrupted stop_reason coverage is send_input behavior. Keep it next to the other send_input cases so resume_agent does not own that contract. --- src/subagent/lifecycle-tools.test.ts | 152 +++++++++++++-------------- 1 file changed, 76 insertions(+), 76 deletions(-) diff --git a/src/subagent/lifecycle-tools.test.ts b/src/subagent/lifecycle-tools.test.ts index f0788c344..a4dc978b6 100644 --- a/src/subagent/lifecycle-tools.test.ts +++ b/src/subagent/lifecycle-tools.test.ts @@ -422,82 +422,6 @@ describe("resume_agent", () => { expect(results[0]!.stop_reason).toBeUndefined(); }); - test("send_input interrupt then successful follow-up wait is done without leftover interrupted stop_reason", async () => { - const sessions = createSubAgentSessionStore(); - const fleetRecords = createFleetMailbox(sessions); - const worker = sessions.start({ - description: "worker", - agentId: "a", - brief: "b", - retained: true, - }); - sessions.markRunning(worker.id); - sessions.registerInterrupt(worker.id, () => {}); - let finish: (reply: string) => void = () => {}; - sessions.registerFollowup( - worker.id, - () => - new Promise((resolve) => { - finish = resolve; - }), - ); - fleetRecords.register(worker.id); - - const sendInput = createSendInputTool({ sessions, fleetRecords }); - const wait = createWaitAgentsTool({ sessions, fleetRecords }); - - await callTool(sendInput, { target: worker.id, message: "stop that", interrupt: true }); - const inflight = fleetRecords.peek(worker.id); - expect(inflight?.status).toBe("interrupted"); - expect(inflight?.stopReason).toBe("interrupted"); - expect(sessions.get(worker.id)?.stopReason).toBe("interrupted"); - expect(sessions.get(worker.id)?.lifecycleStatus).toBe("running"); - - finish("followup report"); - await new Promise((resolve) => setTimeout(resolve, 0)); - const collected = await callTool(wait, { targets: [worker.id], timeout_ms: 1000 }); - expect(collected.timed_out).toBe(false); - const results = collected.results as { - status: string; - report?: string; - stop_reason?: string; - }[]; - expect(results[0]!.status).toBe("done"); - expect(results[0]!.report).toBe("followup report"); - expect(results[0]!.stop_reason).toBeUndefined(); - }); - - test("followup throw after interrupt wait still has stop_reason interrupted", async () => { - const sessions = createSubAgentSessionStore(); - const fleetRecords = createFleetMailbox(sessions); - const worker = sessions.start({ - description: "worker", - agentId: "a", - brief: "b", - retained: true, - }); - sessions.markRunning(worker.id); - sessions.registerInterrupt(worker.id, () => {}); - sessions.registerFollowup(worker.id, async () => { - throw new Error("send failed"); - }); - fleetRecords.register(worker.id); - - const sendInput = createSendInputTool({ sessions, fleetRecords }); - const wait = createWaitAgentsTool({ sessions, fleetRecords }); - - await callTool(sendInput, { target: worker.id, message: "stop that", interrupt: true }); - await new Promise((resolve) => setTimeout(resolve, 0)); - const collected = await callTool(wait, { targets: [worker.id], timeout_ms: 1000 }); - expect(collected.timed_out).toBe(false); - const results = collected.results as { - status: string; - stop_reason?: string; - }[]; - expect(results[0]!.status).toBe("interrupted"); - expect(results[0]!.stop_reason).toBe("interrupted"); - }); - test("resume followup rejection invokes close; close_agent tears down leftover", async () => { const sessions = createSubAgentSessionStore(); const fleetRecords = createFleetMailbox(sessions); @@ -670,6 +594,82 @@ describe("interrupt_agent", () => { }); describe("send_input", () => { + test("send_input interrupt then successful follow-up wait is done without leftover interrupted stop_reason", async () => { + const sessions = createSubAgentSessionStore(); + const fleetRecords = createFleetMailbox(sessions); + const worker = sessions.start({ + description: "worker", + agentId: "a", + brief: "b", + retained: true, + }); + sessions.markRunning(worker.id); + sessions.registerInterrupt(worker.id, () => {}); + let finish: (reply: string) => void = () => {}; + sessions.registerFollowup( + worker.id, + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + fleetRecords.register(worker.id); + + const sendInput = createSendInputTool({ sessions, fleetRecords }); + const wait = createWaitAgentsTool({ sessions, fleetRecords }); + + await callTool(sendInput, { target: worker.id, message: "stop that", interrupt: true }); + const inflight = fleetRecords.peek(worker.id); + expect(inflight?.status).toBe("interrupted"); + expect(inflight?.stopReason).toBe("interrupted"); + expect(sessions.get(worker.id)?.stopReason).toBe("interrupted"); + expect(sessions.get(worker.id)?.lifecycleStatus).toBe("running"); + + finish("followup report"); + await new Promise((resolve) => setTimeout(resolve, 0)); + const collected = await callTool(wait, { targets: [worker.id], timeout_ms: 1000 }); + expect(collected.timed_out).toBe(false); + const results = collected.results as { + status: string; + report?: string; + stop_reason?: string; + }[]; + expect(results[0]!.status).toBe("done"); + expect(results[0]!.report).toBe("followup report"); + expect(results[0]!.stop_reason).toBeUndefined(); + }); + + test("followup throw after interrupt wait still has stop_reason interrupted", async () => { + const sessions = createSubAgentSessionStore(); + const fleetRecords = createFleetMailbox(sessions); + const worker = sessions.start({ + description: "worker", + agentId: "a", + brief: "b", + retained: true, + }); + sessions.markRunning(worker.id); + sessions.registerInterrupt(worker.id, () => {}); + sessions.registerFollowup(worker.id, async () => { + throw new Error("send failed"); + }); + fleetRecords.register(worker.id); + + const sendInput = createSendInputTool({ sessions, fleetRecords }); + const wait = createWaitAgentsTool({ sessions, fleetRecords }); + + await callTool(sendInput, { target: worker.id, message: "stop that", interrupt: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const collected = await callTool(wait, { targets: [worker.id], timeout_ms: 1000 }); + expect(collected.timed_out).toBe(false); + const results = collected.results as { + status: string; + stop_reason?: string; + }[]; + expect(results[0]!.status).toBe("interrupted"); + expect(results[0]!.stop_reason).toBe("interrupted"); + }); + test("soft-delivers without flipping lifecycle or awaiting a reply", async () => { const sessions = createSubAgentSessionStore(); const worker = sessions.start({