Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
delivered to a caller is compacted to a status-only tombstone pointing at
`read_agent_trace` for the detail, so an uncollected report is never
evicted ahead of one that's already been picked up.
- Worker sessions spawned via `spawn_agent` now persist after their turn ends
instead of being torn down: a clean completion leaves the session open and
reusable. Added `close_agent(target)` to permanently close a session
(descendants closed first, bounded by a ~30s cleanup deadline per session
so a wedged descendant cannot hang the call) and `resume_agent(id)` to
reopen a retained, completed session. Sessions now carry an explicit
lifecycle status (`pending_init | running | interrupted | completed |
shutdown | not_found`) alongside the existing display status; a retained
session is exempt from the finished-session display cap until it is
actually closed.

## [0.2.109] - 2026-08-24

Expand Down
13 changes: 8 additions & 5 deletions src/subagent/agent-fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,11 @@ describe("spawn_agent + wait_agents", () => {
test("reports survive well past the session store's display cap (20) until wait_agents collects them", async () => {
// DEFAULT_MAX_COMPLETED on SubAgentSessionStore is 20 finished sessions;
// spawn (and complete) enough workers to blow well past it before any of
// them is collected, proving fleetRecords — not the store — is what
// wait_agents actually reads from.
// them is collected, proving fleetRecords does not depend on the store's
// cap either. CL-6943: a spawn_agent session is now retained (exempt
// from the cap) until close_agent runs, so — unlike the pre-CL-6943
// version of this test — the store also keeps every one of them; that
// is covered by session-store.test.ts's own cap tests.
const COUNT = 25;
const deps = makeDeps(async () => ({ report: "irrelevant" }));
const spawn = createSpawnAgentTool(deps);
Expand All @@ -215,10 +218,10 @@ describe("spawn_agent + wait_agents", () => {
// Let every spawn's run() resolve and complete() land before collecting.
await new Promise((resolve) => setTimeout(resolve, 20));

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

// But every single one is still retrievable through wait_agents.
// Every single one is retrievable through wait_agents too.
const waited = await callTool(wait, { targets: ids, timeout_ms: 5000 });
const results = waited.results as { agent_id: string; status: string; report?: string }[];
expect(results).toHaveLength(COUNT);
Expand Down
11 changes: 11 additions & 0 deletions src/subagent/agent-fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,10 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
description,
agentId: resolved.directorId,
brief,
// CL-6943: a spawn_agent worker's session survives a clean
// completion instead of being torn down — close_agent (or
// resume_agent, transitively) governs it from here on.
retained: true,
});
deps.fleetRecords.register(session.id);
const agentName = classifyAgentName(resolved.directorId);
Expand Down Expand Up @@ -460,6 +464,13 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
systemPromptRole: resolved.systemPromptRole,
directorId: resolved.directorId,
maxTurns: resolvedMaxTurns,
// CL-6943: keep the session open after a clean completion, and hand
// the store a bounded close for close_agent to call later.
persist: true,
onAgentReady: (close) => {
deps.sessions.registerClose(session.id, close);
deps.sessions.markRunning(session.id);
},
};

// Fire and forget: this handler must return before the worker finishes.
Expand Down
3 changes: 3 additions & 0 deletions src/subagent/authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ describe("assertTierMayMountFleetVerb", () => {
expect(() => assertTierMayMountFleetVerb("leaf", "task")).toThrow(FleetAuthorityError);
expect(() => assertTierMayMountFleetVerb("leaf", "search_agents")).toThrow(FleetAuthorityError);
expect(() => assertTierMayMountFleetVerb("leaf", "spawn_agent")).toThrow(FleetAuthorityError);
// CL-6943: the reusable-session verbs are gated the same way.
expect(() => assertTierMayMountFleetVerb("leaf", "close_agent")).toThrow(FleetAuthorityError);
expect(() => assertTierMayMountFleetVerb("leaf", "resume_agent")).toThrow(FleetAuthorityError);
});

test("leaves may still mount non-fleet tools", () => {
Expand Down
7 changes: 7 additions & 0 deletions src/subagent/dispose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ export function isSubAgentCancelError(err: unknown, signal?: AbortSignal): boole
/** Wall-clock wait for in-flight plugin tool calls to finish before posix dispose. */
export const SUBAGENT_SPAWN_DRAIN_MS = 2_000;

/**
* Bounded cleanup deadline for close_agent (CL-6943): a wedged descendant's
* teardown is abandoned (not awaited further), not a reason to hang the
* caller.
*/
export const DEFAULT_CLOSE_DEADLINE_MS = 30_000;

/**
* Honest limits for plugin-spawn teardown (for operator docs and output notes).
* Corbits Code can dispose posix tools and LSP sidecars per sub-agent session; OS
Expand Down
103 changes: 103 additions & 0 deletions src/subagent/lifecycle-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, test } from "bun:test";

import { createCloseAgentTool, createResumeAgentTool } from "./lifecycle-tools.js";
import { createSubAgentSessionStore } from "./session-store.js";

async function callTool(
tool: ReturnType<typeof createCloseAgentTool> | ReturnType<typeof createResumeAgentTool>,
args: Record<string, unknown>,
): Promise<Record<string, unknown>> {
if (tool.kind !== "full") throw new Error(`expected full tool, got ${tool.kind}`);
const result = await tool.handler(
{ id: `call-${Math.random()}`, name: tool.definition.name, arguments: args },
new AbortController().signal,
);
const content =
typeof result.content === "string" ? result.content : JSON.stringify(result.content);
return JSON.parse(content);
}

describe("close_agent", () => {
test("closes descendants before the parent, and reports not_found for an unknown target", async () => {
const sessions = createSubAgentSessionStore();
const parent = sessions.start({ description: "parent", agentId: "a", brief: "b" });
const child = sessions.start({
description: "child",
agentId: "a",
brief: "b",
parentSessionId: parent.id,
});
const grandchild = sessions.start({
description: "grandchild",
agentId: "a",
brief: "b",
parentSessionId: child.id,
});

const closedOrder: string[] = [];
for (const id of [parent.id, child.id, grandchild.id]) {
sessions.registerClose(id, async () => {
closedOrder.push(id);
});
}

const closeAgent = createCloseAgentTool({ sessions });
const result = await callTool(closeAgent, { target: parent.id });

expect(result.status).toBe("shutdown");
// Descendants close before their ancestor: grandchild, then child, then parent.
expect(closedOrder).toEqual([grandchild.id, child.id, parent.id]);
expect(sessions.get(parent.id)?.lifecycleStatus).toBe("shutdown");
expect(sessions.get(child.id)?.lifecycleStatus).toBe("shutdown");
expect(sessions.get(grandchild.id)?.lifecycleStatus).toBe("shutdown");

const missing = await callTool(closeAgent, { target: "does-not-exist" });
expect(missing.status).toBe("not_found");
});

test("a wedged descendant hits its own deadline instead of hanging the whole close", async () => {
const sessions = createSubAgentSessionStore();
const parent = sessions.start({ description: "parent", agentId: "a", brief: "b" });
const wedgedChild = sessions.start({
description: "child",
agentId: "a",
brief: "b",
parentSessionId: parent.id,
});
sessions.registerClose(wedgedChild.id, () => new Promise<void>(() => {}));
sessions.registerClose(parent.id, async () => {});

// Exercise the store directly with a short deadline (the tool itself
// uses the real ~30s bound, which would make this test slow).
const started = Date.now();
const childStatus = await sessions.closeOne(wedgedChild.id, 25);
expect(Date.now() - started).toBeLessThan(500);
expect(childStatus).toBe("shutdown");
});
});

describe("resume_agent", () => {
test("resumes a retained completed session and rejects a non-retained one", async () => {
const sessions = createSubAgentSessionStore();
const retained = sessions.start({ description: "d", agentId: "a", brief: "b", retained: true });
sessions.complete(retained.id, "## Summary\nDone.");

const notRetained = sessions.start({ description: "d2", agentId: "a", brief: "b" });
sessions.complete(notRetained.id, "## Summary\nDone.");

const resumeAgent = createResumeAgentTool({ sessions });

const ok = await callTool(resumeAgent, { target: retained.id });
expect(ok.status).toBe("running");
expect(sessions.get(retained.id)?.lifecycleStatus).toBe("running");

const rawResult = await (async () => {
if (resumeAgent.kind !== "full") throw new Error("expected full tool");
return resumeAgent.handler(
{ id: "call-x", name: "resume_agent", arguments: { target: notRetained.id } },
new AbortController().signal,
);
})();
expect(rawResult.isError).toBe(true);
});
});
147 changes: 147 additions & 0 deletions src/subagent/lifecycle-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* close_agent / resume_agent (CL-6943): the session-lifecycle half of
* reusable worker sessions. spawn_agent/wait_agents (CL-6942) start and
* collect workers; these two verbs let an orchestrator tear one down on
* purpose (close_agent) or bring a retained one back for further input
* (resume_agent), instead of every session dying the instant its turn ends.
*
* interrupt_agent and followup_task (the verbs that actually push a new
* prompt into a resumed session) are a separate, later change — resume_agent
* here only flips a retained session back to an addressable state; it takes
* no prompt argument.
*/

import { tool } from "@intx/agent";
import type { AgentTool } from "@intx/agent";
import { type } from "arktype";
import type { ToolDefinition, ToolResult } from "@intx/types/runtime";

import { DEFAULT_CLOSE_DEADLINE_MS } from "./dispose.js";
import type { AgentLifecycleStatus, SubAgentSessionStore } from "./session-store.js";

function lifecycleResult(callId: string, content: string): ToolResult {
const isError = content.startsWith("Error:");
return { callId, content, ...(isError ? { isError: true } : {}) };
}

const CloseAgentArgs = type({
target: "string",
});

export const closeAgentToolDefinition: ToolDefinition = {
name: "close_agent",
description:
"Permanently close a worker session by agent_id, closing its descendants first. Bounded " +
`by a ~${Math.round(DEFAULT_CLOSE_DEADLINE_MS / 1000)}s cleanup deadline per session so a wedged worker cannot hang ` +
"this call — a session that misses the deadline is still marked shutdown; its teardown just " +
"keeps running in the background. Closing is permanent: a closed session cannot be resumed.",
inputSchema: {
type: "object",
properties: {
target: { type: "string", description: "agent_id of the session to close." },
},
required: ["target"],
},
};

const ResumeAgentArgs = type({
target: "string",
});

export const resumeAgentToolDefinition: ToolDefinition = {
name: "resume_agent",
description:
"Reopen a retained, completed worker session (one that finished a turn and was never closed) " +
"so it is addressable again. Fails on a session that is still running, was never retained, was " +
"interrupted, or was already closed via close_agent (closing is permanent).",
inputSchema: {
type: "object",
properties: {
target: { type: "string", description: "agent_id of the session to resume." },
},
required: ["target"],
},
};

/** Every id in `target`'s subtree (nodes with target somewhere up their parentSessionId chain), deepest first, target last. */
function descendantsClosingOrder(
nodes: readonly { id: string; parentSessionId?: string | undefined }[],
target: string,
): string[] {
const children = new Map<string, string[]>();
for (const node of nodes) {
if (node.parentSessionId === undefined) continue;
const siblings = children.get(node.parentSessionId) ?? [];
siblings.push(node.id);
children.set(node.parentSessionId, siblings);
}
const order: string[] = [];
const visit = (id: string): void => {
for (const child of children.get(id) ?? []) visit(child);
order.push(id);
};
visit(target);
return order;
}

export interface LifecycleToolDeps {
sessions: SubAgentSessionStore;
}

export function createCloseAgentTool(deps: LifecycleToolDeps): AgentTool {
return tool({
definition: closeAgentToolDefinition,
handler: async (call, _signal): Promise<ToolResult> => {
const parsed = CloseAgentArgs(call.arguments);
if (parsed instanceof type.errors) {
return lifecycleResult(call.id, `Error: close_agent arguments invalid: ${parsed.summary}`);
}
const target = parsed.target.trim();
if (deps.sessions.get(target) === undefined) {
return lifecycleResult(
call.id,
JSON.stringify({ agent_id: target, status: "not_found" satisfies AgentLifecycleStatus }),
);
}
const nodes = deps.sessions
.list()
.map((s) => ({ id: s.id, parentSessionId: s.parentSessionId }));
const order = descendantsClosingOrder(nodes, target);
const closed: { agent_id: string; status: AgentLifecycleStatus }[] = [];
for (const id of order) {
const status = await deps.sessions.closeOne(id, DEFAULT_CLOSE_DEADLINE_MS);
closed.push({ agent_id: id, status });
}
const own = closed.find((c) => c.agent_id === target);
return lifecycleResult(
call.id,
JSON.stringify({
agent_id: target,
status: own?.status ?? "shutdown",
closed,
}),
);
},
});
}

export function createResumeAgentTool(deps: LifecycleToolDeps): AgentTool {
return tool({
definition: resumeAgentToolDefinition,
handler: async (call, _signal): Promise<ToolResult> => {
const parsed = ResumeAgentArgs(call.arguments);
if (parsed instanceof type.errors) {
return lifecycleResult(call.id, `Error: resume_agent arguments invalid: ${parsed.summary}`);
}
const target = parsed.target.trim();
const outcome = deps.sessions.resumeOne(target);
if (!outcome.ok) {
return lifecycleResult(
call.id,
`Error: cannot resume "${target}" (status: ${outcome.status}).`,
);
}
return lifecycleResult(call.id, JSON.stringify({ agent_id: target, status: "running" }));
},
});
}
Loading
Loading