Skip to content

Commit 9382db0

Browse files
Merge pull request #858 from corbitsdev/cl-7331-interrupted-workers-never-return-queued-follow-up-reports
Keep wait live after interrupt-with-follow-up
2 parents df7bc49 + 58f9bab commit 9382db0

6 files changed

Lines changed: 428 additions & 32 deletions

File tree

docs/ARCHITECTURE.md

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

235235
- **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.
236-
- **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`.
236+
- **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`.
237237
- **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.
238238
- `spawn_agent` + `wait_agents` is the only spawn path. The tier check still gates which packages may mount any fleet verb.
239239

src/subagent/agent-fleet.test.ts

Lines changed: 340 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { isLiveWaitStatus } from "./lifecycle.js";
1616
import {
1717
createInterruptAgentTool,
1818
createCloseAgentTool,
19+
createResumeAgentTool,
1920
createSendInputTool,
2021
} from "./lifecycle-tools.js";
2122
import { createSubAgentSessionStore } from "./session-store.js";
@@ -1072,7 +1073,7 @@ describe("interrupt_agent unblocks wait_agents", () => {
10721073
gate.resolve({ report: "done" });
10731074
});
10741075

1075-
test("send_input interrupt:true unblocks wait_agents as interrupted", async () => {
1076+
test("send_input interrupt:true keeps wait_agents live until the followup completes", async () => {
10761077
const gate = deferred<RunSubAgentResult>();
10771078
const followupGate = deferred<string>();
10781079
const deps = makeDeps(async (params) => {
@@ -1101,15 +1102,351 @@ describe("interrupt_agent unblocks wait_agents", () => {
11011102
const id = spawned.agent_id as string;
11021103
const waiting = callTool(wait, { targets: [id], timeout_ms: 5000 });
11031104
await callTool(sendInput, { target: id, message: "stop that", interrupt: true });
1105+
followupGate.resolve("later");
1106+
gate.resolve({ report: "original interrupted", interrupted: true } as RunSubAgentResult);
11041107
const waited = await waiting;
11051108
expect(waited.timed_out).toBe(false);
11061109
const results = waited.results as {
11071110
agent_id: string;
11081111
status: string;
1112+
report?: string;
11091113
stop_reason?: string;
11101114
}[];
1111-
expect(results).toEqual([{ agent_id: id, status: "interrupted", stop_reason: "interrupted" }]);
1112-
expect(deps.sessions.get(id)?.stopReason).toBe("interrupted");
1115+
expect(results[0]!.status).toBe("done");
1116+
expect(results[0]!.report).toBe("later");
1117+
});
1118+
1119+
test("CL-7331: send_input interrupt keeps wait live until the queued followup completes", async () => {
1120+
const gate = deferred<RunSubAgentResult>();
1121+
const followupGate = deferred<string>();
1122+
const deps = makeDeps(async (params) => {
1123+
params.onAgentReady?.({
1124+
close: async () => {},
1125+
interrupt: () => {},
1126+
followup: async () => followupGate.promise,
1127+
deliver: () => {},
1128+
});
1129+
return gate.promise;
1130+
});
1131+
const spawn = createSpawnAgentTool(deps);
1132+
const wait = createWaitAgentsTool({
1133+
sessions: deps.sessions,
1134+
fleetRecords: deps.fleetRecords,
1135+
});
1136+
const list = createListAgentsTool({
1137+
sessions: deps.sessions,
1138+
fleetRecords: deps.fleetRecords,
1139+
});
1140+
const sendInput = createSendInputTool({
1141+
sessions: deps.sessions,
1142+
fleetRecords: deps.fleetRecords,
1143+
});
1144+
const resume = createResumeAgentTool({
1145+
sessions: deps.sessions,
1146+
fleetRecords: deps.fleetRecords,
1147+
});
1148+
const spawned = await callTool(spawn, {
1149+
description: "looping",
1150+
prompt: "do it",
1151+
intent: "explore",
1152+
});
1153+
const id = spawned.agent_id as string;
1154+
1155+
const sent = await callTool(sendInput, {
1156+
target: id,
1157+
message: "return a concise report",
1158+
interrupt: true,
1159+
});
1160+
expect(sent.status).toBe("interrupted");
1161+
1162+
// The queued followup is still running: wait must stay live (not an
1163+
// immediate terminal interrupted), and list must agree with lifecycle.
1164+
const pending = await callTool(wait, { targets: [id], timeout_ms: 50 });
1165+
expect(pending.timed_out).toBe(true);
1166+
expect((pending.results as { status: string }[])[0]!.status).toBe("running");
1167+
1168+
const listed = await callTool(list, {});
1169+
const entry = (listed.agents as { agent_id: string; status: string; lifecycle: string }[]).find(
1170+
(a) => a.agent_id === id,
1171+
);
1172+
expect(entry?.status).toBe("running");
1173+
expect(entry?.lifecycle).toBe("running");
1174+
1175+
// A resume while the followup is in flight must agree with wait/list.
1176+
if (resume.kind !== "full") throw new Error("expected full tool");
1177+
const resumed = await resume.handler(
1178+
{
1179+
id: "resume-while-followup",
1180+
name: "resume_agent",
1181+
arguments: { target: id, message: "x" },
1182+
},
1183+
new AbortController().signal,
1184+
);
1185+
expect(resumed.isError).toBe(true);
1186+
expect(String(resumed.content)).toContain("status: running");
1187+
1188+
// When the queued followup finishes, its report must surface via wait.
1189+
followupGate.resolve("followup report");
1190+
gate.resolve({ report: "original interrupted", interrupted: true } as RunSubAgentResult);
1191+
const done = await callTool(wait, { targets: [id], timeout_ms: 5000 });
1192+
expect(done.timed_out).toBe(false);
1193+
const doneResults = done.results as { status: string; report?: string }[];
1194+
expect(doneResults[0]!.status).toBe("done");
1195+
expect(doneResults[0]!.report).toBe("followup report");
1196+
});
1197+
1198+
test("close_agent overlay survives a send_input followup completing in the close window", async () => {
1199+
const gate = deferred<RunSubAgentResult>();
1200+
const followupGate = deferred<string>();
1201+
const closeHold = deferred<undefined>();
1202+
const deps = makeDeps(async (params) => {
1203+
params.onAgentReady?.({
1204+
close: async () => closeHold.promise,
1205+
interrupt: () => {},
1206+
followup: async () => followupGate.promise,
1207+
deliver: () => {},
1208+
});
1209+
return gate.promise;
1210+
});
1211+
const spawn = createSpawnAgentTool(deps);
1212+
const wait = createWaitAgentsTool({
1213+
sessions: deps.sessions,
1214+
fleetRecords: deps.fleetRecords,
1215+
});
1216+
const sendInput = createSendInputTool({
1217+
sessions: deps.sessions,
1218+
fleetRecords: deps.fleetRecords,
1219+
});
1220+
const close = createCloseAgentTool({
1221+
sessions: deps.sessions,
1222+
fleetRecords: deps.fleetRecords,
1223+
});
1224+
const spawned = await callTool(spawn, {
1225+
description: "looping",
1226+
prompt: "do it",
1227+
intent: "explore",
1228+
});
1229+
const id = spawned.agent_id as string;
1230+
1231+
const waiting = callTool(wait, { targets: [id], timeout_ms: 5000 });
1232+
await callTool(sendInput, { target: id, message: "stop that", interrupt: true });
1233+
if (close.kind !== "full") throw new Error("expected full tool");
1234+
const closing = close.handler(
1235+
{ id: "close-during-followup", name: "close_agent", arguments: { target: id } },
1236+
new AbortController().signal,
1237+
);
1238+
followupGate.resolve("followup during close");
1239+
gate.resolve({ report: "original interrupted", interrupted: true } as RunSubAgentResult);
1240+
1241+
const waited = await waiting;
1242+
expect(waited.timed_out).toBe(false);
1243+
const results = waited.results as { status: string }[];
1244+
expect(results[0]!.status).toBe("interrupted");
1245+
expect(deps.fleetRecords.peek(id)?.status).toBe("interrupted");
1246+
1247+
closeHold.resolve(undefined);
1248+
await closing;
1249+
});
1250+
1251+
test("close overlay without in-flight wait stays interrupted after followup complete", async () => {
1252+
const gate = deferred<RunSubAgentResult>();
1253+
const followupGate = deferred<string>();
1254+
const closeHold = deferred<undefined>();
1255+
const deps = makeDeps(async (params) => {
1256+
params.onAgentReady?.({
1257+
close: async () => closeHold.promise,
1258+
interrupt: () => {},
1259+
followup: async () => followupGate.promise,
1260+
deliver: () => {},
1261+
});
1262+
return gate.promise;
1263+
});
1264+
const spawn = createSpawnAgentTool(deps);
1265+
const wait = createWaitAgentsTool({
1266+
sessions: deps.sessions,
1267+
fleetRecords: deps.fleetRecords,
1268+
});
1269+
const list = createListAgentsTool({
1270+
sessions: deps.sessions,
1271+
fleetRecords: deps.fleetRecords,
1272+
});
1273+
const sendInput = createSendInputTool({
1274+
sessions: deps.sessions,
1275+
fleetRecords: deps.fleetRecords,
1276+
});
1277+
const close = createCloseAgentTool({
1278+
sessions: deps.sessions,
1279+
fleetRecords: deps.fleetRecords,
1280+
});
1281+
const spawned = await callTool(spawn, {
1282+
description: "looping",
1283+
prompt: "do it",
1284+
intent: "explore",
1285+
});
1286+
const id = spawned.agent_id as string;
1287+
1288+
await callTool(sendInput, { target: id, message: "stop that", interrupt: true });
1289+
if (close.kind !== "full") throw new Error("expected full tool");
1290+
const closing = close.handler(
1291+
{ id: "close-held-then-followup", name: "close_agent", arguments: { target: id } },
1292+
new AbortController().signal,
1293+
);
1294+
1295+
followupGate.resolve("followup after close overlay");
1296+
await new Promise<void>((resolve) => {
1297+
const done = (): boolean => deps.sessions.get(id)?.lifecycle.state === "completed";
1298+
if (done()) {
1299+
resolve();
1300+
return;
1301+
}
1302+
const unsub = deps.sessions.subscribe(() => {
1303+
if (done()) {
1304+
unsub();
1305+
resolve();
1306+
}
1307+
});
1308+
if (done()) {
1309+
unsub();
1310+
resolve();
1311+
}
1312+
});
1313+
1314+
expect(deps.fleetRecords.peek(id)?.status).toBe("interrupted");
1315+
const listed = await callTool(list, {});
1316+
const entry = (listed.agents as { agent_id: string; status: string }[]).find(
1317+
(a) => a.agent_id === id,
1318+
);
1319+
expect(entry?.status).toBe("interrupted");
1320+
1321+
const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 });
1322+
expect(waited.timed_out).toBe(false);
1323+
const results = waited.results as { status: string }[];
1324+
expect(results[0]!.status).toBe("interrupted");
1325+
1326+
closeHold.resolve(undefined);
1327+
await closing;
1328+
gate.resolve({ report: "original interrupted", interrupted: true } as RunSubAgentResult);
1329+
});
1330+
1331+
test("completeAfterInterrupt does not clear a close overlay", () => {
1332+
const sessions = createSubAgentSessionStore();
1333+
const fleetRecords = createFleetMailbox(sessions);
1334+
const worker = sessions.start({
1335+
description: "looping",
1336+
agentId: "explorer",
1337+
brief: "b",
1338+
retained: true,
1339+
});
1340+
sessions.markRunning(worker.id);
1341+
fleetRecords.register(worker.id);
1342+
fleetRecords.noteFollowup(worker.id);
1343+
fleetRecords.interrupt(worker.id);
1344+
expect(fleetRecords.peek(worker.id)?.status).toBe("interrupted");
1345+
fleetRecords.completeAfterInterrupt(worker.id, "followup reply");
1346+
expect(fleetRecords.peek(worker.id)?.status).toBe("interrupted");
1347+
});
1348+
1349+
test("rejected send_input followup clears the lane so wait collects interrupted salvage", async () => {
1350+
const gate = deferred<RunSubAgentResult>();
1351+
const followupGate = deferred<string>();
1352+
const deps = makeDeps(async (params) => {
1353+
params.onAgentReady?.({
1354+
close: async () => {},
1355+
interrupt: () => {},
1356+
followup: async () => followupGate.promise,
1357+
deliver: () => {},
1358+
});
1359+
return gate.promise;
1360+
});
1361+
const spawn = createSpawnAgentTool(deps);
1362+
const wait = createWaitAgentsTool({
1363+
sessions: deps.sessions,
1364+
fleetRecords: deps.fleetRecords,
1365+
});
1366+
const sendInput = createSendInputTool({
1367+
sessions: deps.sessions,
1368+
fleetRecords: deps.fleetRecords,
1369+
});
1370+
const spawned = await callTool(spawn, {
1371+
description: "looping",
1372+
prompt: "do it",
1373+
intent: "explore",
1374+
});
1375+
const id = spawned.agent_id as string;
1376+
await callTool(sendInput, { target: id, message: "stop that", interrupt: true });
1377+
followupGate.reject(new Error("followup failed"));
1378+
await new Promise((resolve) => setTimeout(resolve, 20));
1379+
gate.resolve({
1380+
report: "## Summary\nStopped.\n## Findings\nsalvage\n## Blockers\ninterrupted\n## Paths\n",
1381+
interrupted: true,
1382+
} as RunSubAgentResult);
1383+
await new Promise((resolve) => setTimeout(resolve, 20));
1384+
1385+
const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 });
1386+
expect(waited.timed_out).toBe(false);
1387+
const results = waited.results as { status: string; report?: string }[];
1388+
expect(results[0]!.status).toBe("interrupted");
1389+
expect(results[0]!.report).toContain("salvage");
1390+
});
1391+
1392+
test("send_input interrupt queued overlay clears when the followup is admitted", async () => {
1393+
const admission = createAdmissionQueue({ capacity: 1 });
1394+
const sessions = createSubAgentSessionStore({ admission });
1395+
const fleetRecords = createFleetMailbox(sessions);
1396+
admission.enqueue({
1397+
id: "holder",
1398+
provider: "p",
1399+
start: () => {},
1400+
});
1401+
const worker = sessions.start({
1402+
description: "looping",
1403+
agentId: "explorer",
1404+
brief: "b",
1405+
retained: true,
1406+
provider: "p",
1407+
});
1408+
sessions.markRunning(worker.id);
1409+
fleetRecords.register(worker.id);
1410+
const followupGate = deferred<string>();
1411+
sessions.registerInterrupt(worker.id, () => {});
1412+
sessions.registerFollowup(worker.id, async () => followupGate.promise);
1413+
1414+
const sendInput = createSendInputTool({ sessions, fleetRecords });
1415+
const wait = createWaitAgentsTool({ sessions, fleetRecords });
1416+
const list = createListAgentsTool({ sessions, fleetRecords });
1417+
1418+
const sent = await callTool(sendInput, {
1419+
target: worker.id,
1420+
message: "stop that",
1421+
interrupt: true,
1422+
});
1423+
expect(sent.status).toBe("interrupted");
1424+
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("pending_init");
1425+
1426+
const queuedWait = await callTool(wait, { targets: [worker.id], timeout_ms: 50 });
1427+
expect(queuedWait.timed_out).toBe(true);
1428+
expect((queuedWait.results as { status: string }[])[0]!.status).toBe("queued");
1429+
const queuedList = await callTool(list, {});
1430+
const queuedEntry = (
1431+
queuedList.agents as { agent_id: string; status: string; lifecycle: string }[]
1432+
).find((a) => a.agent_id === worker.id);
1433+
expect(queuedEntry?.status).toBe("queued");
1434+
expect(queuedEntry?.lifecycle).toBe("pending_init");
1435+
1436+
admission.release("holder");
1437+
await new Promise((resolve) => setTimeout(resolve, 20));
1438+
1439+
expect(sessions.get(worker.id)?.lifecycleStatus).toBe("running");
1440+
const runningWait = await callTool(wait, { targets: [worker.id], timeout_ms: 50 });
1441+
expect(runningWait.timed_out).toBe(true);
1442+
expect((runningWait.results as { status: string }[])[0]!.status).toBe("running");
1443+
const runningList = await callTool(list, {});
1444+
const runningEntry = (
1445+
runningList.agents as { agent_id: string; status: string; lifecycle: string }[]
1446+
).find((a) => a.agent_id === worker.id);
1447+
expect(runningEntry?.status).toBe("running");
1448+
expect(runningEntry?.lifecycle).toBe("running");
1449+
11131450
followupGate.resolve("later");
11141451
});
11151452

0 commit comments

Comments
 (0)