Skip to content

Commit dae5b48

Browse files
committed
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.
1 parent 01bf902 commit dae5b48

3 files changed

Lines changed: 84 additions & 4 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ Every director package carries a required `tier: SubagentTier` field (`src/agent
228228
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:
229229

230230
- **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.
231-
- **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`.
231+
- **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`.
232232
- **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.
233233
- `spawn_agent` + `wait_agents` is the only spawn path. The tier check still gates which packages may mount any fleet verb.
234234

src/subagent/agent-fleet.test.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,7 +1105,7 @@ describe("interrupt_agent unblocks wait_agents", () => {
11051105
test("close_agent overlay survives a send_input followup completing in the close window", async () => {
11061106
const gate = deferred<RunSubAgentResult>();
11071107
const followupGate = deferred<string>();
1108-
const closeHold = deferred<void>();
1108+
const closeHold = deferred<undefined>();
11091109
const deps = makeDeps(async (params) => {
11101110
params.onAgentReady?.({
11111111
close: async () => closeHold.promise,
@@ -1151,10 +1151,90 @@ describe("interrupt_agent unblocks wait_agents", () => {
11511151
expect(results[0]!.status).toBe("interrupted");
11521152
expect(deps.fleetRecords.peek(id)?.status).toBe("interrupted");
11531153

1154-
closeHold.resolve();
1154+
closeHold.resolve(undefined);
11551155
await closing;
11561156
});
11571157

1158+
test("close overlay without in-flight wait stays interrupted after followup complete", async () => {
1159+
const gate = deferred<RunSubAgentResult>();
1160+
const followupGate = deferred<string>();
1161+
const closeHold = deferred<undefined>();
1162+
const deps = makeDeps(async (params) => {
1163+
params.onAgentReady?.({
1164+
close: async () => closeHold.promise,
1165+
interrupt: () => {},
1166+
followup: async () => followupGate.promise,
1167+
deliver: () => {},
1168+
});
1169+
return gate.promise;
1170+
});
1171+
const spawn = createSpawnAgentTool(deps);
1172+
const wait = createWaitAgentsTool({
1173+
sessions: deps.sessions,
1174+
fleetRecords: deps.fleetRecords,
1175+
});
1176+
const list = createListAgentsTool({
1177+
sessions: deps.sessions,
1178+
fleetRecords: deps.fleetRecords,
1179+
});
1180+
const sendInput = createSendInputTool({
1181+
sessions: deps.sessions,
1182+
fleetRecords: deps.fleetRecords,
1183+
});
1184+
const close = createCloseAgentTool({
1185+
sessions: deps.sessions,
1186+
fleetRecords: deps.fleetRecords,
1187+
});
1188+
const spawned = await callTool(spawn, {
1189+
description: "looping",
1190+
prompt: "do it",
1191+
intent: "explore",
1192+
});
1193+
const id = spawned.agent_id as string;
1194+
1195+
await callTool(sendInput, { target: id, message: "stop that", interrupt: true });
1196+
if (close.kind !== "full") throw new Error("expected full tool");
1197+
const closing = close.handler(
1198+
{ id: "close-held-then-followup", name: "close_agent", arguments: { target: id } },
1199+
new AbortController().signal,
1200+
);
1201+
1202+
followupGate.resolve("followup after close overlay");
1203+
await new Promise<void>((resolve) => {
1204+
const done = (): boolean => deps.sessions.get(id)?.lifecycle.state === "completed";
1205+
if (done()) {
1206+
resolve();
1207+
return;
1208+
}
1209+
const unsub = deps.sessions.subscribe(() => {
1210+
if (done()) {
1211+
unsub();
1212+
resolve();
1213+
}
1214+
});
1215+
if (done()) {
1216+
unsub();
1217+
resolve();
1218+
}
1219+
});
1220+
1221+
expect(deps.fleetRecords.peek(id)?.status).toBe("interrupted");
1222+
const listed = await callTool(list, {});
1223+
const entry = (listed.agents as { agent_id: string; status: string }[]).find(
1224+
(a) => a.agent_id === id,
1225+
);
1226+
expect(entry?.status).toBe("interrupted");
1227+
1228+
const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 });
1229+
expect(waited.timed_out).toBe(false);
1230+
const results = waited.results as { status: string }[];
1231+
expect(results[0]!.status).toBe("interrupted");
1232+
1233+
closeHold.resolve(undefined);
1234+
await closing;
1235+
gate.resolve({ report: "original interrupted", interrupted: true } as RunSubAgentResult);
1236+
});
1237+
11581238
test("completeAfterInterrupt does not clear a close overlay", () => {
11591239
const sessions = createSubAgentSessionStore();
11601240
const fleetRecords = createFleetMailbox(sessions);

src/subagent/agent-fleet.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,13 +326,13 @@ class FleetMailbox {
326326
private projectedStatus(id: string, overlay: FleetOverlay): WaitJSONStatus {
327327
if (overlay.frozenStatus !== undefined) return overlay.frozenStatus;
328328
if (this.sessions.hasPendingAsk(id)) return "awaiting_director";
329+
if (overlay.forceInterrupted === true) return "interrupted";
329330
const live = this.sessionWaitStatus(id);
330331
if (live !== undefined && !isLiveWaitStatus(live)) {
331332
overlay.lastWaitStatus = live;
332333
return live;
333334
}
334335
if (overlay.forceQueued === true) return "queued";
335-
if (overlay.forceInterrupted === true) return "interrupted";
336336
if (live !== undefined) {
337337
overlay.lastWaitStatus = live;
338338
return live;

0 commit comments

Comments
 (0)