Skip to content

Commit bfdc819

Browse files
Merge pull request #1415 from corbitsdev/cl-10533-completeafterinterrupt-silently-drops-its-report-argument
fix(subagent): attach the send_input followup reply after an interrupt
2 parents ac8892b + 558bd16 commit bfdc819

2 files changed

Lines changed: 114 additions & 2 deletions

File tree

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1470,6 +1470,96 @@ 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", async () => {
1474+
const sessions = createSubAgentSessionStore();
1475+
const fleetRecords = createFleetMailbox(sessions);
1476+
const worker = sessions.start({
1477+
description: "looping",
1478+
agentId: "explorer",
1479+
brief: "b",
1480+
retained: true,
1481+
});
1482+
sessions.markRunning(worker.id);
1483+
fleetRecords.register(worker.id);
1484+
fleetRecords.noteFollowup(worker.id);
1485+
fleetRecords.interrupt(worker.id);
1486+
fleetRecords.completeAfterInterrupt(worker.id, "followup reply");
1487+
// The overlay stays put, but the reply the send_input followup handoff
1488+
// passes in must surface to the parent through peek/wait.
1489+
expect(fleetRecords.peek(worker.id)?.status).toBe("interrupted");
1490+
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();
1561+
});
1562+
14731563
test("rejected send_input followup clears the lane so wait collects interrupted salvage", async () => {
14741564
const gate = deferred<RunSubAgentResult>();
14751565
const followupGate = deferred<string>();

‎src/subagent/agent-fleet.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,10 @@ interface FleetOverlay {
174174
lastWaitStatus?: WaitJSONStatus;
175175
/** Report/error already copied into a wait or occupancy payload. */
176176
bodyHanded?: boolean;
177+
/** Reply handed to completeAfterInterrupt — the settled follow-up's
178+
* reply, kept on the overlay because an interrupt/close overlay can pin
179+
* the projected status so the session payload alone would hide it. */
180+
report?: string;
177181
tombstoned?: boolean;
178182
hint?: string;
179183
/** Mirror of the session's failure record so it survives eviction. */
@@ -354,9 +358,11 @@ class FleetMailbox {
354358
/**
355359
* The interrupt followup finished. Clear the followup and queued overlays
356360
* so wait projects the settled session, but keep a close/interrupt overlay —
357-
* a followup reply must not undo interrupt_agent or close_agent.
361+
* a followup reply must not undo interrupt_agent or close_agent. The reply
362+
* itself lands on the overlay so interrupt/close-pinned lanes still hand it
363+
* to the parent; a session payload report keeps precedence.
358364
*/
359-
completeAfterInterrupt(id: string, _report?: string): void {
365+
completeAfterInterrupt(id: string, report?: string): void {
360366
const existing = this.records.get(id);
361367
if (existing === undefined || existing.collected === true) return;
362368
let changed = false;
@@ -368,6 +374,10 @@ class FleetMailbox {
368374
delete existing.forceQueued;
369375
changed = true;
370376
}
377+
if (report !== undefined && report.length > 0) {
378+
existing.report = report;
379+
changed = true;
380+
}
371381
if (changed) this.sessions?.wake();
372382
}
373383

@@ -478,6 +488,10 @@ class FleetMailbox {
478488
overlay.tombstoned = true;
479489
overlay.hint = RECOVERY_HINT;
480490
overlay.frozenStatus = this.projectedStatus(id, overlay);
491+
// Tombstone is permanent, so nothing can consume the stored reply
492+
// later — releasing it here keeps the lane's heap holding nothing
493+
// beyond the projection.
494+
delete overlay.report;
481495
if (overlay.pinHeld === true) {
482496
overlay.pinHeld = false;
483497
this.sessions.unpin(id);
@@ -504,6 +518,11 @@ class FleetMailbox {
504518
...(overlay.bodyHanded === true ? { bodyHanded: true } : {}),
505519
...(overlay.tombstoned === true ? { tombstoned: true } : {}),
506520
...(overlay.hint !== undefined ? { hint: overlay.hint } : {}),
521+
// Tombstone already released the overlay reply (see above); it is
522+
// hidden here too so a pre-existing tombstone never projects a reply.
523+
...(overlay.tombstoned !== true && overlay.report !== undefined
524+
? { report: overlay.report }
525+
: {}),
507526
...(payload?.report !== undefined ? { report: payload.report } : {}),
508527
...(payload?.error !== undefined && status === "failed"
509528
? { error: payload.error }
@@ -557,6 +576,9 @@ class FleetMailbox {
557576
victim.frozenStatus = this.projectedStatus(victimId, victim);
558577
victim.tombstoned = true;
559578
victim.hint = RECOVERY_HINT;
579+
// Tombstone is permanent, so the stored reply has no later consumer —
580+
// same release as the snapshot tombstone path.
581+
delete victim.report;
560582
if (victim.pinHeld === true) {
561583
victim.pinHeld = false;
562584
this.sessions?.unpin(victimId);

0 commit comments

Comments
 (0)