From 3478fa0bcdb36ff6c013f9c885ae1abc3dead6c7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 07:37:47 -0700 Subject: [PATCH 1/3] Surface interrupted workers that still have tools running interrupt_agent does not hard-stop in-flight tools. The strip still painted those lanes as busy. The board now says interrupted and names the leftover tool so the parent can tell a live turn from a stopped one. --- src/tui/agent-progress.test.ts | 17 +++++++++++++++++ src/tui/agent-progress.ts | 21 +++++++++++++++++++++ src/tui/chrome-state.ts | 4 ++++ src/tui/runner-host.ts | 1 + src/tui/runner.ts | 1 + 5 files changed, 44 insertions(+) diff --git a/src/tui/agent-progress.test.ts b/src/tui/agent-progress.test.ts index ce33e23d3..11ad3bc77 100644 --- a/src/tui/agent-progress.test.ts +++ b/src/tui/agent-progress.test.ts @@ -32,6 +32,23 @@ describe("agentProgress", () => { expect(agentProgress({ ...base, status: "cancelled" }, 1000)).toBeNull(); }); + test("an interrupted running session names leftover tools instead of looking busy", () => { + const progress = agentProgress( + { + ...base, + lifecycleStatus: "interrupted", + currentToolName: "run_shell", + currentToolPreview: "bun test", + currentToolStartedAt: 1_000, + lastActivityAt: 1_000, + }, + 91_000, + ); + expect(progress?.stat).toBe("interrupted · bun test still running"); + expect(progress?.working).toBe(false); + expect(progress?.stalled).toBe(false); + }); + test("a running session reports elapsed time and its current tool", () => { const progress = agentProgress({ ...base, lastActivityAt: 42_000 }, 42_000); expect(progress).toEqual({ diff --git a/src/tui/agent-progress.ts b/src/tui/agent-progress.ts index 228160c0d..5a212d249 100644 --- a/src/tui/agent-progress.ts +++ b/src/tui/agent-progress.ts @@ -16,6 +16,14 @@ /** Minimal session shape this module reads — avoids a hard dep on the store. */ export interface AgentProgressSession { readonly status: "running" | "done" | "failed" | "cancelled"; + /** Present when the strip knows lifecycle independently of TUI status. */ + readonly lifecycleStatus?: + | "pending_init" + | "running" + | "interrupted" + | "completed" + | "shutdown" + | "not_found"; readonly currentToolName: string | null; /** * Bounded subject of the oldest outstanding call (command, path, pattern…), @@ -146,6 +154,19 @@ export function agentProgress( const hasSubject = subject !== null; const state = laneState(session, nowMs, stallMs); + if (session.lifecycleStatus === "interrupted") { + const toolBit = + hasSubject && session.currentToolName !== null + ? ` · ${subject} still running` + : " · tools still running"; + return { + stat: `interrupted${toolBit}`, + state, + working: false, + stalled: false, + }; + } + const base = hasSubject ? `${elapsed} · ${subject}` : elapsed; // Never render "quiet" — operator chrome only shows motion (elapsed / tool). // Internal `state` still carries stalled for recovery consumers. diff --git a/src/tui/chrome-state.ts b/src/tui/chrome-state.ts index 01181f3b9..d5f61b765 100644 --- a/src/tui/chrome-state.ts +++ b/src/tui/chrome-state.ts @@ -54,6 +54,7 @@ export interface ChromeAgentSession { readonly agentId: string; readonly description: string; readonly status: "running" | "done" | "failed" | "cancelled"; + readonly lifecycleStatus?: AgentProgressSession["lifecycleStatus"]; /** Current tool while running (optional detail). */ readonly currentToolName?: string | null; /** @@ -319,6 +320,7 @@ function toProgressSession(session: ChromeAgentSession): AgentProgressSession | if (session.startedAt === undefined) return null; return { status: session.status, + ...(session.lifecycleStatus !== undefined ? { lifecycleStatus: session.lifecycleStatus } : {}), currentToolName: session.currentToolName ?? null, currentToolPreview: session.currentToolPreview ?? null, currentToolStartedAt: session.currentToolStartedAt, @@ -493,6 +495,7 @@ export interface ChromeSessionAgent { readonly id?: string; readonly description: string; readonly status: "running" | "done" | "failed" | "cancelled"; + readonly lifecycleStatus?: AgentProgressSession["lifecycleStatus"]; readonly currentToolName?: string | null; readonly currentToolPreview?: string | null; readonly currentToolStartedAt: number | null; @@ -554,6 +557,7 @@ function mapSessionAgents( agentId: agentId.length > 0 ? agentId : "agent", description: a.description, status: a.status, + ...(a.lifecycleStatus !== undefined ? { lifecycleStatus: a.lifecycleStatus } : {}), ...(a.currentToolName !== undefined ? { currentToolName: a.currentToolName } : {}), ...(a.currentToolPreview !== undefined ? { currentToolPreview: a.currentToolPreview } : {}), currentToolStartedAt: a.currentToolStartedAt, diff --git a/src/tui/runner-host.ts b/src/tui/runner-host.ts index 4597a64f6..2e7a6eca5 100644 --- a/src/tui/runner-host.ts +++ b/src/tui/runner-host.ts @@ -279,6 +279,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise deps.subAgentSessions().map((s) => ({ id: s.id, status: s.status, + lifecycleStatus: s.lifecycleStatus, currentToolName: s.currentToolName, currentToolPreview: s.currentToolPreview, currentToolStartedAt: s.currentToolStartedAt, diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 9eb4da9ac..d19b19ab3 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2413,6 +2413,7 @@ export async function runTUI(initialConfig: Config): Promise { id: s.id, description: s.description, status: s.status, + lifecycleStatus: s.lifecycleStatus, currentToolName: s.currentToolName, currentToolPreview: s.currentToolPreview, currentToolStartedAt: s.currentToolStartedAt, From 32139634c40125bfc633d35af75e503528d96177 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 08:29:45 -0700 Subject: [PATCH 2/3] Show plain interrupted when no leftover tool remains After tools drain, currentToolName is null. Claiming "tools still running" lied to the operator. Name a leftover tool while one is present; otherwise just say interrupted. Also satisfy prettier on the lifecycleStatus union. --- src/tui/agent-progress.test.ts | 18 ++++++++++++++++++ src/tui/agent-progress.ts | 11 ++--------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/tui/agent-progress.test.ts b/src/tui/agent-progress.test.ts index 11ad3bc77..6d282da25 100644 --- a/src/tui/agent-progress.test.ts +++ b/src/tui/agent-progress.test.ts @@ -49,6 +49,24 @@ describe("agentProgress", () => { expect(progress?.stalled).toBe(false); }); + test("an interrupted session with no leftover tool shows plain interrupted", () => { + const progress = agentProgress( + { + ...base, + lifecycleStatus: "interrupted", + currentToolName: null, + currentToolPreview: null, + currentToolStartedAt: null, + lastActivityAt: 1_000, + }, + 91_000, + ); + expect(progress?.stat).toBe("interrupted"); + expect(progress?.stat).not.toContain("still running"); + expect(progress?.working).toBe(false); + expect(progress?.stalled).toBe(false); + }); + test("a running session reports elapsed time and its current tool", () => { const progress = agentProgress({ ...base, lastActivityAt: 42_000 }, 42_000); expect(progress).toEqual({ diff --git a/src/tui/agent-progress.ts b/src/tui/agent-progress.ts index 5a212d249..2781dc0be 100644 --- a/src/tui/agent-progress.ts +++ b/src/tui/agent-progress.ts @@ -18,12 +18,7 @@ export interface AgentProgressSession { readonly status: "running" | "done" | "failed" | "cancelled"; /** Present when the strip knows lifecycle independently of TUI status. */ readonly lifecycleStatus?: - | "pending_init" - | "running" - | "interrupted" - | "completed" - | "shutdown" - | "not_found"; + "pending_init" | "running" | "interrupted" | "completed" | "shutdown" | "not_found"; readonly currentToolName: string | null; /** * Bounded subject of the oldest outstanding call (command, path, pattern…), @@ -156,9 +151,7 @@ export function agentProgress( if (session.lifecycleStatus === "interrupted") { const toolBit = - hasSubject && session.currentToolName !== null - ? ` · ${subject} still running` - : " · tools still running"; + hasSubject && session.currentToolName !== null ? ` · ${subject} still running` : ""; return { stat: `interrupted${toolBit}`, state, From 663e22b9ebce4d1fa58b8a3c7d4ebfe115195698 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 08:29:52 -0700 Subject: [PATCH 3/3] Attach late salvage after early interrupt collect interrupt_agent terminalizes the wait mailbox with no report. If wait_agents collects that empty interrupt before the run settles, the later salvage was dropped because collected was already true. Attach a missing report on interrupted records regardless of collect. --- src/subagent/agent-fleet.test.ts | 52 ++++++++++++++++++++++++++++++++ src/subagent/agent-fleet.ts | 12 ++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index 405bbac66..6e0ca0570 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -669,6 +669,58 @@ describe("interrupt_agent unblocks wait_agents", () => { expect(again.timed_out).toBe(false); expect(again.results).toEqual([]); }); + + test("late salvage attaches after wait collected an early interrupt", async () => { + const settle = deferred(); + const deps = makeDeps(async (params) => { + params.onAgentReady?.({ + close: async () => {}, + interrupt: () => {}, + followup: async () => "", + }); + return settle.promise; + }); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const interrupt = createInterruptAgentTool({ + 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; + + // Let onAgentReady register interrupt before we call interrupt_agent. + await new Promise((resolve) => setTimeout(resolve, 20)); + + if (interrupt.kind !== "full") throw new Error("expected full tool"); + await interrupt.handler( + { id: "int-1", name: "interrupt_agent", arguments: { target: id } }, + new AbortController().signal, + ); + + const early = await callTool(wait, { targets: [id], timeout_ms: 5000 }); + expect((early.results as { status: string }[])[0]!.status).toBe("interrupted"); + expect((early.results as { report?: string }[])[0]!.report).toBeUndefined(); + + settle.resolve({ + report: "## Summary\nStopped.\n## Findings\nsalvage\n## Blockers\ninterrupted\n## Paths\n", + interrupted: true, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const again = await callTool(wait, { targets: [id], timeout_ms: 5000 }); + const results = again.results as { status: string; report?: string }[]; + expect(results[0]!.status).toBe("interrupted"); + expect(results[0]!.report).toContain("salvage"); + }); }); describe("close_agent unblocks wait_agents", () => { diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index ad78beacf..4c7bc8e9c 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -131,13 +131,19 @@ class FleetRecords { /** * Marks a still-running record interrupted so wait_agents unblocks. - * No-op on an already-terminal id — interrupt must not clobber a collected - * report, and a late interrupt after complete/fail is meaningless. + * No-op on an already-terminal id that is not interrupted — a late + * interrupt after complete/fail is meaningless. A late salvage report may + * still attach to an interrupted record that has none yet (including after + * an early collect), but never overwrites an existing report. */ interrupt(id: string, report?: string): void { const existing = this.records.get(id); if (existing === undefined) return; - if (existing.status === "interrupted" && existing.collected !== true && report !== undefined) { + if ( + existing.status === "interrupted" && + report !== undefined && + existing.report === undefined + ) { existing.report = report; this.notify(); return;