Skip to content

Commit 7c0ea9f

Browse files
committed
Keep wait live after interrupt-with-follow-up
An interrupt overlay freezes wait as interrupted, so a queued follow-up never surfaces even after it completes. Mark the follow-up lane instead and leave wait running until that turn settles.
1 parent 6ea5969 commit 7c0ea9f

3 files changed

Lines changed: 147 additions & 21 deletions

File tree

src/subagent/agent-fleet.test.ts

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { isLiveWaitStatus } from "./lifecycle.js";
1616
import {
1717
createInterruptAgentTool,
1818
createCloseAgentTool,
19+
createResumeAgentTool,
1920
createSendInputTool,
2021
} from "./lifecycle-tools.js";
2122
import { createSubAgentSessionStore } from "./session-store.js";
@@ -984,7 +985,7 @@ describe("interrupt_agent unblocks wait_agents", () => {
984985
gate.resolve({ report: "done" });
985986
});
986987

987-
test("send_input interrupt:true unblocks wait_agents as interrupted", async () => {
988+
test("send_input interrupt:true keeps wait_agents live until the followup completes", async () => {
988989
const gate = deferred<RunSubAgentResult>();
989990
const followupGate = deferred<string>();
990991
const deps = makeDeps(async (params) => {
@@ -1013,11 +1014,92 @@ describe("interrupt_agent unblocks wait_agents", () => {
10131014
const id = spawned.agent_id as string;
10141015
const waiting = callTool(wait, { targets: [id], timeout_ms: 5000 });
10151016
await callTool(sendInput, { target: id, message: "stop that", interrupt: true });
1017+
followupGate.resolve("later");
1018+
gate.resolve({ report: "original interrupted", interrupted: true } as RunSubAgentResult);
10161019
const waited = await waiting;
10171020
expect(waited.timed_out).toBe(false);
1018-
const results = waited.results as { status: string }[];
1019-
expect(results[0]!.status).toBe("interrupted");
1020-
followupGate.resolve("later");
1021+
const results = waited.results as { status: string; report?: string }[];
1022+
expect(results[0]!.status).toBe("done");
1023+
expect(results[0]!.report).toBe("later");
1024+
});
1025+
1026+
test("CL-7331: send_input interrupt keeps wait live until the queued followup completes", async () => {
1027+
const gate = deferred<RunSubAgentResult>();
1028+
const followupGate = deferred<string>();
1029+
const deps = makeDeps(async (params) => {
1030+
params.onAgentReady?.({
1031+
close: async () => {},
1032+
interrupt: () => {},
1033+
followup: async () => followupGate.promise,
1034+
deliver: () => {},
1035+
});
1036+
return gate.promise;
1037+
});
1038+
const spawn = createSpawnAgentTool(deps);
1039+
const wait = createWaitAgentsTool({
1040+
sessions: deps.sessions,
1041+
fleetRecords: deps.fleetRecords,
1042+
});
1043+
const list = createListAgentsTool({
1044+
sessions: deps.sessions,
1045+
fleetRecords: deps.fleetRecords,
1046+
});
1047+
const sendInput = createSendInputTool({
1048+
sessions: deps.sessions,
1049+
fleetRecords: deps.fleetRecords,
1050+
});
1051+
const resume = createResumeAgentTool({
1052+
sessions: deps.sessions,
1053+
fleetRecords: deps.fleetRecords,
1054+
});
1055+
const spawned = await callTool(spawn, {
1056+
description: "looping",
1057+
prompt: "do it",
1058+
intent: "explore",
1059+
});
1060+
const id = spawned.agent_id as string;
1061+
1062+
const sent = await callTool(sendInput, {
1063+
target: id,
1064+
message: "return a concise report",
1065+
interrupt: true,
1066+
});
1067+
expect(sent.status).toBe("interrupted");
1068+
1069+
// The queued followup is still running: wait must stay live (not an
1070+
// immediate terminal interrupted), and list must agree with lifecycle.
1071+
const pending = await callTool(wait, { targets: [id], timeout_ms: 50 });
1072+
expect(pending.timed_out).toBe(true);
1073+
expect((pending.results as { status: string }[])[0]!.status).toBe("running");
1074+
1075+
const listed = await callTool(list, {});
1076+
const entry = (listed.agents as { agent_id: string; status: string; lifecycle: string }[]).find(
1077+
(a) => a.agent_id === id,
1078+
);
1079+
expect(entry?.status).toBe("running");
1080+
expect(entry?.lifecycle).toBe("running");
1081+
1082+
// A resume while the followup is in flight must agree with wait/list.
1083+
if (resume.kind !== "full") throw new Error("expected full tool");
1084+
const resumed = await resume.handler(
1085+
{
1086+
id: "resume-while-followup",
1087+
name: "resume_agent",
1088+
arguments: { target: id, message: "x" },
1089+
},
1090+
new AbortController().signal,
1091+
);
1092+
expect(resumed.isError).toBe(true);
1093+
expect(String(resumed.content)).toContain("status: running");
1094+
1095+
// When the queued followup finishes, its report must surface via wait.
1096+
followupGate.resolve("followup report");
1097+
gate.resolve({ report: "original interrupted", interrupted: true } as RunSubAgentResult);
1098+
const done = await callTool(wait, { targets: [id], timeout_ms: 5000 });
1099+
expect(done.timed_out).toBe(false);
1100+
const doneResults = done.results as { status: string; report?: string }[];
1101+
expect(doneResults[0]!.status).toBe("done");
1102+
expect(doneResults[0]!.report).toBe("followup report");
10211103
});
10221104

10231105
test("soft-interrupt wait path collects so omitted re-wait does not re-deliver", async () => {

src/subagent/agent-fleet.ts

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,11 @@ interface FleetRecord {
118118
interface FleetOverlay {
119119
collected?: boolean;
120120
pinHeld?: boolean;
121-
/** send_input interrupt:true / close_agent — wait interrupted while session may still be running. */
121+
/** interrupt_agent / close_agent — wait interrupted while session may still be running. */
122122
forceInterrupted?: boolean;
123+
/** A send_input interrupt:true followup owns this lane; suppresses any
124+
* terminal overlay so wait stays live until the followup settles. */
125+
followupLive?: boolean;
123126
/** Admission overlay: wait JSON `queued` while run() has not been admitted. */
124127
forceQueued?: boolean;
125128
/** Frozen wait status after collect. Later session completed must not resurrect this mailbox. */
@@ -203,8 +206,8 @@ class FleetMailbox {
203206

204207
/**
205208
* Overlay wait-status override so wait unblocks while the session may still
206-
* be running (send_input interrupt:true followup, close_agent teardown).
207-
* No-op on an already-collected mailbox — frozen status stays interrupted.
209+
* be running (interrupt_agent teardown, close_agent teardown). No-op on an
210+
* already-collected mailbox — frozen status stays interrupted.
208211
*/
209212
interrupt(id: string, _report?: string): void {
210213
const existing = this.records.get(id);
@@ -220,18 +223,47 @@ class FleetMailbox {
220223
}
221224

222225
/**
223-
* send_input interrupt:true followup finished. Clear an uncollected
224-
* interrupted overlay so wait projects session completed → done. No-op if
225-
* wait_agents already collected the interrupt.
226+
* CL-7331: mark that a send_input interrupt:true followup owns this lane.
227+
* Suppresses any interrupt overlay so wait stays live (running/queued)
228+
* until the followup settles, and tells the spawn settlement to swallow
229+
* the original run's interrupted result instead of attaching salvage over
230+
* the live followup. No-op on an unknown id; safe to call on a collected
231+
* mailbox (frozen status still wins for projection, but the settlement
232+
* swallow still applies).
226233
*/
227-
completeAfterInterrupt(id: string, _report?: string): void {
234+
noteFollowup(id: string): void {
228235
const existing = this.records.get(id);
229-
if (existing === undefined || existing.collected === true) return;
230-
if (existing.forceInterrupted !== true) return;
236+
if (existing === undefined) return;
237+
existing.followupLive = true;
231238
delete existing.forceInterrupted;
232239
this.sessions?.wake();
233240
}
234241

242+
/** True while a send_input interrupt:true followup owns this lane. */
243+
hasLiveFollowup(id: string): boolean {
244+
return this.records.get(id)?.followupLive === true;
245+
}
246+
247+
/**
248+
* send_input interrupt:true followup finished. Clear the followup lane flag
249+
* (and any uncollected interrupted overlay) so wait projects session
250+
* completed → done. No-op if wait_agents already collected the interrupt.
251+
*/
252+
completeAfterInterrupt(id: string, _report?: string): void {
253+
const existing = this.records.get(id);
254+
if (existing === undefined || existing.collected === true) return;
255+
let changed = false;
256+
if (existing.followupLive === true) {
257+
delete existing.followupLive;
258+
changed = true;
259+
}
260+
if (existing.forceInterrupted === true) {
261+
delete existing.forceInterrupted;
262+
changed = true;
263+
}
264+
if (changed) this.sessions?.wake();
265+
}
266+
235267
ids(): string[] {
236268
return [...this.records.keys()];
237269
}
@@ -1192,11 +1224,13 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
11921224
if (result.interrupted === true) {
11931225
keepWorktreeAlive = true;
11941226
runInterrupted = true;
1195-
const now = deps.sessions.get(session.id);
1196-
const overlay = deps.fleetRecords.peek(session.id);
1197-
const followupLive =
1198-
now?.lifecycle.state === "running" && overlay?.status === "interrupted";
1199-
if (!followupLive) {
1227+
// CL-7331: a followup started via send_input interrupt owns
1228+
// this lane now — the settling original turn must not attach
1229+
// salvage over it. The mailbox flag (set by send_input, not
1230+
// by interrupt_agent or a bare settle) identifies that lane;
1231+
// lifecycle alone cannot, since a never-started run and a
1232+
// queued followup both read pending_init.
1233+
if (!deps.fleetRecords.hasLiveFollowup(session.id)) {
12001234
deps.sessions.attachReport(session.id, result.report);
12011235
}
12021236
return;

src/subagent/lifecycle-tools.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -331,8 +331,9 @@ export const sendInputToolDefinition: ToolDefinition = {
331331
"worker has a pending ask_director, the message resolves that question (it does not deliver a " +
332332
"steer inbound). Otherwise deliver `message` into the live session and return immediately " +
333333
"without awaiting a reply and without completing wait_agents. " +
334-
"With interrupt:true: stop the current turn (same wait-mailbox flip as interrupt_agent) " +
335-
"then queue `message` as the next-turn followup without awaiting that reply. Fails on a " +
334+
"With interrupt:true: stop the current turn then queue `message` as the next-turn followup " +
335+
"without awaiting that reply — wait_agents stays live (running/queued) and collects the " +
336+
"followup reply when it finishes. Fails on a " +
336337
"session that is not currently running an active turn, or when the message is empty / oversize. Nested " +
337338
"orchestrators may only target their own descendants.",
338339
inputSchema: {
@@ -393,7 +394,16 @@ export function createSendInputTool(deps: LifecycleToolDeps): AgentTool {
393394
`Error: cannot send_input to "${target}" (status: ${outcome.status}).`,
394395
);
395396
}
396-
if (interrupt) deps.fleetRecords?.interrupt(target);
397+
// CL-7331: an interrupt-with-followup is transitional, not terminal.
398+
// interrupt_agent/close_agent flip the wait mailbox so an in-flight
399+
// wait_agents unblocks as interrupted; a queued followup must instead
400+
// stay wait-live (running/queued) so the followup reply surfaces via
401+
// wait_agents instead of freezing as an already-collected interrupt.
402+
if (interrupt && deps.fleetRecords !== undefined) {
403+
deps.fleetRecords.noteFollowup(target);
404+
const after = deps.sessions.get(target);
405+
if (after?.lifecycle.state === "pending_init") deps.fleetRecords.markQueued(target);
406+
}
397407
return lifecycleResult(call.id, JSON.stringify({ agent_id: target, status: outcome.status }));
398408
},
399409
});

0 commit comments

Comments
 (0)