Skip to content

Commit 663e22b

Browse files
committed
Attach late salvage after early interrupt collect
interrupt_agent terminalizes the wait mailbox with no report. If wait_agents collects that empty interrupt before the run settles, the later salvage was dropped because collected was already true. Attach a missing report on interrupted records regardless of collect.
1 parent 3213963 commit 663e22b

2 files changed

Lines changed: 61 additions & 3 deletions

File tree

src/subagent/agent-fleet.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,58 @@ describe("interrupt_agent unblocks wait_agents", () => {
669669
expect(again.timed_out).toBe(false);
670670
expect(again.results).toEqual([]);
671671
});
672+
673+
test("late salvage attaches after wait collected an early interrupt", async () => {
674+
const settle = deferred<RunSubAgentResult>();
675+
const deps = makeDeps(async (params) => {
676+
params.onAgentReady?.({
677+
close: async () => {},
678+
interrupt: () => {},
679+
followup: async () => "",
680+
});
681+
return settle.promise;
682+
});
683+
const spawn = createSpawnAgentTool(deps);
684+
const wait = createWaitAgentsTool({
685+
sessions: deps.sessions,
686+
fleetRecords: deps.fleetRecords,
687+
});
688+
const interrupt = createInterruptAgentTool({
689+
sessions: deps.sessions,
690+
fleetRecords: deps.fleetRecords,
691+
});
692+
693+
const spawned = await callTool(spawn, {
694+
description: "looping",
695+
prompt: "do it",
696+
intent: "explore",
697+
});
698+
const id = spawned.agent_id as string;
699+
700+
// Let onAgentReady register interrupt before we call interrupt_agent.
701+
await new Promise((resolve) => setTimeout(resolve, 20));
702+
703+
if (interrupt.kind !== "full") throw new Error("expected full tool");
704+
await interrupt.handler(
705+
{ id: "int-1", name: "interrupt_agent", arguments: { target: id } },
706+
new AbortController().signal,
707+
);
708+
709+
const early = await callTool(wait, { targets: [id], timeout_ms: 5000 });
710+
expect((early.results as { status: string }[])[0]!.status).toBe("interrupted");
711+
expect((early.results as { report?: string }[])[0]!.report).toBeUndefined();
712+
713+
settle.resolve({
714+
report: "## Summary\nStopped.\n## Findings\nsalvage\n## Blockers\ninterrupted\n## Paths\n",
715+
interrupted: true,
716+
});
717+
await new Promise((resolve) => setTimeout(resolve, 20));
718+
719+
const again = await callTool(wait, { targets: [id], timeout_ms: 5000 });
720+
const results = again.results as { status: string; report?: string }[];
721+
expect(results[0]!.status).toBe("interrupted");
722+
expect(results[0]!.report).toContain("salvage");
723+
});
672724
});
673725

674726
describe("close_agent unblocks wait_agents", () => {

src/subagent/agent-fleet.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,13 +131,19 @@ class FleetRecords {
131131

132132
/**
133133
* Marks a still-running record interrupted so wait_agents unblocks.
134-
* No-op on an already-terminal id — interrupt must not clobber a collected
135-
* report, and a late interrupt after complete/fail is meaningless.
134+
* No-op on an already-terminal id that is not interrupted — a late
135+
* interrupt after complete/fail is meaningless. A late salvage report may
136+
* still attach to an interrupted record that has none yet (including after
137+
* an early collect), but never overwrites an existing report.
136138
*/
137139
interrupt(id: string, report?: string): void {
138140
const existing = this.records.get(id);
139141
if (existing === undefined) return;
140-
if (existing.status === "interrupted" && existing.collected !== true && report !== undefined) {
142+
if (
143+
existing.status === "interrupted" &&
144+
report !== undefined &&
145+
existing.report === undefined
146+
) {
141147
existing.report = report;
142148
this.notify();
143149
return;

0 commit comments

Comments
 (0)