Skip to content

Commit 336d253

Browse files
Surface interrupted workers that still have tools running (#683)
* 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. * 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. * 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.
1 parent 3701024 commit 336d253

7 files changed

Lines changed: 116 additions & 3 deletions

File tree

src/subagent/agent-fleet.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,58 @@ describe("interrupt_agent unblocks wait_agents", () => {
669669
expect(again.timed_out).toBe(false);
670670
expect(again.results).toEqual([]);
671671
});
672+
673+
test("late salvage attaches after wait collected an early interrupt", async () => {
674+
const settle = deferred<RunSubAgentResult>();
675+
const deps = makeDeps(async (params) => {
676+
params.onAgentReady?.({
677+
close: async () => {},
678+
interrupt: () => {},
679+
followup: async () => "",
680+
});
681+
return settle.promise;
682+
});
683+
const spawn = createSpawnAgentTool(deps);
684+
const wait = createWaitAgentsTool({
685+
sessions: deps.sessions,
686+
fleetRecords: deps.fleetRecords,
687+
});
688+
const interrupt = createInterruptAgentTool({
689+
sessions: deps.sessions,
690+
fleetRecords: deps.fleetRecords,
691+
});
692+
693+
const spawned = await callTool(spawn, {
694+
description: "looping",
695+
prompt: "do it",
696+
intent: "explore",
697+
});
698+
const id = spawned.agent_id as string;
699+
700+
// Let onAgentReady register interrupt before we call interrupt_agent.
701+
await new Promise((resolve) => setTimeout(resolve, 20));
702+
703+
if (interrupt.kind !== "full") throw new Error("expected full tool");
704+
await interrupt.handler(
705+
{ id: "int-1", name: "interrupt_agent", arguments: { target: id } },
706+
new AbortController().signal,
707+
);
708+
709+
const early = await callTool(wait, { targets: [id], timeout_ms: 5000 });
710+
expect((early.results as { status: string }[])[0]!.status).toBe("interrupted");
711+
expect((early.results as { report?: string }[])[0]!.report).toBeUndefined();
712+
713+
settle.resolve({
714+
report: "## Summary\nStopped.\n## Findings\nsalvage\n## Blockers\ninterrupted\n## Paths\n",
715+
interrupted: true,
716+
});
717+
await new Promise((resolve) => setTimeout(resolve, 20));
718+
719+
const again = await callTool(wait, { targets: [id], timeout_ms: 5000 });
720+
const results = again.results as { status: string; report?: string }[];
721+
expect(results[0]!.status).toBe("interrupted");
722+
expect(results[0]!.report).toContain("salvage");
723+
});
672724
});
673725

674726
describe("close_agent unblocks wait_agents", () => {

src/subagent/agent-fleet.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,13 +131,19 @@ class FleetRecords {
131131

132132
/**
133133
* Marks a still-running record interrupted so wait_agents unblocks.
134-
* No-op on an already-terminal id — interrupt must not clobber a collected
135-
* report, and a late interrupt after complete/fail is meaningless.
134+
* No-op on an already-terminal id that is not interrupted — a late
135+
* interrupt after complete/fail is meaningless. A late salvage report may
136+
* still attach to an interrupted record that has none yet (including after
137+
* an early collect), but never overwrites an existing report.
136138
*/
137139
interrupt(id: string, report?: string): void {
138140
const existing = this.records.get(id);
139141
if (existing === undefined) return;
140-
if (existing.status === "interrupted" && existing.collected !== true && report !== undefined) {
142+
if (
143+
existing.status === "interrupted" &&
144+
report !== undefined &&
145+
existing.report === undefined
146+
) {
141147
existing.report = report;
142148
this.notify();
143149
return;

src/tui/agent-progress.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,41 @@ describe("agentProgress", () => {
3232
expect(agentProgress({ ...base, status: "cancelled" }, 1000)).toBeNull();
3333
});
3434

35+
test("an interrupted running session names leftover tools instead of looking busy", () => {
36+
const progress = agentProgress(
37+
{
38+
...base,
39+
lifecycleStatus: "interrupted",
40+
currentToolName: "run_shell",
41+
currentToolPreview: "bun test",
42+
currentToolStartedAt: 1_000,
43+
lastActivityAt: 1_000,
44+
},
45+
91_000,
46+
);
47+
expect(progress?.stat).toBe("interrupted · bun test still running");
48+
expect(progress?.working).toBe(false);
49+
expect(progress?.stalled).toBe(false);
50+
});
51+
52+
test("an interrupted session with no leftover tool shows plain interrupted", () => {
53+
const progress = agentProgress(
54+
{
55+
...base,
56+
lifecycleStatus: "interrupted",
57+
currentToolName: null,
58+
currentToolPreview: null,
59+
currentToolStartedAt: null,
60+
lastActivityAt: 1_000,
61+
},
62+
91_000,
63+
);
64+
expect(progress?.stat).toBe("interrupted");
65+
expect(progress?.stat).not.toContain("still running");
66+
expect(progress?.working).toBe(false);
67+
expect(progress?.stalled).toBe(false);
68+
});
69+
3570
test("a running session reports elapsed time and its current tool", () => {
3671
const progress = agentProgress({ ...base, lastActivityAt: 42_000 }, 42_000);
3772
expect(progress).toEqual({

src/tui/agent-progress.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
/** Minimal session shape this module reads — avoids a hard dep on the store. */
1717
export interface AgentProgressSession {
1818
readonly status: "running" | "done" | "failed" | "cancelled";
19+
/** Present when the strip knows lifecycle independently of TUI status. */
20+
readonly lifecycleStatus?:
21+
"pending_init" | "running" | "interrupted" | "completed" | "shutdown" | "not_found";
1922
readonly currentToolName: string | null;
2023
/**
2124
* Bounded subject of the oldest outstanding call (command, path, pattern…),
@@ -146,6 +149,17 @@ export function agentProgress(
146149
const hasSubject = subject !== null;
147150
const state = laneState(session, nowMs, stallMs);
148151

152+
if (session.lifecycleStatus === "interrupted") {
153+
const toolBit =
154+
hasSubject && session.currentToolName !== null ? ` · ${subject} still running` : "";
155+
return {
156+
stat: `interrupted${toolBit}`,
157+
state,
158+
working: false,
159+
stalled: false,
160+
};
161+
}
162+
149163
const base = hasSubject ? `${elapsed} · ${subject}` : elapsed;
150164
// Never render "quiet" — operator chrome only shows motion (elapsed / tool).
151165
// Internal `state` still carries stalled for recovery consumers.

src/tui/chrome-state.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export interface ChromeAgentSession {
5454
readonly agentId: string;
5555
readonly description: string;
5656
readonly status: "running" | "done" | "failed" | "cancelled";
57+
readonly lifecycleStatus?: AgentProgressSession["lifecycleStatus"];
5758
/** Current tool while running (optional detail). */
5859
readonly currentToolName?: string | null;
5960
/**
@@ -319,6 +320,7 @@ function toProgressSession(session: ChromeAgentSession): AgentProgressSession |
319320
if (session.startedAt === undefined) return null;
320321
return {
321322
status: session.status,
323+
...(session.lifecycleStatus !== undefined ? { lifecycleStatus: session.lifecycleStatus } : {}),
322324
currentToolName: session.currentToolName ?? null,
323325
currentToolPreview: session.currentToolPreview ?? null,
324326
currentToolStartedAt: session.currentToolStartedAt,
@@ -493,6 +495,7 @@ export interface ChromeSessionAgent {
493495
readonly id?: string;
494496
readonly description: string;
495497
readonly status: "running" | "done" | "failed" | "cancelled";
498+
readonly lifecycleStatus?: AgentProgressSession["lifecycleStatus"];
496499
readonly currentToolName?: string | null;
497500
readonly currentToolPreview?: string | null;
498501
readonly currentToolStartedAt: number | null;
@@ -554,6 +557,7 @@ function mapSessionAgents(
554557
agentId: agentId.length > 0 ? agentId : "agent",
555558
description: a.description,
556559
status: a.status,
560+
...(a.lifecycleStatus !== undefined ? { lifecycleStatus: a.lifecycleStatus } : {}),
557561
...(a.currentToolName !== undefined ? { currentToolName: a.currentToolName } : {}),
558562
...(a.currentToolPreview !== undefined ? { currentToolPreview: a.currentToolPreview } : {}),
559563
currentToolStartedAt: a.currentToolStartedAt,

src/tui/runner-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost>
279279
deps.subAgentSessions().map((s) => ({
280280
id: s.id,
281281
status: s.status,
282+
lifecycleStatus: s.lifecycleStatus,
282283
currentToolName: s.currentToolName,
283284
currentToolPreview: s.currentToolPreview,
284285
currentToolStartedAt: s.currentToolStartedAt,

src/tui/runner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2413,6 +2413,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
24132413
id: s.id,
24142414
description: s.description,
24152415
status: s.status,
2416+
lifecycleStatus: s.lifecycleStatus,
24162417
currentToolName: s.currentToolName,
24172418
currentToolPreview: s.currentToolPreview,
24182419
currentToolStartedAt: s.currentToolStartedAt,

0 commit comments

Comments
 (0)