From 83de4e5b238e01cab113f277736d45cd809f9a4b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 16:24:53 -0700 Subject: [PATCH 1/5] Keep wait live after interrupt-with-follow-up An interrupt overlay freezes wait as interrupted, so a queued follow-up never surfaces even after it completes. Mark the follow-up lane instead and leave wait running until that turn settles. --- src/subagent/agent-fleet.test.ts | 90 ++++++++++++++++++++++++++++++-- src/subagent/agent-fleet.ts | 62 +++++++++++++++++----- src/subagent/lifecycle-tools.ts | 16 ++++-- 3 files changed, 147 insertions(+), 21 deletions(-) diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index 654ac84a3..4c0d3dc56 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -16,6 +16,7 @@ import { isLiveWaitStatus } from "./lifecycle.js"; import { createInterruptAgentTool, createCloseAgentTool, + createResumeAgentTool, createSendInputTool, } from "./lifecycle-tools.js"; import { createSubAgentSessionStore } from "./session-store.js"; @@ -1072,7 +1073,7 @@ describe("interrupt_agent unblocks wait_agents", () => { gate.resolve({ report: "done" }); }); - test("send_input interrupt:true unblocks wait_agents as interrupted", async () => { + test("send_input interrupt:true keeps wait_agents live until the followup completes", async () => { const gate = deferred(); const followupGate = deferred(); const deps = makeDeps(async (params) => { @@ -1101,16 +1102,97 @@ describe("interrupt_agent unblocks wait_agents", () => { const id = spawned.agent_id as string; const waiting = callTool(wait, { targets: [id], timeout_ms: 5000 }); await callTool(sendInput, { target: id, message: "stop that", interrupt: true }); + followupGate.resolve("later"); + gate.resolve({ report: "original interrupted", interrupted: true } as RunSubAgentResult); const waited = await waiting; expect(waited.timed_out).toBe(false); const results = waited.results as { agent_id: string; status: string; + report?: string; stop_reason?: string; }[]; - expect(results).toEqual([{ agent_id: id, status: "interrupted", stop_reason: "interrupted" }]); - expect(deps.sessions.get(id)?.stopReason).toBe("interrupted"); - followupGate.resolve("later"); + expect(results[0]!.status).toBe("done"); + expect(results[0]!.report).toBe("later"); + }); + + test("CL-7331: send_input interrupt keeps wait live until the queued followup completes", async () => { + const gate = deferred(); + const followupGate = deferred(); + const deps = makeDeps(async (params) => { + params.onAgentReady?.({ + close: async () => {}, + interrupt: () => {}, + followup: async () => followupGate.promise, + deliver: () => {}, + }); + return gate.promise; + }); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const list = createListAgentsTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const sendInput = createSendInputTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const resume = createResumeAgentTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const spawned = await callTool(spawn, { + description: "looping", + prompt: "do it", + intent: "explore", + }); + const id = spawned.agent_id as string; + + const sent = await callTool(sendInput, { + target: id, + message: "return a concise report", + interrupt: true, + }); + expect(sent.status).toBe("interrupted"); + + // The queued followup is still running: wait must stay live (not an + // immediate terminal interrupted), and list must agree with lifecycle. + const pending = await callTool(wait, { targets: [id], timeout_ms: 50 }); + expect(pending.timed_out).toBe(true); + expect((pending.results as { status: string }[])[0]!.status).toBe("running"); + + const listed = await callTool(list, {}); + const entry = (listed.agents as { agent_id: string; status: string; lifecycle: string }[]).find( + (a) => a.agent_id === id, + ); + expect(entry?.status).toBe("running"); + expect(entry?.lifecycle).toBe("running"); + + // A resume while the followup is in flight must agree with wait/list. + if (resume.kind !== "full") throw new Error("expected full tool"); + const resumed = await resume.handler( + { + id: "resume-while-followup", + name: "resume_agent", + arguments: { target: id, message: "x" }, + }, + new AbortController().signal, + ); + expect(resumed.isError).toBe(true); + expect(String(resumed.content)).toContain("status: running"); + + // When the queued followup finishes, its report must surface via wait. + followupGate.resolve("followup report"); + gate.resolve({ report: "original interrupted", interrupted: true } as RunSubAgentResult); + const done = await callTool(wait, { targets: [id], timeout_ms: 5000 }); + expect(done.timed_out).toBe(false); + const doneResults = done.results as { status: string; report?: string }[]; + expect(doneResults[0]!.status).toBe("done"); + expect(doneResults[0]!.report).toBe("followup report"); }); test("soft-interrupt wait path collects so omitted re-wait does not re-deliver", async () => { diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index b35ee785a..969b63ac6 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -120,8 +120,11 @@ interface FleetRecord { interface FleetOverlay { collected?: boolean; pinHeld?: boolean; - /** send_input interrupt:true / close_agent — wait interrupted while session may still be running. */ + /** interrupt_agent / close_agent — wait interrupted while session may still be running. */ forceInterrupted?: boolean; + /** A send_input interrupt:true followup owns this lane; suppresses any + * terminal overlay so wait stays live until the followup settles. */ + followupLive?: boolean; /** Admission overlay: wait JSON `queued` while run() has not been admitted. */ forceQueued?: boolean; /** Frozen wait status after collect. Later session completed must not resurrect this mailbox. */ @@ -205,8 +208,8 @@ class FleetMailbox { /** * Overlay wait-status override so wait unblocks while the session may still - * be running (send_input interrupt:true followup, close_agent teardown). - * No-op on an already-collected mailbox — frozen status stays interrupted. + * be running (interrupt_agent teardown, close_agent teardown). No-op on an + * already-collected mailbox — frozen status stays interrupted. */ interrupt(id: string, _report?: string): void { const existing = this.records.get(id); @@ -222,18 +225,47 @@ class FleetMailbox { } /** - * send_input interrupt:true followup finished. Clear an uncollected - * interrupted overlay so wait projects session completed → done. No-op if - * wait_agents already collected the interrupt. + * CL-7331: mark that a send_input interrupt:true followup owns this lane. + * Suppresses any interrupt overlay so wait stays live (running/queued) + * until the followup settles, and tells the spawn settlement to swallow + * the original run's interrupted result instead of attaching salvage over + * the live followup. No-op on an unknown id; safe to call on a collected + * mailbox (frozen status still wins for projection, but the settlement + * swallow still applies). */ - completeAfterInterrupt(id: string, _report?: string): void { + noteFollowup(id: string): void { const existing = this.records.get(id); - if (existing === undefined || existing.collected === true) return; - if (existing.forceInterrupted !== true) return; + if (existing === undefined) return; + existing.followupLive = true; delete existing.forceInterrupted; this.sessions?.wake(); } + /** True while a send_input interrupt:true followup owns this lane. */ + hasLiveFollowup(id: string): boolean { + return this.records.get(id)?.followupLive === true; + } + + /** + * send_input interrupt:true followup finished. Clear the followup lane flag + * (and any uncollected interrupted overlay) so wait projects session + * completed → done. No-op if wait_agents already collected the interrupt. + */ + completeAfterInterrupt(id: string, _report?: string): void { + const existing = this.records.get(id); + if (existing === undefined || existing.collected === true) return; + let changed = false; + if (existing.followupLive === true) { + delete existing.followupLive; + changed = true; + } + if (existing.forceInterrupted === true) { + delete existing.forceInterrupted; + changed = true; + } + if (changed) this.sessions?.wake(); + } + ids(): string[] { return [...this.records.keys()]; } @@ -1201,11 +1233,13 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { if (result.interrupted === true) { keepWorktreeAlive = true; runInterrupted = true; - const now = deps.sessions.get(session.id); - const overlay = deps.fleetRecords.peek(session.id); - const followupLive = - now?.lifecycle.state === "running" && overlay?.status === "interrupted"; - if (!followupLive) { + // CL-7331: a followup started via send_input interrupt owns + // this lane now — the settling original turn must not attach + // salvage over it. The mailbox flag (set by send_input, not + // by interrupt_agent or a bare settle) identifies that lane; + // lifecycle alone cannot, since a never-started run and a + // queued followup both read pending_init. + if (!deps.fleetRecords.hasLiveFollowup(session.id)) { deps.sessions.attachReport(session.id, result.report, { ...(result.stopReason !== undefined ? { stopReason: result.stopReason } : {}), }); diff --git a/src/subagent/lifecycle-tools.ts b/src/subagent/lifecycle-tools.ts index d1879e42c..217c1def9 100644 --- a/src/subagent/lifecycle-tools.ts +++ b/src/subagent/lifecycle-tools.ts @@ -331,8 +331,9 @@ export const sendInputToolDefinition: ToolDefinition = { "worker has a pending ask_director, the message resolves that question (it does not deliver a " + "steer inbound). Otherwise deliver `message` into the live session and return immediately " + "without awaiting a reply and without completing wait_agents. " + - "With interrupt:true: stop the current turn (same wait-mailbox flip as interrupt_agent) " + - "then queue `message` as the next-turn followup without awaiting that reply. Fails on a " + + "With interrupt:true: stop the current turn then queue `message` as the next-turn followup " + + "without awaiting that reply — wait_agents stays live (running/queued) and collects the " + + "followup reply when it finishes. Fails on a " + "session that is not currently running an active turn, or when the message is empty / oversize. Nested " + "orchestrators may only target their own descendants.", inputSchema: { @@ -393,7 +394,16 @@ export function createSendInputTool(deps: LifecycleToolDeps): AgentTool { `Error: cannot send_input to "${target}" (status: ${outcome.status}).`, ); } - if (interrupt) deps.fleetRecords?.interrupt(target); + // CL-7331: an interrupt-with-followup is transitional, not terminal. + // interrupt_agent/close_agent flip the wait mailbox so an in-flight + // wait_agents unblocks as interrupted; a queued followup must instead + // stay wait-live (running/queued) so the followup reply surfaces via + // wait_agents instead of freezing as an already-collected interrupt. + if (interrupt && deps.fleetRecords !== undefined) { + deps.fleetRecords.noteFollowup(target); + const after = deps.sessions.get(target); + if (after?.lifecycle.state === "pending_init") deps.fleetRecords.markQueued(target); + } return lifecycleResult(call.id, JSON.stringify({ agent_id: target, status: outcome.status })); }, }); From 1f14ac8a90beddceda8de978ac5702c2958cdfed Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 16:44:51 -0700 Subject: [PATCH 2/5] Keep send-input followup from undoing wait overlays --- src/subagent/agent-fleet.test.ts | 175 +++++++++++++++++++++++++++++++ src/subagent/agent-fleet.ts | 9 +- src/subagent/lifecycle-tools.ts | 8 +- src/subagent/session-store.ts | 23 ++-- 4 files changed, 203 insertions(+), 12 deletions(-) diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index 4c0d3dc56..6dbe4a2d6 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -1195,6 +1195,181 @@ describe("interrupt_agent unblocks wait_agents", () => { expect(doneResults[0]!.report).toBe("followup report"); }); + test("close_agent overlay survives a send_input followup completing in the close window", async () => { + const gate = deferred(); + const followupGate = deferred(); + const closeHold = deferred(); + const deps = makeDeps(async (params) => { + params.onAgentReady?.({ + close: async () => closeHold.promise, + interrupt: () => {}, + followup: async () => followupGate.promise, + deliver: () => {}, + }); + return gate.promise; + }); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const sendInput = createSendInputTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const close = createCloseAgentTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const spawned = await callTool(spawn, { + description: "looping", + prompt: "do it", + intent: "explore", + }); + const id = spawned.agent_id as string; + + const waiting = callTool(wait, { targets: [id], timeout_ms: 5000 }); + await callTool(sendInput, { target: id, message: "stop that", interrupt: true }); + if (close.kind !== "full") throw new Error("expected full tool"); + const closing = close.handler( + { id: "close-during-followup", name: "close_agent", arguments: { target: id } }, + new AbortController().signal, + ); + followupGate.resolve("followup during close"); + gate.resolve({ report: "original interrupted", interrupted: true } as RunSubAgentResult); + + const waited = await waiting; + expect(waited.timed_out).toBe(false); + const results = waited.results as { status: string }[]; + expect(results[0]!.status).toBe("interrupted"); + expect(deps.fleetRecords.peek(id)?.status).toBe("interrupted"); + + closeHold.resolve(); + await closing; + }); + + test("completeAfterInterrupt does not clear a close overlay", () => { + const sessions = createSubAgentSessionStore(); + const fleetRecords = createFleetMailbox(sessions); + const worker = sessions.start({ + description: "looping", + agentId: "explorer", + brief: "b", + retained: true, + }); + sessions.markRunning(worker.id); + fleetRecords.register(worker.id); + fleetRecords.noteFollowup(worker.id); + fleetRecords.interrupt(worker.id); + expect(fleetRecords.peek(worker.id)?.status).toBe("interrupted"); + fleetRecords.completeAfterInterrupt(worker.id, "followup reply"); + expect(fleetRecords.peek(worker.id)?.status).toBe("interrupted"); + }); + + test("rejected send_input followup clears the lane so wait collects interrupted salvage", async () => { + const gate = deferred(); + const followupGate = deferred(); + const deps = makeDeps(async (params) => { + params.onAgentReady?.({ + close: async () => {}, + interrupt: () => {}, + followup: async () => followupGate.promise, + deliver: () => {}, + }); + return gate.promise; + }); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const sendInput = createSendInputTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const spawned = await callTool(spawn, { + description: "looping", + prompt: "do it", + intent: "explore", + }); + const id = spawned.agent_id as string; + await callTool(sendInput, { target: id, message: "stop that", interrupt: true }); + followupGate.reject(new Error("followup failed")); + await new Promise((resolve) => setTimeout(resolve, 20)); + gate.resolve({ + report: "## Summary\nStopped.\n## Findings\nsalvage\n## Blockers\ninterrupted\n## Paths\n", + interrupted: true, + } as RunSubAgentResult); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 }); + expect(waited.timed_out).toBe(false); + const results = waited.results as { status: string; report?: string }[]; + expect(results[0]!.status).toBe("interrupted"); + expect(results[0]!.report).toContain("salvage"); + }); + + test("send_input interrupt queued overlay clears when the followup is admitted", async () => { + const admission = createAdmissionQueue({ capacity: 1 }); + const sessions = createSubAgentSessionStore({ admission }); + const fleetRecords = createFleetMailbox(sessions); + admission.enqueue({ + id: "holder", + provider: "p", + start: () => {}, + }); + const worker = sessions.start({ + description: "looping", + agentId: "explorer", + brief: "b", + retained: true, + provider: "p", + }); + sessions.markRunning(worker.id); + fleetRecords.register(worker.id); + const followupGate = deferred(); + sessions.registerInterrupt(worker.id, () => {}); + sessions.registerFollowup(worker.id, async () => followupGate.promise); + + const sendInput = createSendInputTool({ sessions, fleetRecords }); + const wait = createWaitAgentsTool({ sessions, fleetRecords }); + const list = createListAgentsTool({ sessions, fleetRecords }); + + const sent = await callTool(sendInput, { + target: worker.id, + message: "stop that", + interrupt: true, + }); + expect(sent.status).toBe("interrupted"); + expect(sessions.get(worker.id)?.lifecycleStatus).toBe("pending_init"); + + const queuedWait = await callTool(wait, { targets: [worker.id], timeout_ms: 50 }); + expect(queuedWait.timed_out).toBe(true); + expect((queuedWait.results as { status: string }[])[0]!.status).toBe("queued"); + const queuedList = await callTool(list, {}); + const queuedEntry = ( + queuedList.agents as { agent_id: string; status: string; lifecycle: string }[] + ).find((a) => a.agent_id === worker.id); + expect(queuedEntry?.status).toBe("queued"); + expect(queuedEntry?.lifecycle).toBe("pending_init"); + + admission.release("holder"); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(sessions.get(worker.id)?.lifecycleStatus).toBe("running"); + const runningWait = await callTool(wait, { targets: [worker.id], timeout_ms: 50 }); + expect(runningWait.timed_out).toBe(true); + expect((runningWait.results as { status: string }[])[0]!.status).toBe("running"); + const runningList = await callTool(list, {}); + const runningEntry = ( + runningList.agents as { agent_id: string; status: string; lifecycle: string }[] + ).find((a) => a.agent_id === worker.id); + expect(runningEntry?.status).toBe("running"); + expect(runningEntry?.lifecycle).toBe("running"); + + followupGate.resolve("later"); + }); + test("soft-interrupt wait path collects so omitted re-wait does not re-deliver", async () => { const gate = deferred(); const deps = makeDeps(async (params) => { diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 969b63ac6..28c3d832e 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -248,8 +248,9 @@ class FleetMailbox { /** * send_input interrupt:true followup finished. Clear the followup lane flag - * (and any uncollected interrupted overlay) so wait projects session - * completed → done. No-op if wait_agents already collected the interrupt. + * (and any admission queued overlay) so wait can project the settled session. + * Leaves a close/interrupt overlay in place — a followup reply must not undo + * interrupt_agent or close_agent. No-op if wait_agents already collected. */ completeAfterInterrupt(id: string, _report?: string): void { const existing = this.records.get(id); @@ -259,8 +260,8 @@ class FleetMailbox { delete existing.followupLive; changed = true; } - if (existing.forceInterrupted === true) { - delete existing.forceInterrupted; + if (existing.forceQueued === true) { + delete existing.forceQueued; changed = true; } if (changed) this.sessions?.wake(); diff --git a/src/subagent/lifecycle-tools.ts b/src/subagent/lifecycle-tools.ts index 217c1def9..4eb6b753a 100644 --- a/src/subagent/lifecycle-tools.ts +++ b/src/subagent/lifecycle-tools.ts @@ -308,7 +308,7 @@ export function createInterruptAgentTool(deps: InterruptAgentToolDeps): AgentToo } // Soft interrupt leaves the run in flight; projectWaitStatus treats // interrupted+inFlight as running so resume cannot collect a stale stamp. - // Flip the wait mailbox overlay here (same as send_input interrupt:true). + // Flip the wait mailbox overlay so in-flight wait_agents unblocks as interrupted. deps.fleetRecords.interrupt(target); return lifecycleResult( call.id, @@ -382,9 +382,15 @@ export function createSendInputTool(deps: LifecycleToolDeps): AgentTool { ...(interrupt ? { interrupt: true } : {}), ...(interrupt && deps.fleetRecords !== undefined ? { + onStart: () => { + deps.fleetRecords?.clearQueued(target); + }, onFollowupReply: (reply: string) => { deps.fleetRecords?.completeAfterInterrupt(target, reply); }, + onFail: () => { + deps.fleetRecords?.completeAfterInterrupt(target); + }, } : {}), }); diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index a7272cd3f..33c021142 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -245,7 +245,12 @@ export interface SubAgentSessionStore { sendInputOne( id: string, message: string, - opts?: { interrupt?: boolean; onFollowupReply?: (reply: string) => void }, + opts?: { + interrupt?: boolean; + onFollowupReply?: (reply: string) => void; + onStart?: () => void; + onFail?: (error: unknown) => void; + }, ): { ok: true; status: AgentLifecycleStatus } | { ok: false; status: AgentLifecycleStatus }; /** * One pending ask_director per session. `sendInputOne` (soft) resolves it; @@ -827,11 +832,8 @@ export function createSubAgentSessionStore( }) .catch((err: unknown) => { runInFlight.delete(id); - if (opts?.onFail !== undefined) { - opts.onFail(err); - } else { - endFollowupTurn(id, failLifecycle); - } + opts?.onFail?.(err); + endFollowupTurn(id, failLifecycle); log.error("followup turn failed for {id}: {error}", { id, error: err instanceof Error ? err.message : String(err), @@ -1285,7 +1287,12 @@ export function createSubAgentSessionStore( sendInputOne( id: string, message: string, - opts?: { interrupt?: boolean; onFollowupReply?: (reply: string) => void }, + opts?: { + interrupt?: boolean; + onFollowupReply?: (reply: string) => void; + onStart?: () => void; + onFail?: (error: unknown) => void; + }, ): { ok: true; status: AgentLifecycleStatus } | { ok: false; status: AgentLifecycleStatus } { const session = sessions.get(id); if (session === undefined) return { ok: false, status: "not_found" }; @@ -1302,7 +1309,9 @@ export function createSubAgentSessionStore( settleCancelsAsks(id, "cancelled by send_input interrupt"); interrupt(); queueFollowupTurn(id, message, "interrupted", { + ...(opts.onStart !== undefined ? { onStart: opts.onStart } : {}), ...(opts.onFollowupReply !== undefined ? { onReply: opts.onFollowupReply } : {}), + ...(opts.onFail !== undefined ? { onFail: opts.onFail } : {}), }); // After beginFollowupTurn, which clears leftover stopReason. Stamp // here so an in-flight wait_agents overlay can project interrupted From 694de43070e5d20b079136674d2e6d316ffdd1ce Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 16:44:54 -0700 Subject: [PATCH 3/5] Document send-input interrupt wait contract --- docs/ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1b1f8f494..40a40e7ca 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -233,7 +233,7 @@ Every director package carries a required `tier: SubagentTier` field (`src/agent 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: - **Mount-time gate — live today, and fails closed.** `spawn_agent` 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 `spawn_agent` rejects a profile-sourced orchestrator before starting a session. `FLEET_VERBS` in `authority.ts` names the live verbs (`spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `read_agent_trace`, `search_agents`) so every mount site inherits the same gate. `list_agents` is the non-blocking mailbox-scoped list of this install's own `spawn_agent` workers (same scope as `wait_agents`); nested orchestrators may mount it. Fleet discovery (`search_agents`) remains Tier 1 only. -- **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`, `wait_agents` explicit targets, `send_input`, `interrupt_agent`, `close_agent`, and `resume_agent`. 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 per-install wait mailbox over the shared session store, not every running session. Wait JSON is a projection of stored lifecycle plus mailbox membership/pin/collected/interrupt override — not a second terminal store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` stamps the session `interrupted`; wait JSON projects that stored lifecycle and does not write a mailbox overlay. `send_input` with `interrupt:true` sets the mailbox interrupt overlay so wait unblocks while a queued followup may already be running. The wait path collects a terminal status so a later followup cannot resurrect an already-observed interrupt. `close_agent` terminalizes the wait mailbox before teardown. Operator cancel (`cancel` / `cancelAll`) projects wait status `interrupted`. +- **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`, `wait_agents` explicit targets, `send_input`, `interrupt_agent`, `close_agent`, and `resume_agent`. 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 per-install wait mailbox over the shared session store, not every running session. Wait JSON is a projection of stored lifecycle plus mailbox membership/pin/collected/interrupt override — not a second terminal store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` stamps the session `interrupted`; wait JSON projects that stored lifecycle and does not write a mailbox overlay. `send_input` with `interrupt:true` leaves wait live (`running`/`queued`) until the followup settles; `interrupt_agent` and `close_agent` still set the mailbox interrupt overlay. `close_agent` terminalizes the wait mailbox before teardown. Operator cancel (`cancel` / `cancelAll`) projects wait status `interrupted`. - **Leaf `ask_director` (CL-6945).** Tier 3 leaves mount `ask_director` (not `ask_operator`). The worker evaluates caps, then awaits a session-store port; a missing port returns an error and does not suspend. `wait_agents` projects `awaiting_director` with `question` / `question_id` / `description` — this is wait JSON only, not a `WorkerLifecycle` state. Re-wait while still pending re-delivers the same question. Soft `send_input` answers the pending ask (it does not deliver a steer inbound). Interrupt / settle / close cancel the ask, including descendants. A worker blocked in `ask_director` is not stall-salvaged. - `spawn_agent` + `wait_agents` is the only spawn path. The tier check still gates which packages may mount any fleet verb. From 2b5c6b55d9b49738a5eeca807c92c40a69ba3b71 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 17:03:04 -0700 Subject: [PATCH 4/5] Keep a close overlay over a later completed stamp A close or interrupt wait overlay must stay interrupted until collected, even if a followup later stamps the session completed. send_input interrupt does not set that overlay, so a happy-path followup wait still collects done. --- docs/ARCHITECTURE.md | 2 +- src/subagent/agent-fleet.test.ts | 84 +++++++++++++++++++++++++++++++- src/subagent/agent-fleet.ts | 2 +- 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 40a40e7ca..92bad48cb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -233,7 +233,7 @@ Every director package carries a required `tier: SubagentTier` field (`src/agent 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: - **Mount-time gate — live today, and fails closed.** `spawn_agent` 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 `spawn_agent` rejects a profile-sourced orchestrator before starting a session. `FLEET_VERBS` in `authority.ts` names the live verbs (`spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `read_agent_trace`, `search_agents`) so every mount site inherits the same gate. `list_agents` is the non-blocking mailbox-scoped list of this install's own `spawn_agent` workers (same scope as `wait_agents`); nested orchestrators may mount it. Fleet discovery (`search_agents`) remains Tier 1 only. -- **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`, `wait_agents` explicit targets, `send_input`, `interrupt_agent`, `close_agent`, and `resume_agent`. 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 per-install wait mailbox over the shared session store, not every running session. Wait JSON is a projection of stored lifecycle plus mailbox membership/pin/collected/interrupt override — not a second terminal store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` stamps the session `interrupted`; wait JSON projects that stored lifecycle and does not write a mailbox overlay. `send_input` with `interrupt:true` leaves wait live (`running`/`queued`) until the followup settles; `interrupt_agent` and `close_agent` still set the mailbox interrupt overlay. `close_agent` terminalizes the wait mailbox before teardown. Operator cancel (`cancel` / `cancelAll`) projects wait status `interrupted`. +- **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`, `wait_agents` explicit targets, `send_input`, `interrupt_agent`, `close_agent`, and `resume_agent`. 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 per-install wait mailbox over the shared session store, not every running session. Wait JSON is a projection of stored lifecycle plus mailbox membership/pin/collected/interrupt override — not a second terminal store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` stamps the session `interrupted` and, with `close_agent`, writes the mailbox interrupt overlay; `send_input` with `interrupt:true` does not. `send_input` with `interrupt:true` leaves wait live (`running`/`queued`) until the followup settles. `close_agent` terminalizes the wait mailbox before teardown. Operator cancel (`cancel` / `cancelAll`) projects wait status `interrupted`. - **Leaf `ask_director` (CL-6945).** Tier 3 leaves mount `ask_director` (not `ask_operator`). The worker evaluates caps, then awaits a session-store port; a missing port returns an error and does not suspend. `wait_agents` projects `awaiting_director` with `question` / `question_id` / `description` — this is wait JSON only, not a `WorkerLifecycle` state. Re-wait while still pending re-delivers the same question. Soft `send_input` answers the pending ask (it does not deliver a steer inbound). Interrupt / settle / close cancel the ask, including descendants. A worker blocked in `ask_director` is not stall-salvaged. - `spawn_agent` + `wait_agents` is the only spawn path. The tier check still gates which packages may mount any fleet verb. diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index 6dbe4a2d6..36eb52c79 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -1198,7 +1198,7 @@ describe("interrupt_agent unblocks wait_agents", () => { test("close_agent overlay survives a send_input followup completing in the close window", async () => { const gate = deferred(); const followupGate = deferred(); - const closeHold = deferred(); + const closeHold = deferred(); const deps = makeDeps(async (params) => { params.onAgentReady?.({ close: async () => closeHold.promise, @@ -1244,10 +1244,90 @@ describe("interrupt_agent unblocks wait_agents", () => { expect(results[0]!.status).toBe("interrupted"); expect(deps.fleetRecords.peek(id)?.status).toBe("interrupted"); - closeHold.resolve(); + closeHold.resolve(undefined); await closing; }); + test("close overlay without in-flight wait stays interrupted after followup complete", async () => { + const gate = deferred(); + const followupGate = deferred(); + const closeHold = deferred(); + const deps = makeDeps(async (params) => { + params.onAgentReady?.({ + close: async () => closeHold.promise, + interrupt: () => {}, + followup: async () => followupGate.promise, + deliver: () => {}, + }); + return gate.promise; + }); + const spawn = createSpawnAgentTool(deps); + const wait = createWaitAgentsTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const list = createListAgentsTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const sendInput = createSendInputTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const close = createCloseAgentTool({ + sessions: deps.sessions, + fleetRecords: deps.fleetRecords, + }); + const spawned = await callTool(spawn, { + description: "looping", + prompt: "do it", + intent: "explore", + }); + const id = spawned.agent_id as string; + + await callTool(sendInput, { target: id, message: "stop that", interrupt: true }); + if (close.kind !== "full") throw new Error("expected full tool"); + const closing = close.handler( + { id: "close-held-then-followup", name: "close_agent", arguments: { target: id } }, + new AbortController().signal, + ); + + followupGate.resolve("followup after close overlay"); + await new Promise((resolve) => { + const done = (): boolean => deps.sessions.get(id)?.lifecycle.state === "completed"; + if (done()) { + resolve(); + return; + } + const unsub = deps.sessions.subscribe(() => { + if (done()) { + unsub(); + resolve(); + } + }); + if (done()) { + unsub(); + resolve(); + } + }); + + expect(deps.fleetRecords.peek(id)?.status).toBe("interrupted"); + const listed = await callTool(list, {}); + const entry = (listed.agents as { agent_id: string; status: string }[]).find( + (a) => a.agent_id === id, + ); + expect(entry?.status).toBe("interrupted"); + + const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 }); + expect(waited.timed_out).toBe(false); + const results = waited.results as { status: string }[]; + expect(results[0]!.status).toBe("interrupted"); + + closeHold.resolve(undefined); + await closing; + gate.resolve({ report: "original interrupted", interrupted: true } as RunSubAgentResult); + }); + test("completeAfterInterrupt does not clear a close overlay", () => { const sessions = createSubAgentSessionStore(); const fleetRecords = createFleetMailbox(sessions); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 28c3d832e..9ad39f5c5 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -328,13 +328,13 @@ class FleetMailbox { private projectedStatus(id: string, overlay: FleetOverlay): WaitJSONStatus { if (overlay.frozenStatus !== undefined) return overlay.frozenStatus; if (this.sessions.hasPendingAsk(id)) return "awaiting_director"; + if (overlay.forceInterrupted === true) return "interrupted"; const live = this.sessionWaitStatus(id); if (live !== undefined && !isLiveWaitStatus(live)) { overlay.lastWaitStatus = live; return live; } if (overlay.forceQueued === true) return "queued"; - if (overlay.forceInterrupted === true) return "interrupted"; if (live !== undefined) { overlay.lastWaitStatus = live; return live; From 58f9bab188a358d48f52739bb2be25a26cf70ee0 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 9 Sep 2026 18:12:45 -0700 Subject: [PATCH 5/5] Expect wait running during send-input interrupt followup Main asserted the wait mailbox flipped to interrupted as soon as send_input interrupt:true queued a followup. Wait now stays live until that followup settles; the leftover interrupted stamp remains on the session. --- src/subagent/lifecycle-tools.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/subagent/lifecycle-tools.test.ts b/src/subagent/lifecycle-tools.test.ts index a4dc978b6..0731bbb4a 100644 --- a/src/subagent/lifecycle-tools.test.ts +++ b/src/subagent/lifecycle-tools.test.ts @@ -620,8 +620,7 @@ describe("send_input", () => { await callTool(sendInput, { target: worker.id, message: "stop that", interrupt: true }); const inflight = fleetRecords.peek(worker.id); - expect(inflight?.status).toBe("interrupted"); - expect(inflight?.stopReason).toBe("interrupted"); + expect(inflight?.status).toBe("running"); expect(sessions.get(worker.id)?.stopReason).toBe("interrupted"); expect(sessions.get(worker.id)?.lifecycleStatus).toBe("running");