Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions src/inference-error-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
// 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.",
Expand Down
44 changes: 44 additions & 0 deletions src/subagent/agent-fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -921,6 +923,48 @@ describe("list_agents", () => {
expect(parsed.agents[0]!.lifecycle).toBe("pending_init");
gate.resolve({ report: "done" });
});

test("interrupt_agent leaves the strip after the linger window", async () => {
const gate = deferred<RunSubAgentResult>();
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 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-strip", name: "interrupt_agent", arguments: { target: id } },
new AbortController().signal,
);
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 });
});
});

describe("spawn_agent parity with task", () => {
Expand Down
17 changes: 10 additions & 7 deletions src/subagent/agent-fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,14 +546,15 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
const startedAt = Date.now();
let settlement: Readonly<SubAgentRunSettlement> | undefined;
let endFinalized = false;
let runInterrupted = false;
const finalizeEnd = (setupFailed = false): void => {
if (endFinalized) return;
endFinalized = true;
const terminalSession = deps.sessions.get(session.id);
const status =
terminalSession?.status === "cancelled"
? "cancelled"
: terminalSession?.lifecycleStatus === "interrupted"
: runInterrupted || terminalSession?.lifecycleStatus === "interrupted"
? "interrupted"
: (terminalSession?.status ?? "completed");
captureSubagentEnd(telemetry, {
Expand Down Expand Up @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion src/subagent/lifecycle-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
131 changes: 131 additions & 0 deletions src/subagent/session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -519,3 +521,132 @@ 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 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",
});
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,
() =>
new Promise<string>((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("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(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<string>((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);
});
});
36 changes: 32 additions & 4 deletions src/subagent/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -594,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);
Expand Down Expand Up @@ -972,9 +992,7 @@ export function createSubAgentSessionStore(
return { ok: false, status: session.lifecycleStatus };
}
interrupt();
mutate(id, (s) => {
s.lifecycleStatus = "interrupted";
});
beginFollowupTurn(id);
void followup(message)
.then((reply) => {
const still = sessions.get(id);
Expand All @@ -990,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),
Expand All @@ -1014,6 +1033,7 @@ export function createSubAgentSessionStore(
interrupt();
mutate(id, (s) => {
s.lifecycleStatus = "interrupted";
s.finishedAt = s.finishedAt ?? now();
});
pruneRetained();
return { ok: true };
Expand Down Expand Up @@ -1041,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";
Expand Down
2 changes: 2 additions & 0 deletions src/subagent/spawn-agent-worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading