Skip to content

Commit 5a82562

Browse files
committed
Make subagent follow-up lifecycle race safe
1 parent 3391db2 commit 5a82562

6 files changed

Lines changed: 663 additions & 138 deletions

File tree

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

Lines changed: 53 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1502,13 +1502,16 @@ describe("interrupt_agent unblocks wait_agents", () => {
15021502

15031503
test("close overlay without in-flight wait stays interrupted after followup complete", async () => {
15041504
const gate = deferred<RunSubAgentResult>();
1505-
const followupGate = deferred<string>();
15061505
const closeHold = deferred<undefined>();
1506+
const followupCalls: string[] = [];
15071507
const deps = makeDeps(async (params) => {
15081508
params.onAgentReady?.({
15091509
close: async () => closeHold.promise,
15101510
interrupt: () => undefined,
1511-
followup: async () => followupGate.promise,
1511+
followup: async (message: string) => {
1512+
followupCalls.push(message);
1513+
return "should never run";
1514+
},
15121515
deliver: () => undefined,
15131516
});
15141517
return gate.promise;
@@ -1552,25 +1555,16 @@ describe("interrupt_agent unblocks wait_agents", () => {
15521555
new AbortController().signal,
15531556
);
15541557

1555-
followupGate.resolve("followup after close overlay");
1556-
await new Promise<void>((resolve) => {
1557-
const done = (): boolean =>
1558-
deps.sessions.get(id)?.lifecycle.state === "completed";
1559-
if (done()) {
1560-
resolve();
1561-
return;
1562-
}
1563-
const unsub = deps.sessions.subscribe(() => {
1564-
if (done()) {
1565-
unsub();
1566-
resolve();
1567-
}
1568-
});
1569-
if (done()) {
1570-
unsub();
1571-
resolve();
1572-
}
1573-
});
1558+
closeHold.resolve(undefined);
1559+
await closing;
1560+
// CL-7344: close drops the stashed follow-up — it never runs against
1561+
// the closed agent, and the close overlay survives the run settling.
1562+
expect(followupCalls).toEqual([]);
1563+
gate.resolve({
1564+
report: "original interrupted",
1565+
interrupted: true,
1566+
} as RunSubAgentResult);
1567+
await new Promise((resolve) => setTimeout(resolve, 20));
15741568

15751569
expect(deps.fleetRecords.peek(id)?.status).toBe("interrupted");
15761570
const listed = await callTool(list, {});
@@ -1583,13 +1577,6 @@ describe("interrupt_agent unblocks wait_agents", () => {
15831577
expect(waited.timed_out).toBe(false);
15841578
const results = waited.results as { status: string }[];
15851579
expect(defined(results[0]).status).toBe("interrupted");
1586-
1587-
closeHold.resolve(undefined);
1588-
await closing;
1589-
gate.resolve({
1590-
report: "original interrupted",
1591-
interrupted: true,
1592-
} as RunSubAgentResult);
15931580
});
15941581

15951582
test("completeAfterInterrupt does not clear a close overlay", () => {
@@ -1642,14 +1629,17 @@ describe("interrupt_agent unblocks wait_agents", () => {
16421629
message: "stop that",
16431630
interrupt: true,
16441631
});
1645-
followupGate.reject(new Error("followup failed"));
1646-
await new Promise((resolve) => setTimeout(resolve, 20));
1632+
// CL-7344: the follow-up is stashed until the original run settles; the
1633+
// salvage handoff launches it, and its rejection restores the
1634+
// interrupted lane so wait collects the salvage.
16471635
gate.resolve({
16481636
report:
16491637
"## Summary\nStopped.\n## Findings\nsalvage\n## Blockers\ninterrupted\n## Paths\n",
16501638
interrupted: true,
16511639
} as RunSubAgentResult);
16521640
await new Promise((resolve) => setTimeout(resolve, 20));
1641+
followupGate.reject(new Error("followup failed"));
1642+
await new Promise((resolve) => setTimeout(resolve, 20));
16531643

16541644
const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 });
16551645
expect(waited.timed_out).toBe(false);
@@ -1658,21 +1648,14 @@ describe("interrupt_agent unblocks wait_agents", () => {
16581648
expect(defined(results[0]).report).toContain("salvage");
16591649
});
16601650

1661-
test("send_input interrupt queued overlay clears when the followup is admitted", async () => {
1662-
const admission = createAdmissionQueue({ capacity: 1 });
1663-
const sessions = createSubAgentSessionStore({ admission });
1651+
test("send_input interrupt on a live lane stashes the followup until the run settles", async () => {
1652+
const sessions = createSubAgentSessionStore();
16641653
const fleetRecords = createFleetMailbox(sessions);
1665-
admission.enqueue({
1666-
id: "holder",
1667-
provider: "p",
1668-
start: () => undefined,
1669-
});
16701654
const worker = sessions.start({
16711655
description: "looping",
16721656
agentId: "explorer",
16731657
brief: "b",
16741658
retained: true,
1675-
provider: "p",
16761659
});
16771660
sessions.markRunning(worker.id);
16781661
fleetRecords.register(worker.id);
@@ -1690,51 +1673,44 @@ describe("interrupt_agent unblocks wait_agents", () => {
16901673
interrupt: true,
16911674
});
16921675
expect(sent.status).toBe("interrupted");
1693-
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("pending_init");
1676+
// CL-7344: the interrupt stashes the follow-up instead of starting it;
1677+
// the lane is still the live run, so the session stays running while
1678+
// wait/list stay live until the run settles and hands off.
1679+
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("running");
16941680

1695-
const queuedWait = await callTool(wait, {
1681+
const liveWait = await callTool(wait, {
16961682
targets: [worker.id],
16971683
timeout_ms: 50,
16981684
});
1699-
expect(queuedWait.timed_out).toBe(true);
1700-
expect(
1701-
defined((queuedWait.results as { status: string }[])[0]).status,
1702-
).toBe("queued");
1703-
const queuedList = await callTool(list, {});
1704-
const queuedEntry = (
1705-
queuedList.agents as {
1685+
expect(liveWait.timed_out).toBe(true);
1686+
expect(defined((liveWait.results as { status: string }[])[0]).status).toBe(
1687+
"running",
1688+
);
1689+
const liveList = await callTool(list, {});
1690+
const liveEntry = (
1691+
liveList.agents as {
17061692
agent_id: string;
17071693
status: string;
17081694
lifecycle: string;
17091695
}[]
17101696
).find((a) => a.agent_id === worker.id);
1711-
expect(queuedEntry?.status).toBe("queued");
1712-
expect(queuedEntry?.lifecycle).toBe("pending_init");
1713-
1714-
admission.release("holder");
1715-
await new Promise((resolve) => setTimeout(resolve, 20));
1697+
expect(liveEntry?.status).toBe("running");
1698+
expect(liveEntry?.lifecycle).toBe("running");
17161699

1700+
// Settling the original run launches the stashed follow-up.
1701+
sessions.attachReport(worker.id, "interrupted salvage", {
1702+
stopReason: "interrupted",
1703+
});
17171704
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("running");
1718-
const runningWait = await callTool(wait, {
1705+
followupGate.resolve("later");
1706+
const done = await callTool(wait, {
17191707
targets: [worker.id],
1720-
timeout_ms: 50,
1708+
timeout_ms: 5000,
17211709
});
1722-
expect(runningWait.timed_out).toBe(true);
1723-
expect(
1724-
defined((runningWait.results as { status: string }[])[0]).status,
1725-
).toBe("running");
1726-
const runningList = await callTool(list, {});
1727-
const runningEntry = (
1728-
runningList.agents as {
1729-
agent_id: string;
1730-
status: string;
1731-
lifecycle: string;
1732-
}[]
1733-
).find((a) => a.agent_id === worker.id);
1734-
expect(runningEntry?.status).toBe("running");
1735-
expect(runningEntry?.lifecycle).toBe("running");
1736-
1737-
followupGate.resolve("later");
1710+
expect(done.timed_out).toBe(false);
1711+
const results = done.results as { status: string; report?: string }[];
1712+
expect(defined(results[0]).status).toBe("done");
1713+
expect(defined(results[0]).report).toBe("later");
17381714
});
17391715

17401716
test("soft-interrupt wait path collects so omitted re-wait does not re-deliver", async () => {
@@ -1910,8 +1886,13 @@ describe("interrupt_agent unblocks wait_agents", () => {
19101886
message: "stop that",
19111887
interrupt: true,
19121888
});
1889+
// CL-7344: the follow-up is stashed until the original run settles; the
1890+
// salvage handoff launches it, so the run must settle first.
1891+
gate.resolve({
1892+
report: "original interrupted",
1893+
interrupted: true,
1894+
} as RunSubAgentResult);
19131895
followupGate.resolve("followup report");
1914-
await new Promise((resolve) => setTimeout(resolve, 20));
19151896
const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 });
19161897
expect(waited.timed_out).toBe(false);
19171898
const results = waited.results as { status: string; report?: string }[];

‎src/subagent/agent-fleet.ts‎

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -249,11 +249,8 @@ class FleetMailbox {
249249
/**
250250
* CL-7331: mark that a send_input interrupt:true followup owns this lane.
251251
* Suppresses any interrupt overlay so wait stays live (running/queued)
252-
* until the followup settles, and tells the spawn settlement to swallow
253-
* the original run's interrupted result instead of attaching salvage over
254-
* the live followup. No-op on an unknown id; safe to call on a collected
255-
* mailbox (frozen status still wins for projection, but the settlement
256-
* swallow still applies).
252+
* until the followup settles. No-op on an unknown id; safe to call on a
253+
* collected mailbox (frozen status still wins for projection).
257254
*/
258255
noteFollowup(id: string): void {
259256
const existing = this.records.get(id);
@@ -263,11 +260,6 @@ class FleetMailbox {
263260
this.sessions?.wake();
264261
}
265262

266-
/** True while a send_input interrupt:true followup owns this lane. */
267-
hasLiveFollowup(id: string): boolean {
268-
return this.records.get(id)?.followupLive === true;
269-
}
270-
271263
/**
272264
* send_input interrupt:true followup finished. Clear the followup lane flag
273265
* (and any admission queued overlay) so wait can project the settled session.
@@ -1387,19 +1379,17 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
13871379
if (result.interrupted === true) {
13881380
keepWorktreeAlive = true;
13891381
runInterrupted = true;
1390-
// CL-7331: a followup started via send_input interrupt owns
1391-
// this lane now — the settling original turn must not attach
1392-
// salvage over it. The mailbox flag (set by send_input, not
1393-
// by interrupt_agent or a bare settle) identifies that lane;
1394-
// lifecycle alone cannot, since a never-started run and a
1395-
// queued followup both read pending_init.
1396-
if (!deps.fleetRecords.hasLiveFollowup(session.id)) {
1397-
deps.sessions.attachReport(session.id, result.report, {
1398-
...(result.stopReason !== undefined
1399-
? { stopReason: result.stopReason }
1400-
: {}),
1401-
});
1402-
}
1382+
// CL-7344: a followup stashed via send_input interrupt launches
1383+
// from attachReport, so the settling original turn must
1384+
// attach its salvage here — attachReport records the salvage
1385+
// and launches the stashed followup in one atomic step.
1386+
// Skipping the attach would strand the stash and hang
1387+
// wait_agents on a phantom turn.
1388+
deps.sessions.attachReport(session.id, result.report, {
1389+
...(result.stopReason !== undefined
1390+
? { stopReason: result.stopReason }
1391+
: {}),
1392+
});
14031393
return;
14041394
}
14051395
const alreadyCancelled =

0 commit comments

Comments
 (0)