Skip to content

Commit c7fc429

Browse files
Make subagent follow-up lifecycle race safe (#910)
1 parent 6a727e7 commit c7fc429

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
@@ -1756,13 +1756,16 @@ describe("interrupt_agent unblocks wait_agents", () => {
17561756

17571757
test("close overlay without in-flight wait stays interrupted after followup complete", async () => {
17581758
const gate = deferred<RunSubAgentResult>();
1759-
const followupGate = deferred<string>();
17601759
const closeHold = deferred<undefined>();
1760+
const followupCalls: string[] = [];
17611761
const deps = makeDeps(async (params) => {
17621762
params.onAgentReady?.({
17631763
close: async () => closeHold.promise,
17641764
interrupt: () => undefined,
1765-
followup: async () => followupGate.promise,
1765+
followup: async (message: string) => {
1766+
followupCalls.push(message);
1767+
return "should never run";
1768+
},
17661769
deliver: () => undefined,
17671770
});
17681771
return gate.promise;
@@ -1806,25 +1809,16 @@ describe("interrupt_agent unblocks wait_agents", () => {
18061809
new AbortController().signal,
18071810
);
18081811

1809-
followupGate.resolve("followup after close overlay");
1810-
await new Promise<void>((resolve) => {
1811-
const done = (): boolean =>
1812-
deps.sessions.get(id)?.lifecycle.state === "completed";
1813-
if (done()) {
1814-
resolve();
1815-
return;
1816-
}
1817-
const unsub = deps.sessions.subscribe(() => {
1818-
if (done()) {
1819-
unsub();
1820-
resolve();
1821-
}
1822-
});
1823-
if (done()) {
1824-
unsub();
1825-
resolve();
1826-
}
1827-
});
1812+
closeHold.resolve(undefined);
1813+
await closing;
1814+
// CL-7344: close drops the stashed follow-up — it never runs against
1815+
// the closed agent, and the close overlay survives the run settling.
1816+
expect(followupCalls).toEqual([]);
1817+
gate.resolve({
1818+
report: "original interrupted",
1819+
interrupted: true,
1820+
} as RunSubAgentResult);
1821+
await new Promise((resolve) => setTimeout(resolve, 20));
18281822

18291823
expect(deps.fleetRecords.peek(id)?.status).toBe("interrupted");
18301824
const listed = await callTool(list, {});
@@ -1837,13 +1831,6 @@ describe("interrupt_agent unblocks wait_agents", () => {
18371831
expect(waited.timed_out).toBe(false);
18381832
const results = waited.results as { status: string }[];
18391833
expect(defined(results[0]).status).toBe("interrupted");
1840-
1841-
closeHold.resolve(undefined);
1842-
await closing;
1843-
gate.resolve({
1844-
report: "original interrupted",
1845-
interrupted: true,
1846-
} as RunSubAgentResult);
18471834
});
18481835

18491836
test("completeAfterInterrupt does not clear a close overlay", () => {
@@ -1896,14 +1883,17 @@ describe("interrupt_agent unblocks wait_agents", () => {
18961883
message: "stop that",
18971884
interrupt: true,
18981885
});
1899-
followupGate.reject(new Error("followup failed"));
1900-
await new Promise((resolve) => setTimeout(resolve, 20));
1886+
// CL-7344: the follow-up is stashed until the original run settles; the
1887+
// salvage handoff launches it, and its rejection restores the
1888+
// interrupted lane so wait collects the salvage.
19011889
gate.resolve({
19021890
report:
19031891
"## Summary\nStopped.\n## Findings\nsalvage\n## Blockers\ninterrupted\n## Paths\n",
19041892
interrupted: true,
19051893
} as RunSubAgentResult);
19061894
await new Promise((resolve) => setTimeout(resolve, 20));
1895+
followupGate.reject(new Error("followup failed"));
1896+
await new Promise((resolve) => setTimeout(resolve, 20));
19071897

19081898
const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 });
19091899
expect(waited.timed_out).toBe(false);
@@ -1912,21 +1902,14 @@ describe("interrupt_agent unblocks wait_agents", () => {
19121902
expect(defined(results[0]).report).toContain("salvage");
19131903
});
19141904

1915-
test("send_input interrupt queued overlay clears when the followup is admitted", async () => {
1916-
const admission = createAdmissionQueue({ capacity: 1 });
1917-
const sessions = createSubAgentSessionStore({ admission });
1905+
test("send_input interrupt on a live lane stashes the followup until the run settles", async () => {
1906+
const sessions = createSubAgentSessionStore();
19181907
const fleetRecords = createFleetMailbox(sessions);
1919-
admission.enqueue({
1920-
id: "holder",
1921-
provider: "p",
1922-
start: () => undefined,
1923-
});
19241908
const worker = sessions.start({
19251909
description: "looping",
19261910
agentId: "explorer",
19271911
brief: "b",
19281912
retained: true,
1929-
provider: "p",
19301913
});
19311914
sessions.markRunning(worker.id);
19321915
fleetRecords.register(worker.id);
@@ -1944,51 +1927,44 @@ describe("interrupt_agent unblocks wait_agents", () => {
19441927
interrupt: true,
19451928
});
19461929
expect(sent.status).toBe("interrupted");
1947-
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("pending_init");
1930+
// CL-7344: the interrupt stashes the follow-up instead of starting it;
1931+
// the lane is still the live run, so the session stays running while
1932+
// wait/list stay live until the run settles and hands off.
1933+
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("running");
19481934

1949-
const queuedWait = await callTool(wait, {
1935+
const liveWait = await callTool(wait, {
19501936
targets: [worker.id],
19511937
timeout_ms: 50,
19521938
});
1953-
expect(queuedWait.timed_out).toBe(true);
1954-
expect(
1955-
defined((queuedWait.results as { status: string }[])[0]).status,
1956-
).toBe("queued");
1957-
const queuedList = await callTool(list, {});
1958-
const queuedEntry = (
1959-
queuedList.agents as {
1939+
expect(liveWait.timed_out).toBe(true);
1940+
expect(defined((liveWait.results as { status: string }[])[0]).status).toBe(
1941+
"running",
1942+
);
1943+
const liveList = await callTool(list, {});
1944+
const liveEntry = (
1945+
liveList.agents as {
19601946
agent_id: string;
19611947
status: string;
19621948
lifecycle: string;
19631949
}[]
19641950
).find((a) => a.agent_id === worker.id);
1965-
expect(queuedEntry?.status).toBe("queued");
1966-
expect(queuedEntry?.lifecycle).toBe("pending_init");
1967-
1968-
admission.release("holder");
1969-
await new Promise((resolve) => setTimeout(resolve, 20));
1951+
expect(liveEntry?.status).toBe("running");
1952+
expect(liveEntry?.lifecycle).toBe("running");
19701953

1954+
// Settling the original run launches the stashed follow-up.
1955+
sessions.attachReport(worker.id, "interrupted salvage", {
1956+
stopReason: "interrupted",
1957+
});
19711958
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("running");
1972-
const runningWait = await callTool(wait, {
1959+
followupGate.resolve("later");
1960+
const done = await callTool(wait, {
19731961
targets: [worker.id],
1974-
timeout_ms: 50,
1962+
timeout_ms: 5000,
19751963
});
1976-
expect(runningWait.timed_out).toBe(true);
1977-
expect(
1978-
defined((runningWait.results as { status: string }[])[0]).status,
1979-
).toBe("running");
1980-
const runningList = await callTool(list, {});
1981-
const runningEntry = (
1982-
runningList.agents as {
1983-
agent_id: string;
1984-
status: string;
1985-
lifecycle: string;
1986-
}[]
1987-
).find((a) => a.agent_id === worker.id);
1988-
expect(runningEntry?.status).toBe("running");
1989-
expect(runningEntry?.lifecycle).toBe("running");
1990-
1991-
followupGate.resolve("later");
1964+
expect(done.timed_out).toBe(false);
1965+
const results = done.results as { status: string; report?: string }[];
1966+
expect(defined(results[0]).status).toBe("done");
1967+
expect(defined(results[0]).report).toBe("later");
19921968
});
19931969

19941970
test("soft-interrupt wait path collects so omitted re-wait does not re-deliver", async () => {
@@ -2164,8 +2140,13 @@ describe("interrupt_agent unblocks wait_agents", () => {
21642140
message: "stop that",
21652141
interrupt: true,
21662142
});
2143+
// CL-7344: the follow-up is stashed until the original run settles; the
2144+
// salvage handoff launches it, so the run must settle first.
2145+
gate.resolve({
2146+
report: "original interrupted",
2147+
interrupted: true,
2148+
} as RunSubAgentResult);
21672149
followupGate.resolve("followup report");
2168-
await new Promise((resolve) => setTimeout(resolve, 20));
21692150
const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 });
21702151
expect(waited.timed_out).toBe(false);
21712152
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.
@@ -1452,19 +1444,17 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
14521444
if (result.interrupted === true) {
14531445
keepWorktreeAlive = true;
14541446
runInterrupted = true;
1455-
// CL-7331: a followup started via send_input interrupt owns
1456-
// this lane now — the settling original turn must not attach
1457-
// salvage over it. The mailbox flag (set by send_input, not
1458-
// by interrupt_agent or a bare settle) identifies that lane;
1459-
// lifecycle alone cannot, since a never-started run and a
1460-
// queued followup both read pending_init.
1461-
if (!deps.fleetRecords.hasLiveFollowup(session.id)) {
1462-
deps.sessions.attachReport(session.id, result.report, {
1463-
...(result.stopReason !== undefined
1464-
? { stopReason: result.stopReason }
1465-
: {}),
1466-
});
1467-
}
1447+
// CL-7344: a followup stashed via send_input interrupt launches
1448+
// from attachReport, so the settling original turn must
1449+
// attach its salvage here — attachReport records the salvage
1450+
// and launches the stashed followup in one atomic step.
1451+
// Skipping the attach would strand the stash and hang
1452+
// wait_agents on a phantom turn.
1453+
deps.sessions.attachReport(session.id, result.report, {
1454+
...(result.stopReason !== undefined
1455+
? { stopReason: result.stopReason }
1456+
: {}),
1457+
});
14681458
return;
14691459
}
14701460
const alreadyCancelled =

0 commit comments

Comments
 (0)