Skip to content

Commit ec3aa69

Browse files
committed
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.
1 parent 1e0e9a3 commit ec3aa69

6 files changed

Lines changed: 128 additions & 34 deletions

File tree

src/subagent/agent-fleet.test.ts

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
} from "./lifecycle-tools.js";
1616
import { createSubAgentSessionStore } from "./session-store.js";
1717
import { createPermissionGate } from "../permission/gate.js";
18+
import { agentLaneIsLive, fleetProgress } from "../tui/agent-progress.js";
19+
import { AGENTS_PANEL_LINGER_MS, formatAgentsPanel } from "../tui/chrome-state.js";
1820
import { forcedStopReport } from "./stop-policy.js";
1921
import type { RunSubAgentParams, RunSubAgentResult } from "./types.js";
2022

@@ -922,7 +924,7 @@ describe("list_agents", () => {
922924
gate.resolve({ report: "done" });
923925
});
924926

925-
test("after interrupt_agent wait-status is not running", async () => {
927+
test("interrupt_agent leaves the strip after the linger window", async () => {
926928
const gate = deferred<RunSubAgentResult>();
927929
const deps = makeDeps(async (params) => {
928930
params.onAgentReady?.({
@@ -934,10 +936,6 @@ describe("list_agents", () => {
934936
return gate.promise;
935937
});
936938
const spawn = createSpawnAgentTool(deps);
937-
const list = createListAgentsTool({
938-
sessions: deps.sessions,
939-
fleetRecords: deps.fleetRecords,
940-
});
941939
const interrupt = createInterruptAgentTool({
942940
sessions: deps.sessions,
943941
fleetRecords: deps.fleetRecords,
@@ -951,19 +949,20 @@ describe("list_agents", () => {
951949
await new Promise((resolve) => setTimeout(resolve, 20));
952950
if (interrupt.kind !== "full") throw new Error("expected full tool");
953951
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: {} },
952+
{ id: "int-strip", name: "interrupt_agent", arguments: { target: id } },
960953
new AbortController().signal,
961954
);
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");
955+
const agents = deps.sessions.list();
956+
const session = agents[0]!;
957+
expect(session.status).toBe("running");
958+
expect(session.lifecycleStatus).toBe("interrupted");
959+
expect(agentLaneIsLive(session)).toBe(false);
960+
const finishedAt = session.finishedAt!;
961+
expect(finishedAt).toBeNumber();
962+
const inside = finishedAt + 1_000;
963+
expect(fleetProgress(agents, inside).running).toBe(0);
964+
expect(formatAgentsPanel(agents, undefined, inside)?.[0]?.status).toBe("interrupted");
965+
expect(formatAgentsPanel(agents, undefined, finishedAt + AGENTS_PANEL_LINGER_MS)).toBeNull();
967966
gate.resolve({ report: "done", interrupted: true });
968967
});
969968
});

src/subagent/agent-fleet.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -546,14 +546,15 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
546546
const startedAt = Date.now();
547547
let settlement: Readonly<SubAgentRunSettlement> | undefined;
548548
let endFinalized = false;
549+
let runInterrupted = false;
549550
const finalizeEnd = (setupFailed = false): void => {
550551
if (endFinalized) return;
551552
endFinalized = true;
552553
const terminalSession = deps.sessions.get(session.id);
553554
const status =
554555
terminalSession?.status === "cancelled"
555556
? "cancelled"
556-
: terminalSession?.lifecycleStatus === "interrupted"
557+
: runInterrupted || terminalSession?.lifecycleStatus === "interrupted"
557558
? "interrupted"
558559
: (terminalSession?.status ?? "completed");
559560
captureSubagentEnd(telemetry, {
@@ -731,14 +732,16 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
731732
deps
732733
.run(params)
733734
.then((result) => {
734-
// interrupt_agent already flipped this session to "interrupted"
735-
// synchronously (session-store.interruptOne) — do not let the
736-
// settling promise's normal bookkeeping overwrite that with a
737-
// "completed" status. Still terminalize fleetRecords so a waiter
738-
// that never saw interrupt_agent (or raced it) cannot hang.
735+
// interrupt_agent / send_input already flipped this session
736+
// synchronously (session-store.interruptOne / sendInputOne) — do not
737+
// let the settling promise's normal bookkeeping overwrite that with
738+
// a "completed" status, and do not re-stamp the interrupt either: a
739+
// follow-up turn may already be live on this lane. Still terminalize
740+
// fleetRecords so a waiter that never saw interrupt_agent (or raced
741+
// it) cannot hang.
739742
if (result.interrupted === true) {
740743
keepWorktreeAlive = true;
741-
deps.sessions.interruptOne(session.id);
744+
runInterrupted = true;
742745
deps.fleetRecords.interrupt(session.id, result.report);
743746
return;
744747
}

src/subagent/lifecycle-tools.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,8 @@ describe("send_input", () => {
330330
expect(result).toEqual({ agent_id: worker.id, status: "interrupted" });
331331
expect(interrupted).toBe(true);
332332
expect(followupStarted).toBe(true);
333-
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("interrupted");
333+
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("running");
334+
expect(sessions.get(worker.id)?.finishedAt).toBeUndefined();
334335

335336
const missing = sessions.start({
336337
description: "no-followup",

src/subagent/session-store.test.ts

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test";
22

33
import { createSubAgentSessionStore } from "./session-store.js";
44
import { forcedStopReport } from "./stop-policy.js";
5+
import { agentLaneIsLive, fleetProgress } from "../tui/agent-progress.js";
6+
import { formatAgentsPanel } from "../tui/chrome-state.js";
57

68
import type { ReactorEmittedEvent } from "@intx/inference";
79

@@ -553,8 +555,9 @@ describe("interrupt stamps finishedAt once", () => {
553555
expect(store.get(session.id)?.outstandingTools).toHaveLength(1);
554556
});
555557

556-
test("sendInputOne interrupt sets finishedAt once and keeps tools", () => {
558+
test("sendInputOne interrupt starts a live follow-up turn and keeps tools", async () => {
557559
let t = 1000;
560+
let finish: (reply: string) => void = () => {};
558561
const store = createSubAgentSessionStore({
559562
now: () => t,
560563
createId: () => "s-send",
@@ -568,19 +571,82 @@ describe("interrupt stamps finishedAt once", () => {
568571
store.markRunning(session.id);
569572
store.appendEvent(session.id, startCall(1, "call-1", "run_shell"));
570573
store.registerInterrupt(session.id, () => {});
571-
store.registerFollowup(session.id, async () => "later");
574+
store.registerFollowup(
575+
session.id,
576+
() =>
577+
new Promise<string>((resolve) => {
578+
finish = resolve;
579+
}),
580+
);
572581

573582
t = 2500;
574583
const outcome = store.sendInputOne(session.id, "stop that", { interrupt: true });
575584
expect(outcome).toEqual({ ok: true, status: "interrupted" });
576585
const after = store.get(session.id);
577586
expect(after?.status).toBe("running");
578-
expect(after?.lifecycleStatus).toBe("interrupted");
579-
expect(after?.finishedAt).toBe(2500);
587+
expect(after?.lifecycleStatus).toBe("running");
588+
expect(after?.finishedAt).toBeUndefined();
580589
expect(after?.outstandingTools).toHaveLength(1);
581590

582591
t = 4000;
583592
expect(store.interruptOne(session.id).ok).toBe(true);
584-
expect(store.get(session.id)?.finishedAt).toBe(2500);
593+
expect(store.get(session.id)?.finishedAt).toBe(4000);
594+
595+
t = 5000;
596+
finish("later");
597+
await new Promise((resolve) => setTimeout(resolve, 0));
598+
expect(store.get(session.id)?.status).toBe("done");
599+
expect(store.get(session.id)?.lifecycleStatus).toBe("completed");
600+
expect(store.get(session.id)?.finishedAt).toBe(5000);
601+
});
602+
603+
test("a follow-up turn keeps the lane live past the linger window until it completes", async () => {
604+
let t = 1000;
605+
let finish: (reply: string) => void = () => {};
606+
const store = createSubAgentSessionStore({
607+
now: () => t,
608+
createId: () => "s-followup",
609+
});
610+
const session = store.start({
611+
description: "looping",
612+
agentId: "explorer",
613+
brief: "b",
614+
retained: true,
615+
});
616+
store.markRunning(session.id);
617+
store.registerInterrupt(session.id, () => {});
618+
store.registerFollowup(
619+
session.id,
620+
() =>
621+
new Promise<string>((resolve) => {
622+
finish = resolve;
623+
}),
624+
);
625+
626+
t = 2000;
627+
expect(store.interruptOne(session.id).ok).toBe(true);
628+
expect(store.get(session.id)?.finishedAt).toBe(2000);
629+
630+
t = 3000;
631+
const pending = store.followupOne(session.id, "keep going");
632+
633+
t = 11_000;
634+
store.appendEvent(session.id, startCall(1, "call-1", "run_shell"));
635+
const live = store.list();
636+
expect(live[0]?.lifecycleStatus).toBe("running");
637+
expect(live[0]?.finishedAt).toBeUndefined();
638+
expect(agentLaneIsLive(live[0]!)).toBe(true);
639+
expect(formatAgentsPanel(live, undefined, t)?.[0]?.status).toBe("running");
640+
expect(fleetProgress(live, t).running).toBe(1);
641+
642+
t = 12_000;
643+
finish("done");
644+
expect(await pending).toEqual({ ok: true, reply: "done" });
645+
const terminal = store.list();
646+
expect(terminal[0]?.status).toBe("done");
647+
expect(terminal[0]?.lifecycleStatus).toBe("completed");
648+
expect(terminal[0]?.finishedAt).toBe(12_000);
649+
expect(agentLaneIsLive(terminal[0]!)).toBe(false);
650+
expect(fleetProgress(terminal, t).running).toBe(0);
585651
});
586652
});

src/subagent/session-store.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,23 @@ export function createSubAgentSessionStore(
597597
notify();
598598
};
599599

600+
// A follow-up turn takes the lane back over: the worker is live again, so
601+
// the interrupt's linger stamp must not outlive the new turn. Completion
602+
// re-stamps through the caller's own mutate; a rejected turn restores the
603+
// addressable state it started from so followup_task can retry.
604+
const beginFollowupTurn = (id: string): void => {
605+
mutate(id, (s) => {
606+
s.lifecycleStatus = "running";
607+
delete s.finishedAt;
608+
});
609+
};
610+
const endFollowupTurn = (id: string, lifecycleStatus: AgentLifecycleStatus): void => {
611+
mutate(id, (s) => {
612+
s.lifecycleStatus = lifecycleStatus;
613+
s.finishedAt = now();
614+
});
615+
};
616+
600617
return {
601618
list(): readonly SubAgentSession[] {
602619
return [...sessions.values()].map(snapshotOf);
@@ -975,10 +992,7 @@ export function createSubAgentSessionStore(
975992
return { ok: false, status: session.lifecycleStatus };
976993
}
977994
interrupt();
978-
mutate(id, (s) => {
979-
s.lifecycleStatus = "interrupted";
980-
s.finishedAt = s.finishedAt ?? now();
981-
});
995+
beginFollowupTurn(id);
982996
void followup(message)
983997
.then((reply) => {
984998
const still = sessions.get(id);
@@ -994,6 +1008,7 @@ export function createSubAgentSessionStore(
9941008
pruneRetained();
9951009
})
9961010
.catch((err: unknown) => {
1011+
endFollowupTurn(id, "interrupted");
9971012
log.error("send_input followup failed for {id}: {error}", {
9981013
id,
9991014
error: err instanceof Error ? err.message : String(err),
@@ -1046,7 +1061,15 @@ export function createSubAgentSessionStore(
10461061
}
10471062
const followup = followupHandles.get(id);
10481063
if (followup === undefined) return { ok: false, status: session.lifecycleStatus };
1049-
const reply = await followup(message);
1064+
const priorLifecycle = session.lifecycleStatus;
1065+
beginFollowupTurn(id);
1066+
let reply: string;
1067+
try {
1068+
reply = await followup(message);
1069+
} catch (err) {
1070+
endFollowupTurn(id, priorLifecycle);
1071+
throw err;
1072+
}
10501073
mutate(id, (s) => {
10511074
s.status = "done";
10521075
s.lifecycleStatus = "completed";

src/subagent/spawn-agent-worktree.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,8 @@ describe("spawn_agent worktree isolation", () => {
347347
const content = typeof spawned.content === "string" ? spawned.content : "";
348348
const agentId = (JSON.parse(content) as { agent_id: string }).agent_id;
349349

350+
await waitFor(() => sessions.get(agentId)?.lifecycleStatus === "running");
351+
expect(sessions.interruptOne(agentId).ok).toBe(true);
350352
settle.resolve({
351353
report: "## Summary\nStopped.\n## Findings\npartial\n## Blockers\ninterrupted\n## Paths\n",
352354
stopReason: "cancelled",

0 commit comments

Comments
 (0)