From 1321691bf0fac3fef0adf82c63410435ae4ca68a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 09:14:30 -0700 Subject: [PATCH 1/3] Drop interrupted workers from live agents --- CHANGELOG.md | 5 ++ docs/TUI.md | 7 +- src/subagent/agent-fleet.test.ts | 45 ++++++++++++ src/subagent/session-store.test.ts | 65 +++++++++++++++++ src/subagent/session-store.ts | 5 ++ src/tui/agent-progress.test.ts | 27 ++++++++ src/tui/agent-progress.ts | 14 +++- src/tui/chrome-state.test.ts | 108 +++++++++++++++++++++++++++++ src/tui/chrome-state.ts | 65 +++++++++++------ src/tui/shell.ts | 2 +- 10 files changed, 318 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cab50858..b94ec577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename the operator answers. - The `full_shell` overlay mode is removed; every overlay is inset. +### Fixed + +- Interrupted workers linger on the agents strip for 4s then drop, instead of + staying in the live list while leftover tools finish. + ## [0.3.7] - 2026-08-27 ### Fixed diff --git a/docs/TUI.md b/docs/TUI.md index d9ea6ae2..70dd1db9 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -220,9 +220,10 @@ status / current tool) — Amp/Codex-style lanes without a FLEET header board: `formatChromeZones` → `formatAgentsPanel` owns that paint. Geometry stays stack-only (`layoutMode: "stack"`, `railWidth: 0`); the zone max is -`AGENTS_PANEL_MAX_VISIBLE + 1` (lanes plus a trailing `+N more`). Terminal -lanes (done / failed / cancelled) linger for `AGENTS_PANEL_LINGER_MS` (4s) -after `finishedAt`, then drop. Product-host sticky poll uses +`AGENTS_PANEL_MAX_VISIBLE + 1` (lanes plus a trailing `+N more`). Finished +lanes (done / failed / cancelled / interrupted) linger for +`AGENTS_PANEL_LINGER_MS` (4s) after `finishedAt`, then drop. Product-host sticky +poll uses `agentsChromeNeedsSticky` so clocks and linger stay fresh; while sticky is needed it **does not** call `bridge.syncAgentProgress` — chrome owns the live clocks. diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index 6acd0fb7..cde8f095 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -921,6 +921,51 @@ describe("list_agents", () => { expect(parsed.agents[0]!.lifecycle).toBe("pending_init"); gate.resolve({ report: "done" }); }); + + test("after interrupt_agent wait-status is not running", 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 list = createListAgentsTool({ + 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; + await new Promise((resolve) => setTimeout(resolve, 20)); + if (interrupt.kind !== "full") throw new Error("expected full tool"); + await interrupt.handler( + { id: "int-list", 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-int", 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 }[] }; + expect(parsed.agents).toHaveLength(1); + expect(parsed.agents[0]!.agent_id).toBe(id); + expect(parsed.agents[0]!.status).not.toBe("running"); + gate.resolve({ report: "done", interrupted: true }); + }); }); describe("spawn_agent parity with task", () => { diff --git a/src/subagent/session-store.test.ts b/src/subagent/session-store.test.ts index a05e1bdd..115bdc3b 100644 --- a/src/subagent/session-store.test.ts +++ b/src/subagent/session-store.test.ts @@ -519,3 +519,68 @@ describe("CL-6943 reusable worker sessions", () => { expect(store.get(retained.id)).toBeUndefined(); }); }); + +describe("interrupt stamps finishedAt once", () => { + test("interruptOne sets finishedAt, keeps status running, and preserves tools", () => { + let t = 1000; + const store = createSubAgentSessionStore({ + now: () => t, + createId: () => "s-int", + }); + const session = store.start({ + description: "looping", + agentId: "explorer", + brief: "b", + retained: true, + }); + store.markRunning(session.id); + store.appendEvent(session.id, startCall(1, "call-1", "run_shell")); + store.registerInterrupt(session.id, () => {}); + + t = 2000; + expect(store.interruptOne(session.id).ok).toBe(true); + const after = store.get(session.id); + expect(after?.status).toBe("running"); + expect(after?.lifecycleStatus).toBe("interrupted"); + expect(after?.finishedAt).toBe(2000); + expect(after?.outstandingTools).toHaveLength(1); + expect(after?.currentToolName).toBe("run_shell"); + + t = 3500; + expect(store.interruptOne(session.id).ok).toBe(true); + expect(store.get(session.id)?.finishedAt).toBe(2000); + expect(store.get(session.id)?.status).toBe("running"); + expect(store.get(session.id)?.outstandingTools).toHaveLength(1); + }); + + test("sendInputOne interrupt sets finishedAt once and keeps tools", () => { + let t = 1000; + const store = createSubAgentSessionStore({ + now: () => t, + createId: () => "s-send", + }); + const session = store.start({ + description: "looping", + agentId: "explorer", + brief: "b", + retained: true, + }); + store.markRunning(session.id); + store.appendEvent(session.id, startCall(1, "call-1", "run_shell")); + store.registerInterrupt(session.id, () => {}); + store.registerFollowup(session.id, async () => "later"); + + t = 2500; + const outcome = store.sendInputOne(session.id, "stop that", { interrupt: true }); + expect(outcome).toEqual({ ok: true, status: "interrupted" }); + const after = store.get(session.id); + expect(after?.status).toBe("running"); + expect(after?.lifecycleStatus).toBe("interrupted"); + expect(after?.finishedAt).toBe(2500); + expect(after?.outstandingTools).toHaveLength(1); + + t = 4000; + expect(store.interruptOne(session.id).ok).toBe(true); + expect(store.get(session.id)?.finishedAt).toBe(2500); + }); +}); diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index 0ad9fe80..735b644b 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -79,6 +79,9 @@ export interface SubAgentSession { // start/end, a status change). Distinct from startedAt so the strip can // tell a worker mid-turn from one that has gone silent. lastActivityAt: number; + // Clock the live turn ended (complete/fail/cancel, and interrupt while TUI + // status may still be "running"). Drives chrome linger; leftover tools may + // still be outstanding after this stamp. finishedAt?: number; report?: string; error?: string; @@ -974,6 +977,7 @@ export function createSubAgentSessionStore( interrupt(); mutate(id, (s) => { s.lifecycleStatus = "interrupted"; + s.finishedAt = s.finishedAt ?? now(); }); void followup(message) .then((reply) => { @@ -1014,6 +1018,7 @@ export function createSubAgentSessionStore( interrupt(); mutate(id, (s) => { s.lifecycleStatus = "interrupted"; + s.finishedAt = s.finishedAt ?? now(); }); pruneRetained(); return { ok: true }; diff --git a/src/tui/agent-progress.test.ts b/src/tui/agent-progress.test.ts index 6d282da2..465ea079 100644 --- a/src/tui/agent-progress.test.ts +++ b/src/tui/agent-progress.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + agentLaneIsLive, agentProgress, clockLabel, fleetLabel, @@ -218,6 +219,32 @@ describe("fleetProgress", () => { stalled: 0, }); }); + + test("does not count interrupted running leftover tools", () => { + const fleet = fleetProgress( + [ + lane({ lastActivityAt: 59_000 }), + lane({ + lifecycleStatus: "interrupted", + currentToolName: "run_shell", + currentToolStartedAt: 0, + lastActivityAt: 0, + }), + ], + 60_000, + 30_000, + ); + expect(fleet).toEqual({ running: 1, working: 1, inTool: 0, stalled: 0 }); + }); +}); + +describe("agentLaneIsLive", () => { + test("running without lifecycleStatus stays live; interrupted is not", () => { + expect(agentLaneIsLive({ status: "running" })).toBe(true); + expect(agentLaneIsLive({ status: "running", lifecycleStatus: "running" })).toBe(true); + expect(agentLaneIsLive({ status: "running", lifecycleStatus: "interrupted" })).toBe(false); + expect(agentLaneIsLive({ status: "done" })).toBe(false); + }); }); describe("fleetLabel", () => { diff --git a/src/tui/agent-progress.ts b/src/tui/agent-progress.ts index 2781dc0b..4e5f8df1 100644 --- a/src/tui/agent-progress.ts +++ b/src/tui/agent-progress.ts @@ -120,6 +120,18 @@ export function laneState( return "stalled"; } +/** + * Live on the agents strip: TUI status is still "running" and the reusable + * lifecycle has not been interrupted. Missing lifecycleStatus stays live. + * Interrupted leftovers may still have in-flight tools; they are not live lanes. + */ +export function agentLaneIsLive(session: { + readonly status: AgentProgressSession["status"]; + readonly lifecycleStatus?: AgentProgressSession["lifecycleStatus"] | undefined; +}): boolean { + return session.status === "running" && session.lifecycleStatus !== "interrupted"; +} + /** * Progress for a running session's pending row, or null once it has finished — * a terminal session resolves its row through the tool-result path instead. @@ -200,7 +212,7 @@ export function fleetProgress( let inTool = 0; let stalled = 0; for (const session of sessions) { - if (session.status !== "running") continue; + if (!agentLaneIsLive(session)) continue; switch (laneState(session, nowMs, stallMs)) { case "working": working += 1; diff --git a/src/tui/chrome-state.test.ts b/src/tui/chrome-state.test.ts index 0140cee3..35191c28 100644 --- a/src/tui/chrome-state.test.ts +++ b/src/tui/chrome-state.test.ts @@ -173,6 +173,51 @@ describe("agentsChromeNeedsSticky / linger", () => { expect(agentsChromeNeedsSticky(undefined, NOW)).toBe(false); expect(agentsChromeNeedsSticky([], NOW)).toBe(false); }); + + test("interrupted running inside linger is sticky with interrupted tail, not cancelled or cream-live", () => { + const session = { + agentId: "a", + description: "looping", + status: "running" as const, + lifecycleStatus: "interrupted" as const, + currentToolName: "run_shell", + currentToolPreview: "bun test", + currentToolStartedAt: NOW - 2_000, + startedAt: NOW - 10_000, + lastActivityAt: NOW - 2_000, + finishedAt: NOW - 1_000, + }; + expect(agentIsLingering(session, NOW)).toBe(true); + expect(agentsChromeNeedsSticky([session], NOW)).toBe(true); + const rows = formatAgentsPanel([session], undefined, NOW); + expect(rows).toEqual([ + { + label: "● a looping", + tail: " · interrupted · bun test still running", + stalled: false, + kind: "lane", + status: "interrupted", + }, + ]); + }); + + test("interrupted running past linger drops even though status stays running", () => { + const session = { + agentId: "a", + description: "looping", + status: "running" as const, + lifecycleStatus: "interrupted" as const, + currentToolName: "run_shell", + currentToolPreview: "bun test", + currentToolStartedAt: NOW - 2_000, + startedAt: NOW - 10_000, + lastActivityAt: NOW - 2_000, + finishedAt: NOW - AGENTS_PANEL_LINGER_MS, + }; + expect(agentIsLingering(session, NOW)).toBe(false); + expect(agentsChromeNeedsSticky([session], NOW)).toBe(false); + expect(formatAgentsPanel([session], undefined, NOW)).toBeNull(); + }); }); describe("formatTasksPanel", () => { @@ -325,6 +370,69 @@ describe("formatAgentsPanel", () => { ).toBeNull(); }); + test("interrupted linger ranks with terminals, newest finishedAt first", () => { + const rows = formatAgentsPanel( + [ + { + agentId: "live", + description: "still going", + status: "running", + currentToolStartedAt: null, + startedAt: NOW - 1_000, + lastActivityAt: NOW, + }, + { + agentId: "done", + description: "finished", + status: "done", + currentToolStartedAt: null, + finishedAt: NOW - 2_000, + }, + { + agentId: "stopped", + description: "cut short", + status: "running", + lifecycleStatus: "interrupted", + currentToolStartedAt: null, + startedAt: NOW - 5_000, + lastActivityAt: NOW - 500, + finishedAt: NOW - 500, + }, + ], + undefined, + NOW, + ); + expect(rows?.map((r) => r.status)).toEqual(["running", "interrupted", "done"]); + expect(rows?.[1]?.tail).toBe(" · interrupted"); + }); + + test("cancelled linger with lifecycle interrupted paints cancelled, not interrupted", () => { + const session = { + agentId: "a", + description: "cut short", + status: "cancelled" as const, + lifecycleStatus: "interrupted" as const, + currentToolName: "run_shell", + currentToolPreview: "bun test", + currentToolStartedAt: NOW - 2_000, + startedAt: NOW - 10_000, + lastActivityAt: NOW - 2_000, + finishedAt: NOW - 1_000, + }; + expect(agentIsLingering(session, NOW)).toBe(true); + expect(agentsChromeNeedsSticky([session], NOW)).toBe(true); + const rows = formatAgentsPanel([session], undefined, NOW); + expect(rows).toEqual([ + { + label: "● a cut short", + tail: " · cancelled", + stalled: false, + kind: "lane", + status: "cancelled", + }, + ]); + }); + test("a stalled lane uses ! marker and reports silence via the clock", () => { const rows = formatAgentsPanel( [ diff --git a/src/tui/chrome-state.ts b/src/tui/chrome-state.ts index d5f61b76..af88b07e 100644 --- a/src/tui/chrome-state.ts +++ b/src/tui/chrome-state.ts @@ -28,11 +28,12 @@ * Always pass the full snapshot so absent zones clear (`null` hides the zone). * Partial object fields mean “no data” → that zone line is null, not left * stale. Observe mode can override the agents line via `state.observe`. - * Sticky poll continues while any agent is running or still inside the - * post-terminal linger window (`finishedAt` + `AGENTS_PANEL_LINGER_MS`). + * Sticky poll continues while any agent is live or still inside the + * post-finish linger window (`finishedAt` + `AGENTS_PANEL_LINGER_MS`). */ import { + agentLaneIsLive, agentProgress, laneState, DEFAULT_STALL_MS, @@ -43,9 +44,9 @@ import { AGENTS_PANEL_MAX_VISIBLE, TASKS_PANEL_MAX_VISIBLE } from "./geometry/zo import type { ChromeZoneContent } from "./shell.js"; /** - * How long a terminal agent row (done / failed / cancelled) stays on the strip - * after `finishedAt` before dropping. Mid of the 3–5s hold window so success - * and failure share the same glanceable linger. + * How long a finished agent row (done / failed / cancelled / interrupted) stays + * on the strip after `finishedAt` before dropping. Mid of the 3–5s hold window + * so success, failure, and interrupt share the same glanceable linger. */ export const AGENTS_PANEL_LINGER_MS = 4_000; @@ -69,8 +70,10 @@ export interface ChromeAgentSession { /** Clock the oldest outstanding tool call began; separates a long tool from silence. */ readonly currentToolStartedAt: number | null; /** - * When the worker reached a terminal status. Drives the post-finish linger - * window on the strip (`AGENTS_PANEL_LINGER_MS`); absent → no linger paint. + * When the live turn ended. Drives the post-finish linger window on the strip + * (`AGENTS_PANEL_LINGER_MS`); absent → no linger paint. Set on interrupt while + * TUI `status` may still be `"running"` — the live turn is over even if leftover + * tools keep running. */ readonly finishedAt?: number; } @@ -131,9 +134,10 @@ export interface AgentPanelRow { readonly kind?: "header" | "lane" | "more"; /** * Lane lifecycle for paint tone. Live running uses primary `UI.text`; - * terminal linger uses done/error/dim. Absent ⇒ treat as live running. + * terminal linger uses done/error/dim. Interrupted linger is dim, not cream + * live. Absent ⇒ treat as live running. */ - readonly status?: "running" | "done" | "failed" | "cancelled"; + readonly status?: "running" | "done" | "failed" | "cancelled" | "interrupted"; } /** @@ -178,8 +182,8 @@ export function chromeZonesContent(state: ChromeLiveState): ChromeZoneContent { } /** - * True while the agents strip still needs wall-clock ticks: any running worker, - * or any terminal row still inside the post-finish linger window. Product-host + * True while the agents strip still needs wall-clock ticks: any live worker, + * or any finished row still inside the post-finish linger window. Product-host * sticky poll uses this both to keep clocks/linger fresh and to freeze * transcript `syncAgentProgress` rewrites while chrome owns live status. */ @@ -190,19 +194,19 @@ export function agentsChromeNeedsSticky( ): boolean { if (agents === null || agents === undefined) return false; for (const session of agents) { - if (session.status === "running") return true; + if (agentLaneIsLive(session)) return true; if (agentIsLingering(session, nowMs, lingerMs)) return true; } return false; } -/** Terminal session still inside the glanceable linger window. */ +/** Finished session still inside the glanceable linger window. */ export function agentIsLingering( session: ChromeAgentSession, nowMs: number, lingerMs: number = AGENTS_PANEL_LINGER_MS, ): boolean { - if (session.status === "running") return false; + if (agentLaneIsLive(session)) return false; if (session.finishedAt === undefined) return false; return nowMs - session.finishedAt < lingerMs; } @@ -247,10 +251,10 @@ export function formatTasksPanel( * bounded to `maxVisible` with a trailing "+N more" row. * * No FLEET header — a roll-up board fought the Amp/Codex-style lane list the - * strip is meant to be. Running lanes sort trouble-first via `laneState`; - * terminal sessions linger for `AGENTS_PANEL_LINGER_MS` after `finishedAt` - * (success / fail / cancel share the same window) then drop. Observe mode - * still replaces the whole strip with a single observe row. + * strip is meant to be. Live lanes sort trouble-first via `laneState`; + * finished sessions linger for `AGENTS_PANEL_LINGER_MS` after `finishedAt` + * (success / fail / cancel / interrupt share the same window) then drop. Observe + * mode still replaces the whole strip with a single observe row. */ export function formatAgentsPanel( agents: readonly ChromeAgentSession[] | null | undefined, @@ -265,7 +269,7 @@ export function formatAgentsPanel( if (agents === null || agents === undefined || agents.length === 0) return null; - const running = agents.filter((s) => s.status === "running"); + const running = agents.filter((s) => agentLaneIsLive(s)); const lingering = agents.filter((s) => agentIsLingering(s, nowMs, lingerMs)); if (running.length === 0 && lingering.length === 0) return null; @@ -290,7 +294,11 @@ export function formatAgentsPanel( const ranked: AgentPanelRow[] = [ ...rankedRunning.map(({ session, state }) => formatAgentRow(session, state, nowMs, stallMs)), - ...rankedLingering.map((session) => formatTerminalRow(session)), + ...rankedLingering.map((session) => + session.status === "running" && session.lifecycleStatus === "interrupted" + ? formatInterruptedLingerRow(session, nowMs, stallMs) + : formatTerminalRow(session), + ), ]; const shown = ranked.slice(0, maxVisible); @@ -440,6 +448,23 @@ function formatAgentRow( }; } +function formatInterruptedLingerRow( + session: ChromeAgentSession, + nowMs: number, + stallMs: number, +): AgentPanelRow { + const label = `● ${session.agentId} ${session.description}`.trim(); + const progressSession = toProgressSession(session); + const progress = progressSession !== null ? agentProgress(progressSession, nowMs, stallMs) : null; + return { + label, + tail: progress !== null ? ` · ${progress.stat}` : " · interrupted", + stalled: false, + kind: "lane", + status: "interrupted", + }; +} + function formatTerminalRow(session: ChromeAgentSession): AgentPanelRow { const failed = session.status === "failed"; const marker = failed ? "!" : "●"; diff --git a/src/tui/shell.ts b/src/tui/shell.ts index bd0eeccc..2f71647d 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -4508,7 +4508,7 @@ function agentRowFg(row: AgentPanelRow): string { if (row.kind === "more" || row.kind === "header") return UI.textDim; if (row.stalled || row.status === "failed") return UI.action; if (row.status === "done") return UI.done; - if (row.status === "cancelled") return UI.textDim; + if (row.status === "cancelled" || row.status === "interrupted") return UI.textDim; return UI.text; } From 92f9c00011c0f2742d93a04fa8bdfa1a83658f7c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 12:35:18 -0700 Subject: [PATCH 2/3] Keep follow-up turns live after interrupt instead of re-stamping linger send_input interrupt and followup_task clear finishedAt and set lifecycle to running so the agents strip stays live through the new turn. Settling an interrupted run no longer re-calls interruptOne, which would overwrite a live follow-up's linger stamp. --- src/subagent/agent-fleet.test.ts | 31 +++++---- src/subagent/agent-fleet.ts | 17 ++--- src/subagent/lifecycle-tools.test.ts | 3 +- src/subagent/session-store.test.ts | 76 +++++++++++++++++++++-- src/subagent/session-store.ts | 33 ++++++++-- src/subagent/spawn-agent-worktree.test.ts | 2 + 6 files changed, 128 insertions(+), 34 deletions(-) diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index cde8f095..3652f725 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -15,6 +15,8 @@ import { } from "./lifecycle-tools.js"; import { createSubAgentSessionStore } from "./session-store.js"; import { createPermissionGate } from "../permission/gate.js"; +import { agentLaneIsLive, fleetProgress } from "../tui/agent-progress.js"; +import { AGENTS_PANEL_LINGER_MS, formatAgentsPanel } from "../tui/chrome-state.js"; import { forcedStopReport } from "./stop-policy.js"; import type { RunSubAgentParams, RunSubAgentResult } from "./types.js"; @@ -922,7 +924,7 @@ describe("list_agents", () => { gate.resolve({ report: "done" }); }); - test("after interrupt_agent wait-status is not running", async () => { + test("interrupt_agent leaves the strip after the linger window", async () => { const gate = deferred(); const deps = makeDeps(async (params) => { params.onAgentReady?.({ @@ -934,10 +936,6 @@ describe("list_agents", () => { return gate.promise; }); const spawn = createSpawnAgentTool(deps); - const list = createListAgentsTool({ - sessions: deps.sessions, - fleetRecords: deps.fleetRecords, - }); const interrupt = createInterruptAgentTool({ sessions: deps.sessions, fleetRecords: deps.fleetRecords, @@ -951,19 +949,20 @@ describe("list_agents", () => { await new Promise((resolve) => setTimeout(resolve, 20)); if (interrupt.kind !== "full") throw new Error("expected full tool"); await interrupt.handler( - { id: "int-list", 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-int", name: "list_agents", arguments: {} }, + { id: "int-strip", name: "interrupt_agent", arguments: { target: id } }, 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 }[] }; - expect(parsed.agents).toHaveLength(1); - expect(parsed.agents[0]!.agent_id).toBe(id); - expect(parsed.agents[0]!.status).not.toBe("running"); + const agents = deps.sessions.list(); + const session = agents[0]!; + expect(session.status).toBe("running"); + expect(session.lifecycleStatus).toBe("interrupted"); + expect(agentLaneIsLive(session)).toBe(false); + const finishedAt = session.finishedAt!; + expect(finishedAt).toBeNumber(); + const inside = finishedAt + 1_000; + expect(fleetProgress(agents, inside).running).toBe(0); + expect(formatAgentsPanel(agents, undefined, inside)?.[0]?.status).toBe("interrupted"); + expect(formatAgentsPanel(agents, undefined, finishedAt + AGENTS_PANEL_LINGER_MS)).toBeNull(); gate.resolve({ report: "done", interrupted: true }); }); }); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 820a84d9..b52881b9 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -546,6 +546,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { const startedAt = Date.now(); let settlement: Readonly | undefined; let endFinalized = false; + let runInterrupted = false; const finalizeEnd = (setupFailed = false): void => { if (endFinalized) return; endFinalized = true; @@ -553,7 +554,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { const status = terminalSession?.status === "cancelled" ? "cancelled" - : terminalSession?.lifecycleStatus === "interrupted" + : runInterrupted || terminalSession?.lifecycleStatus === "interrupted" ? "interrupted" : (terminalSession?.status ?? "completed"); captureSubagentEnd(telemetry, { @@ -731,14 +732,16 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { deps .run(params) .then((result) => { - // interrupt_agent already flipped this session to "interrupted" - // synchronously (session-store.interruptOne) — do not let the - // settling promise's normal bookkeeping overwrite that with a - // "completed" status. Still terminalize fleetRecords so a waiter - // that never saw interrupt_agent (or raced it) cannot hang. + // interrupt_agent / send_input already flipped this session + // synchronously (session-store.interruptOne / sendInputOne) — do not + // let the settling promise's normal bookkeeping overwrite that with + // a "completed" status, and do not re-stamp the interrupt either: a + // follow-up turn may already be live on this lane. Still terminalize + // fleetRecords so a waiter that never saw interrupt_agent (or raced + // it) cannot hang. if (result.interrupted === true) { keepWorktreeAlive = true; - deps.sessions.interruptOne(session.id); + runInterrupted = true; deps.fleetRecords.interrupt(session.id, result.report); return; } diff --git a/src/subagent/lifecycle-tools.test.ts b/src/subagent/lifecycle-tools.test.ts index 10ee695b..34f3a088 100644 --- a/src/subagent/lifecycle-tools.test.ts +++ b/src/subagent/lifecycle-tools.test.ts @@ -330,7 +330,8 @@ describe("send_input", () => { expect(result).toEqual({ agent_id: worker.id, status: "interrupted" }); expect(interrupted).toBe(true); expect(followupStarted).toBe(true); - expect(sessions.get(worker.id)?.lifecycleStatus).toBe("interrupted"); + expect(sessions.get(worker.id)?.lifecycleStatus).toBe("running"); + expect(sessions.get(worker.id)?.finishedAt).toBeUndefined(); const missing = sessions.start({ description: "no-followup", diff --git a/src/subagent/session-store.test.ts b/src/subagent/session-store.test.ts index 115bdc3b..bb1675b0 100644 --- a/src/subagent/session-store.test.ts +++ b/src/subagent/session-store.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"; import { createSubAgentSessionStore } from "./session-store.js"; import { forcedStopReport } from "./stop-policy.js"; +import { agentLaneIsLive, fleetProgress } from "../tui/agent-progress.js"; +import { formatAgentsPanel } from "../tui/chrome-state.js"; import type { ReactorEmittedEvent } from "@intx/inference"; @@ -553,8 +555,9 @@ describe("interrupt stamps finishedAt once", () => { expect(store.get(session.id)?.outstandingTools).toHaveLength(1); }); - test("sendInputOne interrupt sets finishedAt once and keeps tools", () => { + test("sendInputOne interrupt starts a live follow-up turn and keeps tools", async () => { let t = 1000; + let finish: (reply: string) => void = () => {}; const store = createSubAgentSessionStore({ now: () => t, createId: () => "s-send", @@ -568,19 +571,82 @@ describe("interrupt stamps finishedAt once", () => { store.markRunning(session.id); store.appendEvent(session.id, startCall(1, "call-1", "run_shell")); store.registerInterrupt(session.id, () => {}); - store.registerFollowup(session.id, async () => "later"); + store.registerFollowup( + session.id, + () => + new Promise((resolve) => { + finish = resolve; + }), + ); t = 2500; const outcome = store.sendInputOne(session.id, "stop that", { interrupt: true }); expect(outcome).toEqual({ ok: true, status: "interrupted" }); const after = store.get(session.id); expect(after?.status).toBe("running"); - expect(after?.lifecycleStatus).toBe("interrupted"); - expect(after?.finishedAt).toBe(2500); + expect(after?.lifecycleStatus).toBe("running"); + expect(after?.finishedAt).toBeUndefined(); expect(after?.outstandingTools).toHaveLength(1); t = 4000; expect(store.interruptOne(session.id).ok).toBe(true); - expect(store.get(session.id)?.finishedAt).toBe(2500); + expect(store.get(session.id)?.finishedAt).toBe(4000); + + t = 5000; + finish("later"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(store.get(session.id)?.status).toBe("done"); + expect(store.get(session.id)?.lifecycleStatus).toBe("completed"); + expect(store.get(session.id)?.finishedAt).toBe(5000); + }); + + test("a follow-up turn keeps the lane live past the linger window until it completes", async () => { + let t = 1000; + let finish: (reply: string) => void = () => {}; + const store = createSubAgentSessionStore({ + now: () => t, + createId: () => "s-followup", + }); + const session = store.start({ + description: "looping", + agentId: "explorer", + brief: "b", + retained: true, + }); + store.markRunning(session.id); + store.registerInterrupt(session.id, () => {}); + store.registerFollowup( + session.id, + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + + t = 2000; + expect(store.interruptOne(session.id).ok).toBe(true); + expect(store.get(session.id)?.finishedAt).toBe(2000); + + t = 3000; + const pending = store.followupOne(session.id, "keep going"); + + t = 11_000; + store.appendEvent(session.id, startCall(1, "call-1", "run_shell")); + const live = store.list(); + expect(live[0]?.lifecycleStatus).toBe("running"); + expect(live[0]?.finishedAt).toBeUndefined(); + expect(agentLaneIsLive(live[0]!)).toBe(true); + expect(formatAgentsPanel(live, undefined, t)?.[0]?.status).toBe("running"); + expect(fleetProgress(live, t).running).toBe(1); + + t = 12_000; + finish("done"); + expect(await pending).toEqual({ ok: true, reply: "done" }); + const terminal = store.list(); + expect(terminal[0]?.status).toBe("done"); + expect(terminal[0]?.lifecycleStatus).toBe("completed"); + expect(terminal[0]?.finishedAt).toBe(12_000); + expect(agentLaneIsLive(terminal[0]!)).toBe(false); + expect(fleetProgress(terminal, t).running).toBe(0); }); }); diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index 735b644b..42a84e88 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -597,6 +597,23 @@ export function createSubAgentSessionStore( notify(); }; + // A follow-up turn takes the lane back over: the worker is live again, so + // the interrupt's linger stamp must not outlive the new turn. Completion + // re-stamps through the caller's own mutate; a rejected turn restores the + // addressable state it started from so followup_task can retry. + const beginFollowupTurn = (id: string): void => { + mutate(id, (s) => { + s.lifecycleStatus = "running"; + delete s.finishedAt; + }); + }; + const endFollowupTurn = (id: string, lifecycleStatus: AgentLifecycleStatus): void => { + mutate(id, (s) => { + s.lifecycleStatus = lifecycleStatus; + s.finishedAt = now(); + }); + }; + return { list(): readonly SubAgentSession[] { return [...sessions.values()].map(snapshotOf); @@ -975,10 +992,7 @@ export function createSubAgentSessionStore( return { ok: false, status: session.lifecycleStatus }; } interrupt(); - mutate(id, (s) => { - s.lifecycleStatus = "interrupted"; - s.finishedAt = s.finishedAt ?? now(); - }); + beginFollowupTurn(id); void followup(message) .then((reply) => { const still = sessions.get(id); @@ -994,6 +1008,7 @@ export function createSubAgentSessionStore( pruneRetained(); }) .catch((err: unknown) => { + endFollowupTurn(id, "interrupted"); log.error("send_input followup failed for {id}: {error}", { id, error: err instanceof Error ? err.message : String(err), @@ -1046,7 +1061,15 @@ export function createSubAgentSessionStore( } const followup = followupHandles.get(id); if (followup === undefined) return { ok: false, status: session.lifecycleStatus }; - const reply = await followup(message); + const priorLifecycle = session.lifecycleStatus; + beginFollowupTurn(id); + let reply: string; + try { + reply = await followup(message); + } catch (err) { + endFollowupTurn(id, priorLifecycle); + throw err; + } mutate(id, (s) => { s.status = "done"; s.lifecycleStatus = "completed"; diff --git a/src/subagent/spawn-agent-worktree.test.ts b/src/subagent/spawn-agent-worktree.test.ts index d97c0573..a94f8f38 100644 --- a/src/subagent/spawn-agent-worktree.test.ts +++ b/src/subagent/spawn-agent-worktree.test.ts @@ -347,6 +347,8 @@ describe("spawn_agent worktree isolation", () => { const content = typeof spawned.content === "string" ? spawned.content : ""; const agentId = (JSON.parse(content) as { agent_id: string }).agent_id; + await waitFor(() => sessions.get(agentId)?.lifecycleStatus === "running"); + expect(sessions.interruptOne(agentId).ok).toBe(true); settle.resolve({ report: "## Summary\nStopped.\n## Findings\npartial\n## Blockers\ninterrupted\n## Paths\n", stopReason: "cancelled", From a31bf68db3a486aa084614a4844808a96e7d57fc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 15:23:25 -0700 Subject: [PATCH 3/3] Keep classified credential errors from being rewritten sendFailureText rematched the #710 credential_failure line against raw-provider auth patterns and replaced it with the generic other copy. #711 tests still expected the pre-#710 session-expired string. --- src/inference-error-message.ts | 6 ++++-- src/tui/runtime-bridge.test.ts | 6 +++--- src/tui/session-chrome.test.ts | 6 ++++++ src/tui/session-chrome.ts | 5 +++++ 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/inference-error-message.ts b/src/inference-error-message.ts index d595372e..3b75b9ea 100644 --- a/src/inference-error-message.ts +++ b/src/inference-error-message.ts @@ -20,9 +20,11 @@ import { type InferenceErrorLike, } from "./inference-gateway-error.js"; +/** Committed auth death — do not claim a refresh is in flight. */ +export const CREDENTIAL_FAILURE_USER_MESSAGE = "Authentication failed — log in again."; + const FRIENDLY_BY_CATEGORY: Record = { - // Committed auth death — do not claim a refresh is in flight. - credential_failure: "Authentication failed — log in again.", + credential_failure: CREDENTIAL_FAILURE_USER_MESSAGE, quota_exhausted: "Quota exhausted — usage limit reached.", context_overflow: "Context window full — compaction could not keep up. Try /clear to start fresh.", diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 47168686..60e4e4ab 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -1024,7 +1024,7 @@ describe("same-turn failover after inference.error", () => { const text = shell.streamLog.map((r) => r.text).join("\n"); expect(text).toContain("next prompt"); - expect(errorRows(shell)).toContain("Session expired — re-authenticating…"); + expect(errorRows(shell)).toContain("Authentication failed — log in again."); } finally { bridge.dispose(); shell.dispose(); @@ -1096,7 +1096,7 @@ describe("same-turn failover after inference.error", () => { const text = shell.streamLog.map((r) => r.text).join("\n"); expect(text).toContain("restart from here"); expect(text).toContain("stop — restarting from your message"); - expect(errorRows(shell)).toContain("Session expired — re-authenticating…"); + expect(errorRows(shell)).toContain("Authentication failed — log in again."); } finally { bridge.dispose(); shell.dispose(); @@ -1129,7 +1129,7 @@ describe("same-turn failover after inference.error", () => { bridge.handle(event); } - expect(errorRows(shell)).toContain("Session expired — re-authenticating…"); + expect(errorRows(shell)).toContain("Authentication failed — log in again."); } finally { bridge.dispose(); shell.dispose(); diff --git a/src/tui/session-chrome.test.ts b/src/tui/session-chrome.test.ts index cfdc9b4f..4d841316 100644 --- a/src/tui/session-chrome.test.ts +++ b/src/tui/session-chrome.test.ts @@ -261,6 +261,12 @@ describe("sendFailureText", () => { authProvider: null, }); }); + + test("a classified credential_failure line is not rewritten as generic other", () => { + expect(sendFailureText("Authentication failed — log in again.")).toBe( + "Authentication failed — log in again.", + ); + }); }); describe("fleet state in the top-level indicator", () => { diff --git a/src/tui/session-chrome.ts b/src/tui/session-chrome.ts index 2ab524e5..239ec98e 100644 --- a/src/tui/session-chrome.ts +++ b/src/tui/session-chrome.ts @@ -5,6 +5,7 @@ * duplicating the state machine that produces it. */ +import { CREDENTIAL_FAILURE_USER_MESSAGE } from "../inference-error-message.js"; import type { Telemetry } from "../telemetry/index.js"; import type { FleetProgress } from "./agent-progress.js"; import type { RampPhase } from "./ramp.js"; @@ -204,6 +205,10 @@ const AUTH_FAILURE_TEXT: Record = { * the only detail the operator has. */ export function sendFailureText(message: string): string { + // Classified inference.error lines are already operator-facing. Rematching + // them against raw-provider auth patterns rewrites intentional copy + // (e.g. "Authentication failed — log in again." → generic other). + if (message === CREDENTIAL_FAILURE_USER_MESSAGE) return message; const failure = classifySendFailureMessage(message); if (failure.kind === "auth" && failure.authProvider !== null) { return AUTH_FAILURE_TEXT[failure.authProvider];