Skip to content

Commit 558bd16

Browse files
committed
fix(subagent): release the followup reply when the lane tombstones
1 parent 9af0e6c commit 558bd16

2 files changed

Lines changed: 80 additions & 3 deletions

File tree

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

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1470,7 +1470,7 @@ describe("interrupt_agent unblocks wait_agents", () => {
14701470
expect(fleetRecords.peek(worker.id)?.status).toBe("interrupted");
14711471
});
14721472

1473-
test("completeAfterInterrupt attaches the followup reply to the interrupted lane", () => {
1473+
test("completeAfterInterrupt attaches the followup reply to the interrupted lane", async () => {
14741474
const sessions = createSubAgentSessionStore();
14751475
const fleetRecords = createFleetMailbox(sessions);
14761476
const worker = sessions.start({
@@ -1488,6 +1488,76 @@ describe("interrupt_agent unblocks wait_agents", () => {
14881488
// passes in must surface to the parent through peek/wait.
14891489
expect(fleetRecords.peek(worker.id)?.status).toBe("interrupted");
14901490
expect(fleetRecords.peek(worker.id)?.report).toBe("followup reply");
1491+
expect(fleetRecords.peek(worker.id)?.collected).toBeUndefined();
1492+
1493+
// wait_agents delivers the reply exactly once; the bodyHanded guard
1494+
// omits it on redelivery so a repeat wait cannot hand the same reply
1495+
// out twice.
1496+
const wait = createWaitAgentsTool({ sessions, fleetRecords });
1497+
const first = await callFleetTool(wait, {
1498+
targets: [worker.id],
1499+
timeout_ms: 1000,
1500+
});
1501+
expect(first.timed_out).toBe(false);
1502+
const firstResults = first.results as {
1503+
agent_id: string;
1504+
status: string;
1505+
report?: string;
1506+
}[];
1507+
expect(defined(firstResults[0])).toEqual({
1508+
agent_id: worker.id,
1509+
status: "interrupted",
1510+
report: "followup reply",
1511+
});
1512+
expect(fleetRecords.peek(worker.id)?.bodyHanded).toBe(true);
1513+
1514+
const second = await callFleetTool(wait, {
1515+
targets: [worker.id],
1516+
timeout_ms: 1000,
1517+
});
1518+
expect(second.timed_out).toBe(false);
1519+
const secondResults = second.results as {
1520+
agent_id: string;
1521+
status: string;
1522+
report?: string;
1523+
}[];
1524+
expect(defined(secondResults[0])).toEqual({
1525+
agent_id: worker.id,
1526+
status: "interrupted",
1527+
});
1528+
});
1529+
1530+
test("tombstone releases a stored followup reply from the lane", () => {
1531+
const sessions = createSubAgentSessionStore();
1532+
const fleetRecords = createFleetMailbox(sessions);
1533+
const worker = sessions.start({
1534+
description: "looping",
1535+
agentId: "explorer",
1536+
brief: "b",
1537+
retained: true,
1538+
});
1539+
sessions.markRunning(worker.id);
1540+
fleetRecords.register(worker.id);
1541+
fleetRecords.noteFollowup(worker.id);
1542+
fleetRecords.interrupt(worker.id);
1543+
fleetRecords.completeAfterInterrupt(worker.id, "followup reply");
1544+
// Dropping the session makes the lane tombstone; tombstone is permanent
1545+
// and the record is kept, so the reply must be released from the
1546+
// overlay instead of lingering behind its hidden projection.
1547+
sessions.clear();
1548+
expect(sessions.get(worker.id)).toBeUndefined();
1549+
const snap = fleetRecords.peek(worker.id);
1550+
expect(snap?.status).toBe("interrupted");
1551+
expect(snap?.tombstoned).toBe(true);
1552+
expect(snap?.report).toBeUndefined();
1553+
// Heap-truth check: the projection gate alone would hide a retained
1554+
// reply; the overlay itself must carry nothing.
1555+
const overlay = (
1556+
fleetRecords as unknown as {
1557+
records: Map<string, { report?: string }>;
1558+
}
1559+
).records.get(worker.id);
1560+
expect(overlay?.report).toBeUndefined();
14911561
});
14921562

14931563
test("rejected send_input followup clears the lane so wait collects interrupted salvage", async () => {

‎src/subagent/agent-fleet.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,10 @@ class FleetMailbox {
446446
overlay.tombstoned = true;
447447
overlay.hint = RECOVERY_HINT;
448448
overlay.frozenStatus = this.projectedStatus(id, overlay);
449+
// Tombstone is permanent, so nothing can consume the stored reply
450+
// later — releasing it here keeps the lane's heap holding nothing
451+
// beyond the projection.
452+
delete overlay.report;
449453
if (overlay.pinHeld === true) {
450454
overlay.pinHeld = false;
451455
this.sessions.unpin(id);
@@ -472,8 +476,8 @@ class FleetMailbox {
472476
...(overlay.bodyHanded === true ? { bodyHanded: true } : {}),
473477
...(overlay.tombstoned === true ? { tombstoned: true } : {}),
474478
...(overlay.hint !== undefined ? { hint: overlay.hint } : {}),
475-
// A tombstone already compacted the payload; the overlay reply goes
476-
// with it so compaction keeps bounding memory.
479+
// Tombstone already released the overlay reply (see above); it is
480+
// hidden here too so a pre-existing tombstone never projects a reply.
477481
...(overlay.tombstoned !== true && overlay.report !== undefined
478482
? { report: overlay.report }
479483
: {}),
@@ -530,6 +534,9 @@ class FleetMailbox {
530534
victim.frozenStatus = this.projectedStatus(victimId, victim);
531535
victim.tombstoned = true;
532536
victim.hint = RECOVERY_HINT;
537+
// Tombstone is permanent, so the stored reply has no later consumer —
538+
// same release as the snapshot tombstone path.
539+
delete victim.report;
533540
if (victim.pinHeld === true) {
534541
victim.pinHeld = false;
535542
this.sessions?.unpin(victimId);

0 commit comments

Comments
 (0)