Skip to content

Commit a2db693

Browse files
committed
Require fleetRecords on interrupt and tighten fleet wait coverage
1 parent bb60de7 commit a2db693

6 files changed

Lines changed: 129 additions & 19 deletions

File tree

src/subagent/agent-fleet.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ describe("spawn_agent + wait_agents", () => {
174174
expect(secondResults[0]!.report).toBe("finished");
175175
});
176176

177-
test("wait_agents with no targets waits on all currently running spawned agents", async () => {
177+
test("wait_agents with no targets waits on all uncollected agents in this fleet", async () => {
178178
const gates = [deferred<RunSubAgentResult>(), deferred<RunSubAgentResult>()];
179179
let callIndex = 0;
180180
const deps = makeDeps(async () => gates[callIndex++]!.promise);
@@ -462,6 +462,64 @@ describe("wait_agents caller scope", () => {
462462
expect(finishedResults.every((r) => r.status === "done")).toBe(true);
463463
});
464464

465+
test("mode=all with one interrupted target stays blocked until siblings finish", async () => {
466+
const gates = [deferred<RunSubAgentResult>(), deferred<RunSubAgentResult>()];
467+
let callIndex = 0;
468+
const deps = makeDeps(async (params) => {
469+
params.onAgentReady?.({
470+
close: async () => {},
471+
interrupt: () => {},
472+
followup: async () => "",
473+
});
474+
return gates[callIndex++]!.promise;
475+
});
476+
const spawn = createSpawnAgentTool(deps);
477+
const wait = createWaitAgentsTool({
478+
sessions: deps.sessions,
479+
fleetRecords: deps.fleetRecords,
480+
});
481+
const interrupt = createInterruptAgentTool({
482+
sessions: deps.sessions,
483+
fleetRecords: deps.fleetRecords,
484+
});
485+
486+
const first = await callTool(spawn, {
487+
description: "a",
488+
prompt: "do it",
489+
intent: "explore",
490+
});
491+
const second = await callTool(spawn, {
492+
description: "b",
493+
prompt: "do it",
494+
intent: "explore",
495+
});
496+
const ids = [first.agent_id as string, second.agent_id as string];
497+
498+
// Interrupt one of N while mode=all is in flight: interrupted is terminal
499+
// for that target, but mode=all must not complete as "all done" while a
500+
// sibling is still running.
501+
if (interrupt.kind !== "full") throw new Error("expected full tool");
502+
await interrupt.handler(
503+
{ id: "int-1", name: "interrupt_agent", arguments: { target: ids[0]! } },
504+
new AbortController().signal,
505+
);
506+
507+
const partial = await callTool(wait, { targets: ids, mode: "all", timeout_ms: 50 });
508+
expect(partial.timed_out).toBe(true);
509+
const partialResults = partial.results as { agent_id: string; status: string }[];
510+
expect(partialResults.find((r) => r.agent_id === ids[0]!)?.status).toBe("interrupted");
511+
expect(partialResults.find((r) => r.agent_id === ids[1]!)?.status).toBe("running");
512+
513+
gates[1]!.resolve({ report: "b done" });
514+
const finished = await callTool(wait, { targets: ids, mode: "all", timeout_ms: 5000 });
515+
expect(finished.timed_out).toBe(false);
516+
const finishedResults = finished.results as { agent_id: string; status: string }[];
517+
expect(finishedResults.find((r) => r.agent_id === ids[0]!)?.status).toBe("interrupted");
518+
expect(finishedResults.find((r) => r.agent_id === ids[1]!)?.status).toBe("done");
519+
// Leave the interrupted gate unresolved — interrupt unblocked the wait
520+
// without the run settling.
521+
});
522+
465523
test("aborting the wait returns without cancelling workers", async () => {
466524
const gate = deferred<RunSubAgentResult>();
467525
const deps = makeDeps(async () => gate.promise);

src/subagent/agent-fleet.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,6 @@ class FleetRecords {
151151
this.notify();
152152
}
153153

154-
ids(): string[] {
155-
return [...this.records.keys()];
156-
}
157-
158154
/** Running plus terminal-but-not-yet-handed-to-a-waiter. */
159155
uncollectedIds(): string[] {
160156
return [...this.records.entries()]

src/subagent/authority.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,9 @@ function isDescendant(
109109
}
110110

111111
/**
112-
* SEAM, NOT YET A LIVE GATE: this function has no production call site today.
113-
* No verb in this codebase currently lets one live agent target another
114-
* (`task` only spawns; it never addresses an existing session), so the
115-
* subtree rule below is exercised only by authority.test.ts — it is not
116-
* enforced at runtime yet. It exists now so future verbs that make one
117-
* agent addressable by another can call it from day one instead of
118-
* inventing their own check. Until one of those wires a call site here, do
119-
* not describe this rule as enforced; only assertTierMayMountFleetVerb is.
112+
* Live gate for `read_agent_trace` (and any future verb that addresses an
113+
* existing session). Callers that only spawn (`task`, `spawn_agent`) never
114+
* reach this check.
120115
*
121116
* Authority rule (root owns its tree; a child manages only its own
122117
* descendants): throws unless `actor` is Tier 1, or `targetId` is `actor.id`

src/subagent/lifecycle-tools.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
createInterruptAgentTool,
77
createFollowupTaskTool,
88
} from "./lifecycle-tools.js";
9+
import { createFleetRecords } from "./agent-fleet.js";
910
import { createSubAgentSessionStore } from "./session-store.js";
1011

1112
async function callTool(
@@ -135,7 +136,10 @@ describe("interrupt_agent / followup_task", () => {
135136
return `Applying fix given ${history.length} prior turns of context.`;
136137
});
137138

138-
const interruptAgent = createInterruptAgentTool({ sessions });
139+
const interruptAgent = createInterruptAgentTool({
140+
sessions,
141+
fleetRecords: createFleetRecords(),
142+
});
139143
const followupTask = createFollowupTaskTool({ sessions });
140144

141145
const interruptResult = await callTool(interruptAgent, { target: worker.id });
@@ -219,7 +223,10 @@ describe("interrupt_agent / followup_task", () => {
219223
});
220224
sessions.registerFollowup(worker.id, async () => "resumed cleanly");
221225

222-
const interruptAgent = createInterruptAgentTool({ sessions });
226+
const interruptAgent = createInterruptAgentTool({
227+
sessions,
228+
fleetRecords: createFleetRecords(),
229+
});
223230
const followupTask = createFollowupTaskTool({ sessions });
224231

225232
await callTool(interruptAgent, { target: worker.id });
@@ -239,7 +246,10 @@ describe("interrupt_agent / followup_task", () => {
239246
const notRunning = sessions.start({ description: "d", agentId: "a", brief: "b" });
240247
sessions.complete(notRunning.id, "## Summary\nDone.");
241248

242-
const interruptAgent = createInterruptAgentTool({ sessions });
249+
const interruptAgent = createInterruptAgentTool({
250+
sessions,
251+
fleetRecords: createFleetRecords(),
252+
});
243253
const followupTask = createFollowupTaskTool({ sessions });
244254

245255
if (interruptAgent.kind !== "full") throw new Error("expected full tool");

src/subagent/lifecycle-tools.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,15 @@ function descendantsClosingOrder(
8787

8888
export interface LifecycleToolDeps {
8989
sessions: SubAgentSessionStore;
90-
/** When set, interrupt_agent terminalizes this wait mailbox immediately. */
90+
/** Optional for close/resume/followup; interrupt requires it (see InterruptAgentToolDeps). */
9191
fleetRecords?: FleetRecordsHandle;
9292
}
9393

94+
/** interrupt_agent always terminalizes the wait mailbox — no silent skip. */
95+
export type InterruptAgentToolDeps = LifecycleToolDeps & {
96+
fleetRecords: FleetRecordsHandle;
97+
};
98+
9499
export function createCloseAgentTool(deps: LifecycleToolDeps): AgentTool {
95100
return tool({
96101
definition: closeAgentToolDefinition,
@@ -173,7 +178,7 @@ export const interruptAgentToolDefinition: ToolDefinition = {
173178
},
174179
};
175180

176-
export function createInterruptAgentTool(deps: LifecycleToolDeps): AgentTool {
181+
export function createInterruptAgentTool(deps: InterruptAgentToolDeps): AgentTool {
177182
return tool({
178183
definition: interruptAgentToolDefinition,
179184
handler: async (call, _signal): Promise<ToolResult> => {
@@ -194,7 +199,7 @@ export function createInterruptAgentTool(deps: LifecycleToolDeps): AgentTool {
194199
}
195200
// Wait mailbox is separate from the TUI strip — flip it here so
196201
// wait_agents does not stay blocked on a still-"running" record.
197-
deps.fleetRecords?.interrupt(target);
202+
deps.fleetRecords.interrupt(target);
198203
return lifecycleResult(
199204
call.id,
200205
JSON.stringify({ agent_id: target, status: "interrupted" satisfies AgentLifecycleStatus }),

src/subagent/run-authority.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,49 @@ describe("runSubAgent search_agents mount gate (CL-7051, Tier-1 only)", () => {
167167
expect(searchAgentsMounts).toBe(1);
168168
});
169169
});
170+
171+
describe("runSubAgent passes parentSessionId into spawn_agent mount", () => {
172+
test("nested orchestrator fleetDeps.parentSessionId equals params.id", async () => {
173+
const cwd = await tmpCwd();
174+
let capturedParentSessionId: string | undefined;
175+
let spawnMounts = 0;
176+
177+
await withMockedModuleDuring(
178+
import.meta.resolve("./agent-fleet.js"),
179+
(real: typeof import("./agent-fleet.js")) => ({
180+
...real,
181+
createSpawnAgentTool: (deps: Parameters<typeof real.createSpawnAgentTool>[0]) => {
182+
spawnMounts++;
183+
capturedParentSessionId = deps.parentSessionId;
184+
return real.createSpawnAgentTool(deps);
185+
},
186+
}),
187+
async () => {
188+
const { runSubAgent: run } = await import("./run.js");
189+
try {
190+
await run({
191+
...baseParams(cwd, join(cwd, ".ctx")),
192+
id: "greybeard-session",
193+
orchestrator: true,
194+
orchestratorTier: "nested-orchestrator",
195+
nestedDispatch: {
196+
permissionGate: testPermissionGate,
197+
getWorkdirBase: () => join(cwd, ".ctx"),
198+
provider: {
199+
providerName: "test",
200+
baseURL: "http://localhost",
201+
model: "test-model",
202+
},
203+
profiles: [{ id: "intern", systemPromptRole: "You are intern." }],
204+
},
205+
});
206+
} catch {
207+
// Inference/agent construction may fail; mount decisions run first.
208+
}
209+
},
210+
);
211+
212+
expect(spawnMounts).toBe(1);
213+
expect(capturedParentSessionId).toBe("greybeard-session");
214+
});
215+
});

0 commit comments

Comments
 (0)