Skip to content

Commit b05ea20

Browse files
committed
Gate addressing fleet verbs with subtree authority
Nested interrupt/close/resume/followup now share send_input's assertCanTargetAgent check and fail closed without an actorId. Soft-interrupt wait_agents collects so a later followup cannot resurrect an already-observed interrupt as done.
1 parent 789f8ec commit b05ea20

7 files changed

Lines changed: 273 additions & 51 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,8 @@ Every director package carries a required `tier: SubagentTier` field (`src/agent
219219

220220
Enforcement is runtime code at the existing tool-mount point, not prompt wording — this is the fix for four prior mechanisms (`writePaths`, `report.requiredSections`, a `--config` comment, the thrash matcher) that were documented-as-enforced while enforcing nothing:
221221

222-
- **Mount-time gate — live today, and fails closed.** `task-tool.ts` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier` — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing fleet verbs, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — there is no profile-level opt-in today, so the mount always throws `FleetAuthorityError` for a profile-sourced orchestrator. `FLEET_VERBS` in `authority.ts` names the live verbs (`task`, `spawn_agent`, `wait_agents`, `interrupt_agent`, `close_agent`, `resume_agent`, `followup_task`, `read_agent_trace`, `search_agents`) plus reserved names (`list_agents`, `send_input`) so a later mount site inherits the same gate.
223-
- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. `read_agent_trace` is a production call site. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's own `fleetRecords`, not every running session in the shared store. `interrupt_agent` terminalizes the wait mailbox immediately.
222+
- **Mount-time gate — live today, and fails closed.** `task-tool.ts` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier` — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing fleet verbs, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — there is no profile-level opt-in today, so the mount always throws `FleetAuthorityError` for a profile-sourced orchestrator. `FLEET_VERBS` in `authority.ts` names the live verbs (`task`, `spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `followup_task`, `read_agent_trace`, `search_agents`) so every mount site inherits the same gate.
223+
- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. Production call sites: `read_agent_trace`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, and `followup_task`. Nested mounts pass `{actorId, tier, getNodes}` from `run.ts`; a missing `actorId` fails closed. Tier-1 primary omits authority and stays unrestricted. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's own `fleetRecords`, not every running session in the shared store. `interrupt_agent` / `send_input` with `interrupt:true` terminalize the wait mailbox immediately; the soft-interrupt wait path collects so a later followup cannot resurrect an already-observed interrupt.
224224
- `task()` remains the deprecated fused spawn+wait fallback. `spawn_agent` + `wait_agents` is the supported parallel path. The tier check still gates which packages may mount any fleet verb.
225225

226226
#### Closed director fleet (`src/agent/directors/`)

src/subagent/agent-fleet.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,35 @@ describe("interrupt_agent unblocks wait_agents", () => {
643643
expect(results[0]!.status).toBe("interrupted");
644644
followupGate.resolve("later");
645645
});
646+
647+
test("soft-interrupt wait collects so a later followup cannot resurrect done", async () => {
648+
const sessions = createSubAgentSessionStore();
649+
const fleetRecords = createFleetRecords();
650+
const worker = sessions.start({
651+
id: "soft-int",
652+
description: "looping",
653+
agentId: "explorer",
654+
brief: "b",
655+
retained: true,
656+
});
657+
sessions.markRunning(worker.id);
658+
// Running fleet record + soft-interrupted session (lifecycle only) —
659+
// the wait soft path must interrupt+take before returning.
660+
fleetRecords.register(worker.id);
661+
sessions.registerInterrupt(worker.id, () => {});
662+
sessions.interruptOne(worker.id);
663+
664+
const wait = createWaitAgentsTool({ sessions, fleetRecords });
665+
const waited = await callTool(wait, { targets: [worker.id], timeout_ms: 1000 });
666+
expect(waited.timed_out).toBe(false);
667+
const results = waited.results as { status: string }[];
668+
expect(results[0]!.status).toBe("interrupted");
669+
expect(fleetRecords.peek(worker.id)?.collected).toBe(true);
670+
671+
fleetRecords.completeAfterInterrupt(worker.id, "resurrected reply");
672+
expect(fleetRecords.peek(worker.id)?.status).toBe("interrupted");
673+
expect(fleetRecords.peek(worker.id)?.collected).toBe(true);
674+
});
646675
});
647676

648677
describe("list_agents", () => {

src/subagent/agent-fleet.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -715,10 +715,18 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool {
715715
}
716716
const session = deps.sessions.get(id);
717717
if (isSoftInterrupted(session)) {
718+
// Match the mailbox to what we report, then collect so a later
719+
// completeAfterInterrupt cannot resurrect this wait as "done".
720+
deps.fleetRecords.interrupt(id);
721+
const taken = deps.fleetRecords.take(id);
718722
return {
719723
agent_id: id,
720724
status: "interrupted" as const,
721-
...(session.report !== undefined ? { report: session.report } : {}),
725+
...(taken?.report !== undefined
726+
? { report: taken.report }
727+
: session.report !== undefined
728+
? { report: session.report }
729+
: {}),
722730
};
723731
}
724732
if (record === undefined) {

src/subagent/authority.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,20 +106,15 @@ function isDescendant(
106106
}
107107

108108
/**
109-
* SEAM, NOT YET A LIVE GATE: this function has no production call site today.
110-
* No verb in this codebase currently lets one live agent target another
111-
* (`task` only spawns; it never addresses an existing session), so the
112-
* subtree rule below is exercised only by authority.test.ts — it is not
113-
* enforced at runtime yet. It exists now so future verbs that make one
114-
* agent addressable by another can call it from day one instead of
115-
* inventing their own check. Until one of those wires a call site here, do
116-
* not describe this rule as enforced; only assertTierMayMountFleetVerb is.
117-
*
118109
* Authority rule (root owns its tree; a child manages only its own
119110
* descendants): throws unless `actor` is Tier 1, or `targetId` is `actor.id`
120111
* itself, or a descendant of `actor.id` in `nodes`. A Tier 3 leaf holds no
121112
* fleet verbs at all and can never reach this check with a real call, so it
122113
* always fails closed here too.
114+
*
115+
* Production call sites: `read_agent_trace`, `send_input`, `interrupt_agent`,
116+
* `close_agent`, `resume_agent`, and `followup_task` (nested mounts pass
117+
* authority from run.ts; Tier-1 primary omits it and stays unrestricted).
123118
*/
124119
export function assertCanTargetAgent(
125120
actor: { readonly id: string; readonly tier: SubagentTier },

src/subagent/lifecycle-tools.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,4 +387,169 @@ describe("send_input", () => {
387387
);
388388
expect(denied.isError).toBe(true);
389389
});
390+
391+
test("fails closed when nested authority has no actorId", async () => {
392+
const sessions = createSubAgentSessionStore();
393+
const worker = sessions.start({
394+
id: "worker",
395+
description: "worker",
396+
agentId: "a",
397+
brief: "b",
398+
});
399+
sessions.markRunning(worker.id);
400+
sessions.registerDeliver(worker.id, () => {});
401+
const sendInput = createSendInputTool({
402+
sessions,
403+
authority: {
404+
actorId: undefined,
405+
tier: "nested-orchestrator",
406+
getNodes: () => sessions.list(),
407+
},
408+
});
409+
if (sendInput.kind !== "full") throw new Error("expected full tool");
410+
const denied = await sendInput.handler(
411+
{ id: "no-actor", name: "send_input", arguments: { target: worker.id, message: "x" } },
412+
new AbortController().signal,
413+
);
414+
expect(denied.isError).toBe(true);
415+
expect(String(denied.content)).toContain("no resolvable session");
416+
});
417+
});
418+
419+
describe("nested lifecycle authority", () => {
420+
function nestAuthority(sessions: ReturnType<typeof createSubAgentSessionStore>, actorId: string) {
421+
return {
422+
actorId,
423+
tier: "nested-orchestrator" as const,
424+
getNodes: () => sessions.list(),
425+
};
426+
}
427+
428+
test("interrupt_agent denies a sibling and allows a descendant", async () => {
429+
const sessions = createSubAgentSessionStore();
430+
const nested = sessions.start({ id: "nested", description: "n", agentId: "a", brief: "b" });
431+
const child = sessions.start({
432+
id: "child",
433+
description: "c",
434+
agentId: "a",
435+
brief: "b",
436+
parentSessionId: nested.id,
437+
});
438+
const sibling = sessions.start({ id: "sibling", description: "s", agentId: "a", brief: "b" });
439+
for (const s of [child, sibling]) {
440+
sessions.markRunning(s.id);
441+
sessions.registerInterrupt(s.id, () => {});
442+
}
443+
const interrupt = createInterruptAgentTool({
444+
sessions,
445+
authority: nestAuthority(sessions, nested.id),
446+
});
447+
expect((await callTool(interrupt, { target: child.id })).status).toBe("interrupted");
448+
if (interrupt.kind !== "full") throw new Error("expected full tool");
449+
const denied = await interrupt.handler(
450+
{ id: "d", name: "interrupt_agent", arguments: { target: sibling.id } },
451+
new AbortController().signal,
452+
);
453+
expect(denied.isError).toBe(true);
454+
});
455+
456+
test("close_agent denies a sibling and allows a descendant", async () => {
457+
const sessions = createSubAgentSessionStore();
458+
const nested = sessions.start({ id: "nested", description: "n", agentId: "a", brief: "b" });
459+
const child = sessions.start({
460+
id: "child",
461+
description: "c",
462+
agentId: "a",
463+
brief: "b",
464+
parentSessionId: nested.id,
465+
});
466+
const sibling = sessions.start({ id: "sibling", description: "s", agentId: "a", brief: "b" });
467+
for (const s of [child, sibling]) sessions.registerClose(s.id, async () => {});
468+
const close = createCloseAgentTool({
469+
sessions,
470+
authority: nestAuthority(sessions, nested.id),
471+
});
472+
expect((await callTool(close, { target: child.id })).status).toBe("shutdown");
473+
if (close.kind !== "full") throw new Error("expected full tool");
474+
const denied = await close.handler(
475+
{ id: "d", name: "close_agent", arguments: { target: sibling.id } },
476+
new AbortController().signal,
477+
);
478+
expect(denied.isError).toBe(true);
479+
expect(sessions.get(sibling.id)?.lifecycleStatus).not.toBe("shutdown");
480+
});
481+
482+
test("followup_task denies a sibling and allows a descendant", async () => {
483+
const sessions = createSubAgentSessionStore();
484+
const nested = sessions.start({ id: "nested", description: "n", agentId: "a", brief: "b" });
485+
const child = sessions.start({
486+
id: "child",
487+
description: "c",
488+
agentId: "a",
489+
brief: "b",
490+
parentSessionId: nested.id,
491+
retained: true,
492+
});
493+
const sibling = sessions.start({
494+
id: "sibling",
495+
description: "s",
496+
agentId: "a",
497+
brief: "b",
498+
retained: true,
499+
});
500+
for (const s of [child, sibling]) {
501+
sessions.complete(s.id, "done");
502+
sessions.registerFollowup(s.id, async () => "reply");
503+
}
504+
const followup = createFollowupTaskTool({
505+
sessions,
506+
authority: nestAuthority(sessions, nested.id),
507+
});
508+
expect((await callTool(followup, { target: child.id, message: "more" })).status).toBe(
509+
"completed",
510+
);
511+
if (followup.kind !== "full") throw new Error("expected full tool");
512+
const denied = await followup.handler(
513+
{
514+
id: "d",
515+
name: "followup_task",
516+
arguments: { target: sibling.id, message: "more" },
517+
},
518+
new AbortController().signal,
519+
);
520+
expect(denied.isError).toBe(true);
521+
});
522+
523+
test("resume_agent denies a sibling and allows a descendant", async () => {
524+
const sessions = createSubAgentSessionStore();
525+
const nested = sessions.start({ id: "nested", description: "n", agentId: "a", brief: "b" });
526+
const child = sessions.start({
527+
id: "child",
528+
description: "c",
529+
agentId: "a",
530+
brief: "b",
531+
parentSessionId: nested.id,
532+
retained: true,
533+
});
534+
const sibling = sessions.start({
535+
id: "sibling",
536+
description: "s",
537+
agentId: "a",
538+
brief: "b",
539+
retained: true,
540+
});
541+
sessions.complete(child.id, "done");
542+
sessions.complete(sibling.id, "done");
543+
const resume = createResumeAgentTool({
544+
sessions,
545+
authority: nestAuthority(sessions, nested.id),
546+
});
547+
expect((await callTool(resume, { target: child.id })).status).toBe("running");
548+
if (resume.kind !== "full") throw new Error("expected full tool");
549+
const denied = await resume.handler(
550+
{ id: "d", name: "resume_agent", arguments: { target: sibling.id } },
551+
new AbortController().signal,
552+
);
553+
expect(denied.isError).toBe(true);
554+
});
390555
});

0 commit comments

Comments
 (0)