Skip to content

Commit df55400

Browse files
committed
Freeze pruned mailbox wait status at last known result
A completed worker pruned from the session store was wait-reported as interrupted because snapshot hard-froze missing sessions. Capture the live wait projection and retention tombstone so wait keeps done/failed, and only unknown missing sessions stay interrupted.
1 parent bad370b commit df55400

3 files changed

Lines changed: 108 additions & 9 deletions

File tree

src/subagent/agent-fleet.test.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,33 @@ function makeDeps(
6262
};
6363
}
6464

65+
function waitUntilMailboxTerminal(
66+
mailbox: ReturnType<typeof createFleetMailbox>,
67+
sessions: ReturnType<typeof createSubAgentSessionStore>,
68+
id: string,
69+
): Promise<void> {
70+
return new Promise((resolve) => {
71+
const done = (): boolean => {
72+
const snap = mailbox.peek(id);
73+
return snap !== undefined && snap.status !== "running";
74+
};
75+
if (done()) {
76+
resolve();
77+
return;
78+
}
79+
const unsub = sessions.subscribe(() => {
80+
if (done()) {
81+
unsub();
82+
resolve();
83+
}
84+
});
85+
if (done()) {
86+
unsub();
87+
resolve();
88+
}
89+
});
90+
}
91+
6592
async function callToolRaw(
6693
tool: ReturnType<typeof createSpawnAgentTool> | ReturnType<typeof createWaitAgentsTool>,
6794
args: Record<string, unknown>,
@@ -422,17 +449,29 @@ describe("wait mailbox session tombstone and pin", () => {
422449
maxCompleted: 1,
423450
now: () => ++t,
424451
});
425-
const deps = makeDeps(async () => ({ report: "ok" }), { sessions });
452+
const firstRun = deferred<RunSubAgentResult>();
453+
const secondRun = deferred<RunSubAgentResult>();
454+
let calls = 0;
455+
const deps = makeDeps(
456+
async () => {
457+
calls += 1;
458+
return (calls === 1 ? firstRun : secondRun).promise;
459+
},
460+
{ sessions },
461+
);
426462
const spawn = createSpawnAgentTool(deps);
427463
const wait = createWaitAgentsTool({ sessions, fleetRecords: deps.fleetRecords });
428464
if (spawn.kind !== "full") throw new Error("expected full tool");
429465
const args = { description: "job", prompt: "do it", intent: "explore" };
430466
const signal = new AbortController().signal;
431467

432468
await spawn.handler({ id: "reuse-id", name: "spawn_agent", arguments: args }, signal);
433-
await new Promise((resolve) => setTimeout(resolve, 20));
469+
firstRun.resolve({ report: "ok" });
470+
await callTool(wait, { targets: ["reuse-id"], timeout_ms: 5000 });
471+
434472
await spawn.handler({ id: "reuse-id", name: "spawn_agent", arguments: args }, signal);
435-
await new Promise((resolve) => setTimeout(resolve, 20));
473+
secondRun.resolve({ report: "ok" });
474+
await waitUntilMailboxTerminal(deps.fleetRecords, sessions, "reuse-id");
436475

437476
const extra1 = sessions.start({ description: "flood-1", agentId: "a", brief: "b" });
438477
sessions.complete(extra1.id, "flood-1");
@@ -464,10 +503,19 @@ describe("wait mailbox session tombstone and pin", () => {
464503
sessions.complete(extra2.id, "prune");
465504
expect(sessions.get("reuse")).toBeUndefined();
466505
const snap = mailbox.peek("reuse");
467-
expect(snap?.status).not.toBe("running");
506+
expect(snap?.status).toBe("done");
468507
expect(snap?.tombstoned).toBe(true);
469508
expect(snap?.hint).toContain("read_agent_trace");
470509
});
510+
511+
test("wait on a mailbox member with no session history is interrupted", () => {
512+
const sessions = createSubAgentSessionStore();
513+
const mailbox = createFleetMailbox(sessions);
514+
mailbox.register("ghost");
515+
const snap = mailbox.peek("ghost");
516+
expect(snap?.status).toBe("interrupted");
517+
expect(snap?.tombstoned).toBe(true);
518+
});
471519
});
472520

473521
describe("spawn_agent parentage", () => {

src/subagent/agent-fleet.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ import type { Settings } from "../config/settings.js";
5959
import { resolveEffortForRole } from "../provider/reasoning-effort.js";
6060
import { isCodexProviderName } from "../config/codex-providers.js";
6161
import { buildDispatchBrief, type TaskIntent } from "./report.js";
62-
import { DEFAULT_CANCEL_REASON, type SubAgentSessionStore } from "./session-store.js";
62+
import {
63+
DEFAULT_CANCEL_REASON,
64+
type AgentLifecycleStatus,
65+
type SubAgentSessionStore,
66+
} from "./session-store.js";
6367
import { projectWaitStatus, type WaitJSONStatus } from "./lifecycle.js";
6468
import type {
6569
NestedDispatchDeps,
@@ -103,13 +107,23 @@ interface FleetOverlay {
103107
forceInterrupted?: boolean;
104108
/** Frozen wait status after collect. Later session completed must not resurrect this mailbox. */
105109
frozenStatus?: WaitJSONStatus;
110+
/** Last wait projection seen while the session still existed. */
111+
lastWaitStatus?: WaitJSONStatus;
106112
tombstoned?: boolean;
107113
hint?: string;
108114
}
109115

110116
const RECOVERY_HINT =
111117
"Report evicted to bound fleet memory; recover full detail via read_agent_trace(agent_id).";
112118

119+
function waitStatusFromVerbLifecycle(
120+
status: AgentLifecycleStatus | undefined,
121+
): WaitJSONStatus | undefined {
122+
if (status === "completed") return "done";
123+
if (status === "interrupted" || status === "shutdown") return "interrupted";
124+
return undefined;
125+
}
126+
113127
/** Payload cap: uncollected pinned terminal records still holding a report. */
114128
export const MAX_FLEET_RECORDS = 200;
115129

@@ -124,15 +138,22 @@ class FleetMailbox {
124138

125139
constructor(sessions: SubAgentSessionStore) {
126140
this.sessions = sessions;
127-
sessions?.subscribe(() => this.enforceCap());
141+
sessions?.subscribe(() => {
142+
this.rememberLiveWaitStatuses();
143+
this.enforceCap();
144+
});
128145
}
129146

130147
register(id: string): void {
131148
const existing = this.records.get(id);
132149
// start() drops pinCounts on call-id reuse. Re-pin whenever the overlay
133150
// thought it still held a pin, so wait cannot desync against an empty map.
134151
if (existing?.pinHeld === true) this.sessions.unpin(id);
135-
this.records.set(id, { pinHeld: true });
152+
const wait = this.sessionWaitStatus(id);
153+
this.records.set(id, {
154+
pinHeld: true,
155+
...(wait !== undefined ? { lastWaitStatus: wait } : {}),
156+
});
136157
this.sessions.pin(id);
137158
this.enforceCap();
138159
}
@@ -204,6 +225,17 @@ class FleetMailbox {
204225
return this.snapshot(id);
205226
}
206227

228+
private rememberLiveWaitStatuses(): void {
229+
for (const [id, overlay] of this.records) {
230+
const wait = this.sessionWaitStatus(id);
231+
if (wait !== undefined) overlay.lastWaitStatus = wait;
232+
}
233+
}
234+
235+
private waitStatusFromEvicted(id: string): WaitJSONStatus | undefined {
236+
return waitStatusFromVerbLifecycle(this.sessions.evictedLifecycle(id));
237+
}
238+
207239
private sessionWaitStatus(id: string): WaitJSONStatus | undefined {
208240
const session = this.sessions?.get(id);
209241
if (session === undefined) return undefined;
@@ -213,7 +245,16 @@ class FleetMailbox {
213245
private projectedStatus(id: string, overlay: FleetOverlay): WaitJSONStatus {
214246
if (overlay.frozenStatus !== undefined) return overlay.frozenStatus;
215247
if (overlay.forceInterrupted === true) return "interrupted";
216-
return this.sessionWaitStatus(id) ?? "interrupted";
248+
const live = this.sessionWaitStatus(id);
249+
if (live !== undefined) {
250+
overlay.lastWaitStatus = live;
251+
return live;
252+
}
253+
const last = overlay.lastWaitStatus;
254+
if (last !== undefined && last !== "running") return last;
255+
const evicted = this.waitStatusFromEvicted(id);
256+
if (evicted !== undefined && evicted !== "running") return evicted;
257+
return "interrupted";
217258
}
218259

219260
snapshot(id: string): FleetRecord {
@@ -229,7 +270,7 @@ class FleetMailbox {
229270
) {
230271
overlay.tombstoned = true;
231272
overlay.hint = RECOVERY_HINT;
232-
overlay.frozenStatus = "interrupted";
273+
overlay.frozenStatus = this.projectedStatus(id, overlay);
233274
if (overlay.pinHeld === true) {
234275
overlay.pinHeld = false;
235276
this.sessions.unpin(id);

src/subagent/session-store.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ export interface SubAgentSessionStoreOptions {
158158
export interface SubAgentSessionStore {
159159
list(): readonly SubAgentSession[];
160160
get(id: string): SubAgentSession | undefined;
161+
/**
162+
* Verb lifecycle of a session dropped by pruneRetained, if a tombstone remains.
163+
* `get` does not surface these — they exist so wait/resume can recover a
164+
* terminal status instead of treating the id as never-seen.
165+
*/
166+
evictedLifecycle(id: string): AgentLifecycleStatus | undefined;
161167
// Running + recent completed, newest first — surface for the Agents strip.
162168
listForStrip(): readonly SubAgentSession[];
163169
start(input: StartSessionInput): SubAgentSession;
@@ -738,6 +744,10 @@ export function createSubAgentSessionStore(
738744
return session === undefined ? undefined : snapshotOf(session);
739745
},
740746

747+
evictedLifecycle(id: string): AgentLifecycleStatus | undefined {
748+
return evicted.get(id)?.lifecycleStatus;
749+
},
750+
741751
listForStrip(): readonly SubAgentSession[] {
742752
return [...sessions.values()].map(snapshotOf).sort((a, b) => {
743753
// Running first, then by startedAt descending.

0 commit comments

Comments
 (0)