Skip to content

Commit 387291e

Browse files
committed
Add reusable worker sessions: close_agent / resume_agent (CL-6943)
Worker sessions from spawn_agent no longer tear down on a clean completion — they stay open and retained until close_agent runs. close_agent(target) permanently closes a session (descendants first, bounded by a ~30s deadline per session so a wedged descendant cannot hang the call); resume_agent(id) reopens a retained, completed session. A new AgentLifecycleStatus enum (pending_init/running/interrupted/ completed/shutdown/not_found) tracks this independent of the existing TUI display status, and a retained session is exempt from the finished-session display cap until it is actually closed. interrupt_agent and followup_task are a separate, later change.
1 parent 1fcdd40 commit 387291e

12 files changed

Lines changed: 597 additions & 13 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,21 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### Agent
17+
18+
- Worker sessions spawned via `spawn_agent` now persist after their turn ends
19+
instead of being torn down: a clean completion leaves the session open and
20+
reusable. Added `close_agent(target)` to permanently close a session
21+
(descendants closed first, bounded by a ~30s cleanup deadline per session
22+
so a wedged descendant cannot hang the call) and `resume_agent(id)` to
23+
reopen a retained, completed session. Sessions now carry an explicit
24+
lifecycle status (`pending_init | running | interrupted | completed |
25+
shutdown | not_found`) alongside the existing display status; a retained
26+
session is exempt from the finished-session display cap until it is
27+
actually closed.
28+
1429
## [0.2.109] - 2026-08-24
1530

1631
### Agent

src/subagent/agent-fleet.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,11 @@ describe("spawn_agent + wait_agents", () => {
194194
test("reports survive well past the session store's display cap (20) until wait_agents collects them", async () => {
195195
// DEFAULT_MAX_COMPLETED on SubAgentSessionStore is 20 finished sessions;
196196
// spawn (and complete) enough workers to blow well past it before any of
197-
// them is collected, proving fleetRecords — not the store — is what
198-
// wait_agents actually reads from.
197+
// them is collected, proving fleetRecords does not depend on the store's
198+
// cap either. CL-6943: a spawn_agent session is now retained (exempt
199+
// from the cap) until close_agent runs, so — unlike the pre-CL-6943
200+
// version of this test — the store also keeps every one of them; that
201+
// is covered by session-store.test.ts's own cap tests.
199202
const COUNT = 25;
200203
const deps = makeDeps(async () => ({ report: "irrelevant" }));
201204
const spawn = createSpawnAgentTool(deps);
@@ -214,10 +217,10 @@ describe("spawn_agent + wait_agents", () => {
214217
// Let every spawn's run() resolve and complete() land before collecting.
215218
await new Promise((resolve) => setTimeout(resolve, 20));
216219

217-
// The store itself has already evicted all but the most recent 20.
218-
expect(deps.sessions.get(ids[0]!)).toBeUndefined();
220+
// Retained sessions are exempt from the display cap.
221+
expect(deps.sessions.get(ids[0]!)).toBeDefined();
219222

220-
// But every single one is still retrievable through wait_agents.
223+
// Every single one is retrievable through wait_agents too.
221224
const waited = await callTool(wait, { targets: ids, timeout_ms: 5000 });
222225
const results = waited.results as { agent_id: string; status: string; report?: string }[];
223226
expect(results).toHaveLength(COUNT);

src/subagent/agent-fleet.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,10 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
394394
description,
395395
agentId: resolved.directorId,
396396
brief,
397+
// CL-6943: a spawn_agent worker's session survives a clean
398+
// completion instead of being torn down — close_agent (or
399+
// resume_agent, transitively) governs it from here on.
400+
retained: true,
397401
});
398402
deps.fleetRecords.register(session.id);
399403
if (isWriteRisk) {
@@ -450,6 +454,13 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
450454
systemPromptRole: resolved.systemPromptRole,
451455
directorId: resolved.directorId,
452456
maxTurns: resolvedMaxTurns,
457+
// CL-6943: keep the session open after a clean completion, and hand
458+
// the store a bounded close for close_agent to call later.
459+
persist: true,
460+
onAgentReady: (close) => {
461+
deps.sessions.registerClose(session.id, close);
462+
deps.sessions.markRunning(session.id);
463+
},
453464
};
454465

455466
// Fire and forget: this handler must return before the worker finishes.

src/subagent/authority.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ describe("assertTierMayMountFleetVerb", () => {
1111
expect(() => assertTierMayMountFleetVerb("leaf", "task")).toThrow(FleetAuthorityError);
1212
expect(() => assertTierMayMountFleetVerb("leaf", "search_agents")).toThrow(FleetAuthorityError);
1313
expect(() => assertTierMayMountFleetVerb("leaf", "spawn_agent")).toThrow(FleetAuthorityError);
14+
// CL-6943: the reusable-session verbs are gated the same way.
15+
expect(() => assertTierMayMountFleetVerb("leaf", "close_agent")).toThrow(FleetAuthorityError);
16+
expect(() => assertTierMayMountFleetVerb("leaf", "resume_agent")).toThrow(FleetAuthorityError);
1417
});
1518

1619
test("leaves may still mount non-fleet tools", () => {

src/subagent/dispose.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ export function isSubAgentCancelError(err: unknown, signal?: AbortSignal): boole
3030
/** Wall-clock wait for in-flight plugin tool calls to finish before posix dispose. */
3131
export const SUBAGENT_SPAWN_DRAIN_MS = 2_000;
3232

33+
/**
34+
* Bounded cleanup deadline for close_agent (CL-6943): a wedged descendant's
35+
* teardown is abandoned (not awaited further), not a reason to hang the
36+
* caller.
37+
*/
38+
export const DEFAULT_CLOSE_DEADLINE_MS = 30_000;
39+
3340
/**
3441
* Honest limits for plugin-spawn teardown (for operator docs and output notes).
3542
* Corbits Code can dispose posix tools and LSP sidecars per sub-agent session; OS
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { createCloseAgentTool, createResumeAgentTool } from "./lifecycle-tools.js";
4+
import { createSubAgentSessionStore } from "./session-store.js";
5+
6+
async function callTool(
7+
tool: ReturnType<typeof createCloseAgentTool> | ReturnType<typeof createResumeAgentTool>,
8+
args: Record<string, unknown>,
9+
): Promise<Record<string, unknown>> {
10+
if (tool.kind !== "full") throw new Error(`expected full tool, got ${tool.kind}`);
11+
const result = await tool.handler(
12+
{ id: `call-${Math.random()}`, name: tool.definition.name, arguments: args },
13+
new AbortController().signal,
14+
);
15+
const content =
16+
typeof result.content === "string" ? result.content : JSON.stringify(result.content);
17+
return JSON.parse(content);
18+
}
19+
20+
describe("close_agent", () => {
21+
test("closes descendants before the parent, and reports not_found for an unknown target", async () => {
22+
const sessions = createSubAgentSessionStore();
23+
const parent = sessions.start({ description: "parent", agentId: "a", brief: "b" });
24+
const child = sessions.start({
25+
description: "child",
26+
agentId: "a",
27+
brief: "b",
28+
parentSessionId: parent.id,
29+
});
30+
const grandchild = sessions.start({
31+
description: "grandchild",
32+
agentId: "a",
33+
brief: "b",
34+
parentSessionId: child.id,
35+
});
36+
37+
const closedOrder: string[] = [];
38+
for (const id of [parent.id, child.id, grandchild.id]) {
39+
sessions.registerClose(id, async () => {
40+
closedOrder.push(id);
41+
});
42+
}
43+
44+
const closeAgent = createCloseAgentTool({ sessions });
45+
const result = await callTool(closeAgent, { target: parent.id });
46+
47+
expect(result.status).toBe("shutdown");
48+
// Descendants close before their ancestor: grandchild, then child, then parent.
49+
expect(closedOrder).toEqual([grandchild.id, child.id, parent.id]);
50+
expect(sessions.get(parent.id)?.lifecycleStatus).toBe("shutdown");
51+
expect(sessions.get(child.id)?.lifecycleStatus).toBe("shutdown");
52+
expect(sessions.get(grandchild.id)?.lifecycleStatus).toBe("shutdown");
53+
54+
const missing = await callTool(closeAgent, { target: "does-not-exist" });
55+
expect(missing.status).toBe("not_found");
56+
});
57+
58+
test("a wedged descendant hits its own deadline instead of hanging the whole close", async () => {
59+
const sessions = createSubAgentSessionStore();
60+
const parent = sessions.start({ description: "parent", agentId: "a", brief: "b" });
61+
const wedgedChild = sessions.start({
62+
description: "child",
63+
agentId: "a",
64+
brief: "b",
65+
parentSessionId: parent.id,
66+
});
67+
sessions.registerClose(wedgedChild.id, () => new Promise<void>(() => {}));
68+
sessions.registerClose(parent.id, async () => {});
69+
70+
// Exercise the store directly with a short deadline (the tool itself
71+
// uses the real ~30s bound, which would make this test slow).
72+
const started = Date.now();
73+
const childStatus = await sessions.closeOne(wedgedChild.id, 25);
74+
expect(Date.now() - started).toBeLessThan(500);
75+
expect(childStatus).toBe("shutdown");
76+
});
77+
});
78+
79+
describe("resume_agent", () => {
80+
test("resumes a retained completed session and rejects a non-retained one", async () => {
81+
const sessions = createSubAgentSessionStore();
82+
const retained = sessions.start({ description: "d", agentId: "a", brief: "b", retained: true });
83+
sessions.complete(retained.id, "## Summary\nDone.");
84+
85+
const notRetained = sessions.start({ description: "d2", agentId: "a", brief: "b" });
86+
sessions.complete(notRetained.id, "## Summary\nDone.");
87+
88+
const resumeAgent = createResumeAgentTool({ sessions });
89+
90+
const ok = await callTool(resumeAgent, { target: retained.id });
91+
expect(ok.status).toBe("running");
92+
expect(sessions.get(retained.id)?.lifecycleStatus).toBe("running");
93+
94+
const rawResult = await (async () => {
95+
if (resumeAgent.kind !== "full") throw new Error("expected full tool");
96+
return resumeAgent.handler(
97+
{ id: "call-x", name: "resume_agent", arguments: { target: notRetained.id } },
98+
new AbortController().signal,
99+
);
100+
})();
101+
expect(rawResult.isError).toBe(true);
102+
});
103+
});

src/subagent/lifecycle-tools.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* close_agent / resume_agent (CL-6943): the session-lifecycle half of
3+
* reusable worker sessions. spawn_agent/wait_agents (CL-6942) start and
4+
* collect workers; these two verbs let an orchestrator tear one down on
5+
* purpose (close_agent) or bring a retained one back for further input
6+
* (resume_agent), instead of every session dying the instant its turn ends.
7+
*
8+
* interrupt_agent and followup_task (the verbs that actually push a new
9+
* prompt into a resumed session) are a separate, later change — resume_agent
10+
* here only flips a retained session back to an addressable state; it takes
11+
* no prompt argument.
12+
*/
13+
14+
import { tool } from "@intx/agent";
15+
import type { AgentTool } from "@intx/agent";
16+
import { type } from "arktype";
17+
import type { ToolDefinition, ToolResult } from "@intx/types/runtime";
18+
19+
import { DEFAULT_CLOSE_DEADLINE_MS } from "./dispose.js";
20+
import type { AgentLifecycleStatus, SubAgentSessionStore } from "./session-store.js";
21+
22+
function lifecycleResult(callId: string, content: string): ToolResult {
23+
const isError = content.startsWith("Error:");
24+
return { callId, content, ...(isError ? { isError: true } : {}) };
25+
}
26+
27+
const CloseAgentArgs = type({
28+
target: "string",
29+
});
30+
31+
export const closeAgentToolDefinition: ToolDefinition = {
32+
name: "close_agent",
33+
description:
34+
"Permanently close a worker session by agent_id, closing its descendants first. Bounded " +
35+
`by a ~${Math.round(DEFAULT_CLOSE_DEADLINE_MS / 1000)}s cleanup deadline per session so a wedged worker cannot hang ` +
36+
"this call — a session that misses the deadline is still marked shutdown; its teardown just " +
37+
"keeps running in the background. Closing is permanent: a closed session cannot be resumed.",
38+
inputSchema: {
39+
type: "object",
40+
properties: {
41+
target: { type: "string", description: "agent_id of the session to close." },
42+
},
43+
required: ["target"],
44+
},
45+
};
46+
47+
const ResumeAgentArgs = type({
48+
target: "string",
49+
});
50+
51+
export const resumeAgentToolDefinition: ToolDefinition = {
52+
name: "resume_agent",
53+
description:
54+
"Reopen a retained, completed worker session (one that finished a turn and was never closed) " +
55+
"so it is addressable again. Fails on a session that is still running, was never retained, was " +
56+
"interrupted, or was already closed via close_agent (closing is permanent).",
57+
inputSchema: {
58+
type: "object",
59+
properties: {
60+
target: { type: "string", description: "agent_id of the session to resume." },
61+
},
62+
required: ["target"],
63+
},
64+
};
65+
66+
/** Every id in `target`'s subtree (nodes with target somewhere up their parentSessionId chain), deepest first, target last. */
67+
function descendantsClosingOrder(
68+
nodes: readonly { id: string; parentSessionId?: string | undefined }[],
69+
target: string,
70+
): string[] {
71+
const children = new Map<string, string[]>();
72+
for (const node of nodes) {
73+
if (node.parentSessionId === undefined) continue;
74+
const siblings = children.get(node.parentSessionId) ?? [];
75+
siblings.push(node.id);
76+
children.set(node.parentSessionId, siblings);
77+
}
78+
const order: string[] = [];
79+
const visit = (id: string): void => {
80+
for (const child of children.get(id) ?? []) visit(child);
81+
order.push(id);
82+
};
83+
visit(target);
84+
return order;
85+
}
86+
87+
export interface LifecycleToolDeps {
88+
sessions: SubAgentSessionStore;
89+
}
90+
91+
export function createCloseAgentTool(deps: LifecycleToolDeps): AgentTool {
92+
return tool({
93+
definition: closeAgentToolDefinition,
94+
handler: async (call, _signal): Promise<ToolResult> => {
95+
const parsed = CloseAgentArgs(call.arguments);
96+
if (parsed instanceof type.errors) {
97+
return lifecycleResult(call.id, `Error: close_agent arguments invalid: ${parsed.summary}`);
98+
}
99+
const target = parsed.target.trim();
100+
if (deps.sessions.get(target) === undefined) {
101+
return lifecycleResult(
102+
call.id,
103+
JSON.stringify({ agent_id: target, status: "not_found" satisfies AgentLifecycleStatus }),
104+
);
105+
}
106+
const nodes = deps.sessions
107+
.list()
108+
.map((s) => ({ id: s.id, parentSessionId: s.parentSessionId }));
109+
const order = descendantsClosingOrder(nodes, target);
110+
const closed: { agent_id: string; status: AgentLifecycleStatus }[] = [];
111+
for (const id of order) {
112+
const status = await deps.sessions.closeOne(id, DEFAULT_CLOSE_DEADLINE_MS);
113+
closed.push({ agent_id: id, status });
114+
}
115+
const own = closed.find((c) => c.agent_id === target);
116+
return lifecycleResult(
117+
call.id,
118+
JSON.stringify({
119+
agent_id: target,
120+
status: own?.status ?? "shutdown",
121+
closed,
122+
}),
123+
);
124+
},
125+
});
126+
}
127+
128+
export function createResumeAgentTool(deps: LifecycleToolDeps): AgentTool {
129+
return tool({
130+
definition: resumeAgentToolDefinition,
131+
handler: async (call, _signal): Promise<ToolResult> => {
132+
const parsed = ResumeAgentArgs(call.arguments);
133+
if (parsed instanceof type.errors) {
134+
return lifecycleResult(call.id, `Error: resume_agent arguments invalid: ${parsed.summary}`);
135+
}
136+
const target = parsed.target.trim();
137+
const outcome = deps.sessions.resumeOne(target);
138+
if (!outcome.ok) {
139+
return lifecycleResult(
140+
call.id,
141+
`Error: cannot resume "${target}" (status: ${outcome.status}).`,
142+
);
143+
}
144+
return lifecycleResult(call.id, JSON.stringify({ agent_id: target, status: "running" }));
145+
},
146+
});
147+
}

0 commit comments

Comments
 (0)