Skip to content

Commit 1e0e9a3

Browse files
committed
Drop interrupted workers from live agents
1 parent 8b115bf commit 1e0e9a3

10 files changed

Lines changed: 318 additions & 25 deletions

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1313

1414
## [Unreleased]
1515

16+
### Fixed
17+
18+
- Interrupted workers linger on the agents strip for 4s then drop, instead of
19+
staying in the live list while leftover tools finish.
20+
1621
## [0.3.7] - 2026-08-27
1722

1823
### Fixed

‎docs/TUI.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,10 @@ status / current tool) — Amp/Codex-style lanes without a FLEET header board:
220220

221221
`formatChromeZones` → `formatAgentsPanel` owns that paint. Geometry stays
222222
stack-only (`layoutMode: "stack"`, `railWidth: 0`); the zone max is
223-
`AGENTS_PANEL_MAX_VISIBLE + 1` (lanes plus a trailing `+N more`). Terminal
224-
lanes (done / failed / cancelled) linger for `AGENTS_PANEL_LINGER_MS` (4s)
225-
after `finishedAt`, then drop. Product-host sticky poll uses
223+
`AGENTS_PANEL_MAX_VISIBLE + 1` (lanes plus a trailing `+N more`). Finished
224+
lanes (done / failed / cancelled / interrupted) linger for
225+
`AGENTS_PANEL_LINGER_MS` (4s) after `finishedAt`, then drop. Product-host sticky
226+
poll uses
226227
`agentsChromeNeedsSticky` so clocks and linger stay fresh; while sticky is
227228
needed it **does not** call `bridge.syncAgentProgress` — chrome owns the live
228229
clocks.

‎src/subagent/agent-fleet.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,51 @@ describe("list_agents", () => {
921921
expect(parsed.agents[0]!.lifecycle).toBe("pending_init");
922922
gate.resolve({ report: "done" });
923923
});
924+
925+
test("after interrupt_agent wait-status is not running", async () => {
926+
const gate = deferred<RunSubAgentResult>();
927+
const deps = makeDeps(async (params) => {
928+
params.onAgentReady?.({
929+
close: async () => {},
930+
interrupt: () => {},
931+
followup: async () => "",
932+
deliver: () => {},
933+
});
934+
return gate.promise;
935+
});
936+
const spawn = createSpawnAgentTool(deps);
937+
const list = createListAgentsTool({
938+
sessions: deps.sessions,
939+
fleetRecords: deps.fleetRecords,
940+
});
941+
const interrupt = createInterruptAgentTool({
942+
sessions: deps.sessions,
943+
fleetRecords: deps.fleetRecords,
944+
});
945+
const spawned = await callTool(spawn, {
946+
description: "looping",
947+
prompt: "do it",
948+
intent: "explore",
949+
});
950+
const id = spawned.agent_id as string;
951+
await new Promise((resolve) => setTimeout(resolve, 20));
952+
if (interrupt.kind !== "full") throw new Error("expected full tool");
953+
await interrupt.handler(
954+
{ id: "int-list", name: "interrupt_agent", arguments: { target: id } },
955+
new AbortController().signal,
956+
);
957+
if (list.kind !== "full") throw new Error("expected full tool");
958+
const raw = await list.handler(
959+
{ id: "list-int", name: "list_agents", arguments: {} },
960+
new AbortController().signal,
961+
);
962+
const content = typeof raw.content === "string" ? raw.content : JSON.stringify(raw.content);
963+
const parsed = JSON.parse(content) as { agents: { agent_id: string; status: string }[] };
964+
expect(parsed.agents).toHaveLength(1);
965+
expect(parsed.agents[0]!.agent_id).toBe(id);
966+
expect(parsed.agents[0]!.status).not.toBe("running");
967+
gate.resolve({ report: "done", interrupted: true });
968+
});
924969
});
925970

926971
describe("spawn_agent parity with task", () => {

‎src/subagent/session-store.test.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,3 +519,68 @@ describe("CL-6943 reusable worker sessions", () => {
519519
expect(store.get(retained.id)).toBeUndefined();
520520
});
521521
});
522+
523+
describe("interrupt stamps finishedAt once", () => {
524+
test("interruptOne sets finishedAt, keeps status running, and preserves tools", () => {
525+
let t = 1000;
526+
const store = createSubAgentSessionStore({
527+
now: () => t,
528+
createId: () => "s-int",
529+
});
530+
const session = store.start({
531+
description: "looping",
532+
agentId: "explorer",
533+
brief: "b",
534+
retained: true,
535+
});
536+
store.markRunning(session.id);
537+
store.appendEvent(session.id, startCall(1, "call-1", "run_shell"));
538+
store.registerInterrupt(session.id, () => {});
539+
540+
t = 2000;
541+
expect(store.interruptOne(session.id).ok).toBe(true);
542+
const after = store.get(session.id);
543+
expect(after?.status).toBe("running");
544+
expect(after?.lifecycleStatus).toBe("interrupted");
545+
expect(after?.finishedAt).toBe(2000);
546+
expect(after?.outstandingTools).toHaveLength(1);
547+
expect(after?.currentToolName).toBe("run_shell");
548+
549+
t = 3500;
550+
expect(store.interruptOne(session.id).ok).toBe(true);
551+
expect(store.get(session.id)?.finishedAt).toBe(2000);
552+
expect(store.get(session.id)?.status).toBe("running");
553+
expect(store.get(session.id)?.outstandingTools).toHaveLength(1);
554+
});
555+
556+
test("sendInputOne interrupt sets finishedAt once and keeps tools", () => {
557+
let t = 1000;
558+
const store = createSubAgentSessionStore({
559+
now: () => t,
560+
createId: () => "s-send",
561+
});
562+
const session = store.start({
563+
description: "looping",
564+
agentId: "explorer",
565+
brief: "b",
566+
retained: true,
567+
});
568+
store.markRunning(session.id);
569+
store.appendEvent(session.id, startCall(1, "call-1", "run_shell"));
570+
store.registerInterrupt(session.id, () => {});
571+
store.registerFollowup(session.id, async () => "later");
572+
573+
t = 2500;
574+
const outcome = store.sendInputOne(session.id, "stop that", { interrupt: true });
575+
expect(outcome).toEqual({ ok: true, status: "interrupted" });
576+
const after = store.get(session.id);
577+
expect(after?.status).toBe("running");
578+
expect(after?.lifecycleStatus).toBe("interrupted");
579+
expect(after?.finishedAt).toBe(2500);
580+
expect(after?.outstandingTools).toHaveLength(1);
581+
582+
t = 4000;
583+
expect(store.interruptOne(session.id).ok).toBe(true);
584+
expect(store.get(session.id)?.finishedAt).toBe(2500);
585+
});
586+
});

‎src/subagent/session-store.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ export interface SubAgentSession {
7979
// start/end, a status change). Distinct from startedAt so the strip can
8080
// tell a worker mid-turn from one that has gone silent.
8181
lastActivityAt: number;
82+
// Clock the live turn ended (complete/fail/cancel, and interrupt while TUI
83+
// status may still be "running"). Drives chrome linger; leftover tools may
84+
// still be outstanding after this stamp.
8285
finishedAt?: number;
8386
report?: string;
8487
error?: string;
@@ -974,6 +977,7 @@ export function createSubAgentSessionStore(
974977
interrupt();
975978
mutate(id, (s) => {
976979
s.lifecycleStatus = "interrupted";
980+
s.finishedAt = s.finishedAt ?? now();
977981
});
978982
void followup(message)
979983
.then((reply) => {
@@ -1014,6 +1018,7 @@ export function createSubAgentSessionStore(
10141018
interrupt();
10151019
mutate(id, (s) => {
10161020
s.lifecycleStatus = "interrupted";
1021+
s.finishedAt = s.finishedAt ?? now();
10171022
});
10181023
pruneRetained();
10191024
return { ok: true };

‎src/tui/agent-progress.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, test } from "bun:test";
22
import {
3+
agentLaneIsLive,
34
agentProgress,
45
clockLabel,
56
fleetLabel,
@@ -218,6 +219,32 @@ describe("fleetProgress", () => {
218219
stalled: 0,
219220
});
220221
});
222+
223+
test("does not count interrupted running leftover tools", () => {
224+
const fleet = fleetProgress(
225+
[
226+
lane({ lastActivityAt: 59_000 }),
227+
lane({
228+
lifecycleStatus: "interrupted",
229+
currentToolName: "run_shell",
230+
currentToolStartedAt: 0,
231+
lastActivityAt: 0,
232+
}),
233+
],
234+
60_000,
235+
30_000,
236+
);
237+
expect(fleet).toEqual({ running: 1, working: 1, inTool: 0, stalled: 0 });
238+
});
239+
});
240+
241+
describe("agentLaneIsLive", () => {
242+
test("running without lifecycleStatus stays live; interrupted is not", () => {
243+
expect(agentLaneIsLive({ status: "running" })).toBe(true);
244+
expect(agentLaneIsLive({ status: "running", lifecycleStatus: "running" })).toBe(true);
245+
expect(agentLaneIsLive({ status: "running", lifecycleStatus: "interrupted" })).toBe(false);
246+
expect(agentLaneIsLive({ status: "done" })).toBe(false);
247+
});
221248
});
222249

223250
describe("fleetLabel", () => {

‎src/tui/agent-progress.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,18 @@ export function laneState(
120120
return "stalled";
121121
}
122122

123+
/**
124+
* Live on the agents strip: TUI status is still "running" and the reusable
125+
* lifecycle has not been interrupted. Missing lifecycleStatus stays live.
126+
* Interrupted leftovers may still have in-flight tools; they are not live lanes.
127+
*/
128+
export function agentLaneIsLive(session: {
129+
readonly status: AgentProgressSession["status"];
130+
readonly lifecycleStatus?: AgentProgressSession["lifecycleStatus"] | undefined;
131+
}): boolean {
132+
return session.status === "running" && session.lifecycleStatus !== "interrupted";
133+
}
134+
123135
/**
124136
* Progress for a running session's pending row, or null once it has finished —
125137
* a terminal session resolves its row through the tool-result path instead.
@@ -200,7 +212,7 @@ export function fleetProgress(
200212
let inTool = 0;
201213
let stalled = 0;
202214
for (const session of sessions) {
203-
if (session.status !== "running") continue;
215+
if (!agentLaneIsLive(session)) continue;
204216
switch (laneState(session, nowMs, stallMs)) {
205217
case "working":
206218
working += 1;

‎src/tui/chrome-state.test.ts‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,51 @@ describe("agentsChromeNeedsSticky / linger", () => {
173173
expect(agentsChromeNeedsSticky(undefined, NOW)).toBe(false);
174174
expect(agentsChromeNeedsSticky([], NOW)).toBe(false);
175175
});
176+
177+
test("interrupted running inside linger is sticky with interrupted tail, not cancelled or cream-live", () => {
178+
const session = {
179+
agentId: "a",
180+
description: "looping",
181+
status: "running" as const,
182+
lifecycleStatus: "interrupted" as const,
183+
currentToolName: "run_shell",
184+
currentToolPreview: "bun test",
185+
currentToolStartedAt: NOW - 2_000,
186+
startedAt: NOW - 10_000,
187+
lastActivityAt: NOW - 2_000,
188+
finishedAt: NOW - 1_000,
189+
};
190+
expect(agentIsLingering(session, NOW)).toBe(true);
191+
expect(agentsChromeNeedsSticky([session], NOW)).toBe(true);
192+
const rows = formatAgentsPanel([session], undefined, NOW);
193+
expect(rows).toEqual([
194+
{
195+
label: "● a looping",
196+
tail: " · interrupted · bun test still running",
197+
stalled: false,
198+
kind: "lane",
199+
status: "interrupted",
200+
},
201+
]);
202+
});
203+
204+
test("interrupted running past linger drops even though status stays running", () => {
205+
const session = {
206+
agentId: "a",
207+
description: "looping",
208+
status: "running" as const,
209+
lifecycleStatus: "interrupted" as const,
210+
currentToolName: "run_shell",
211+
currentToolPreview: "bun test",
212+
currentToolStartedAt: NOW - 2_000,
213+
startedAt: NOW - 10_000,
214+
lastActivityAt: NOW - 2_000,
215+
finishedAt: NOW - AGENTS_PANEL_LINGER_MS,
216+
};
217+
expect(agentIsLingering(session, NOW)).toBe(false);
218+
expect(agentsChromeNeedsSticky([session], NOW)).toBe(false);
219+
expect(formatAgentsPanel([session], undefined, NOW)).toBeNull();
220+
});
176221
});
177222

178223
describe("formatTasksPanel", () => {
@@ -325,6 +370,69 @@ describe("formatAgentsPanel", () => {
325370
).toBeNull();
326371
});
327372

373+
test("interrupted linger ranks with terminals, newest finishedAt first", () => {
374+
const rows = formatAgentsPanel(
375+
[
376+
{
377+
agentId: "live",
378+
description: "still going",
379+
status: "running",
380+
currentToolStartedAt: null,
381+
startedAt: NOW - 1_000,
382+
lastActivityAt: NOW,
383+
},
384+
{
385+
agentId: "done",
386+
description: "finished",
387+
status: "done",
388+
currentToolStartedAt: null,
389+
finishedAt: NOW - 2_000,
390+
},
391+
{
392+
agentId: "stopped",
393+
description: "cut short",
394+
status: "running",
395+
lifecycleStatus: "interrupted",
396+
currentToolStartedAt: null,
397+
startedAt: NOW - 5_000,
398+
lastActivityAt: NOW - 500,
399+
finishedAt: NOW - 500,
400+
},
401+
],
402+
undefined,
403+
NOW,
404+
);
405+
expect(rows?.map((r) => r.status)).toEqual(["running", "interrupted", "done"]);
406+
expect(rows?.[1]?.tail).toBe(" · interrupted");
407+
});
408+
409+
test("cancelled linger with lifecycle interrupted paints cancelled, not interrupted", () => {
410+
const session = {
411+
agentId: "a",
412+
description: "cut short",
413+
status: "cancelled" as const,
414+
lifecycleStatus: "interrupted" as const,
415+
currentToolName: "run_shell",
416+
currentToolPreview: "bun test",
417+
currentToolStartedAt: NOW - 2_000,
418+
startedAt: NOW - 10_000,
419+
lastActivityAt: NOW - 2_000,
420+
finishedAt: NOW - 1_000,
421+
};
422+
expect(agentIsLingering(session, NOW)).toBe(true);
423+
expect(agentsChromeNeedsSticky([session], NOW)).toBe(true);
424+
const rows = formatAgentsPanel([session], undefined, NOW);
425+
expect(rows).toEqual([
426+
{
427+
label: "● a cut short",
428+
tail: " · cancelled",
429+
stalled: false,
430+
kind: "lane",
431+
status: "cancelled",
432+
},
433+
]);
434+
});
435+
328436
test("a stalled lane uses ! marker and reports silence via the clock", () => {
329437
const rows = formatAgentsPanel(
330438
[

0 commit comments

Comments
 (0)