diff --git a/CHANGELOG.md b/CHANGELOG.md index 1998a9b42..d7e6e5918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index de59d1d12..bcc588d4c 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -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); @@ -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); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 875d3f5f4..7a5d18b41 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -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); @@ -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. diff --git a/src/subagent/authority.test.ts b/src/subagent/authority.test.ts index cd1e69484..4826eaeb1 100644 --- a/src/subagent/authority.test.ts +++ b/src/subagent/authority.test.ts @@ -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", () => { diff --git a/src/subagent/dispose.ts b/src/subagent/dispose.ts index 73f3935b0..377e1c692 100644 --- a/src/subagent/dispose.ts +++ b/src/subagent/dispose.ts @@ -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 diff --git a/src/subagent/lifecycle-tools.test.ts b/src/subagent/lifecycle-tools.test.ts new file mode 100644 index 000000000..b3ea937a3 --- /dev/null +++ b/src/subagent/lifecycle-tools.test.ts @@ -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 | ReturnType, + args: Record, +): Promise> { + 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(() => {})); + 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); + }); +}); diff --git a/src/subagent/lifecycle-tools.ts b/src/subagent/lifecycle-tools.ts new file mode 100644 index 000000000..8f317e821 --- /dev/null +++ b/src/subagent/lifecycle-tools.ts @@ -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(); + 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 => { + 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 => { + 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" })); + }, + }); +} diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 2e2a33296..5084042d4 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -103,9 +103,11 @@ import { createSubAgentSpawnRegistryPlugin, disposeSubAgentSession, isSubAgentCancelError, + DEFAULT_CLOSE_DEADLINE_MS, } from "./dispose.js"; import { createTaskTool } from "./task-tool.js"; import { createFleetRecords, createSpawnAgentTool, createWaitAgentsTool } from "./agent-fleet.js"; +import { createCloseAgentTool, createResumeAgentTool } from "./lifecycle-tools.js"; import { createSubAgentSessionStore } from "./session-store.js"; import type { RunSubAgentParams, RunSubAgentResult, SubAgentProvider } from "./types.js"; import type { TaskIntent } from "./report.js"; @@ -324,6 +326,9 @@ export async function runSubAgent(params: RunSubAgentParams): Promise> | null = null; let streamPromise: Promise | undefined; let closeOnAbort: (() => void) | undefined; + // Set only on the clean-completion return path; read by the finally block + // to decide whether a persisted session's teardown is skipped (CL-6943). + let turnSucceeded = false; // Declared before try (same reasoning as closeOnAbort above): assigned once // requestContinuation/modelFamilyPolicy exist inside the try, but must be // visible to the finally block, which is a sibling scope, not a child. @@ -457,6 +462,8 @@ export async function runSubAgent(params: RunSubAgentParams): Promise => { + if (!runController.signal.aborted) runController.abort(new Error("closed by close_agent")); + const teardown = disposeSubAgentSession({ + signal: runController.signal, + ...(closeOnAbort !== undefined ? { closeOnAbort } : {}), + agent, + ...(streamPromise !== undefined ? { streamPromise } : {}), + posixTools, + }).catch(() => { + // Best-effort: a wedged descendant must not reject the caller. + }); + await Promise.race([ + teardown, + new Promise((resolve) => setTimeout(resolve, deadlineMs)), + ]); + }; + params.onAgentReady(boundedClose); + } + const fullPrompt = buildDispatchBrief({ description: params.description, prompt: params.prompt, @@ -824,6 +860,10 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { expect(store.get(bare.id)?.stopReason).toBe("cancelled"); }); }); + +describe("CL-6943 reusable worker sessions", () => { + test("a completed retained session stays open and reusable; resume_agent reopens it", () => { + const store = createSubAgentSessionStore(); + const session = store.start({ description: "d", agentId: "a", brief: "b", retained: true }); + store.markRunning(session.id); + expect(store.get(session.id)?.lifecycleStatus).toBe("running"); + + store.complete(session.id, "## Summary\nDone."); + expect(store.get(session.id)?.lifecycleStatus).toBe("completed"); + expect(store.get(session.id)?.retained).toBe(true); + + const outcome = store.resumeOne(session.id); + expect(outcome).toEqual({ ok: true }); + expect(store.get(session.id)?.lifecycleStatus).toBe("running"); + }); + + test("resume_agent fails on a session that was never retained", () => { + const store = createSubAgentSessionStore(); + const session = store.start({ description: "d", agentId: "a", brief: "b" }); + store.complete(session.id, "## Summary\nDone."); + expect(store.resumeOne(session.id)).toEqual({ ok: false, status: "completed" }); + }); + + test("resume_agent fails on an unknown id with not_found", () => { + const store = createSubAgentSessionStore(); + expect(store.resumeOne("missing")).toEqual({ ok: false, status: "not_found" }); + }); + + test("closeOne is bounded by its deadline when the registered close hangs forever", async () => { + const store = createSubAgentSessionStore(); + const session = store.start({ description: "d", agentId: "a", brief: "b", retained: true }); + store.registerClose(session.id, () => new Promise(() => {})); // never resolves + + const started = Date.now(); + const status = await store.closeOne(session.id, 25); + expect(Date.now() - started).toBeLessThan(500); + expect(status).toBe("shutdown"); + expect(store.get(session.id)?.lifecycleStatus).toBe("shutdown"); + expect(store.get(session.id)?.retained).toBe(false); + }); + + test("closeOne is idempotent and returns not_found for an unknown id", async () => { + const store = createSubAgentSessionStore(); + const session = store.start({ description: "d", agentId: "a", brief: "b", retained: true }); + let closeCalls = 0; + store.registerClose(session.id, async () => { + closeCalls += 1; + }); + + expect(await store.closeOne(session.id, 1000)).toBe("shutdown"); + expect(await store.closeOne(session.id, 1000)).toBe("shutdown"); + expect(closeCalls).toBe(1); + expect(await store.closeOne("missing", 1000)).toBe("not_found"); + }); + + test("resume_agent fails on a session close_agent already shut down (close is permanent)", async () => { + const store = createSubAgentSessionStore(); + const session = store.start({ description: "d", agentId: "a", brief: "b", retained: true }); + store.complete(session.id, "## Summary\nDone."); + await store.closeOne(session.id, 1000); + expect(store.resumeOne(session.id)).toEqual({ ok: false, status: "shutdown" }); + }); + + test("pruneCompleted does not evict a retained, still-open session past maxCompleted", () => { + const store = createSubAgentSessionStore({ maxCompleted: 1 }); + const retained = store.start({ + description: "keep-me", + agentId: "a", + brief: "b", + retained: true, + }); + store.complete(retained.id, "## Summary\nDone."); + + for (let i = 0; i < 3; i++) { + const s = store.start({ description: `fill-${i}`, agentId: "a", brief: "b" }); + store.complete(s.id, "## Summary\nDone."); + } + + expect(store.get(retained.id)).toBeDefined(); + expect(store.get(retained.id)?.lifecycleStatus).toBe("completed"); + }); + + test("once closed, a retained session becomes a normal finished record subject to the cap", async () => { + const store = createSubAgentSessionStore({ maxCompleted: 1 }); + const retained = store.start({ + description: "keep-me", + agentId: "a", + brief: "b", + retained: true, + }); + store.complete(retained.id, "## Summary\nDone."); + await store.closeOne(retained.id, 1000); + + for (let i = 0; i < 3; i++) { + const s = store.start({ description: `fill-${i}`, agentId: "a", brief: "b" }); + store.complete(s.id, "## Summary\nDone."); + } + + // No longer exempt — the cap may have evicted it like any other record. + expect(store.get(retained.id)).toBeUndefined(); + }); +}); diff --git a/src/subagent/session-store.ts b/src/subagent/session-store.ts index bad5e7469..d83aff8b7 100644 --- a/src/subagent/session-store.ts +++ b/src/subagent/session-store.ts @@ -10,6 +10,17 @@ import { toolCallPreview } from "./tool-preview.js"; export type SubAgentSessionStatus = "running" | "done" | "failed" | "cancelled"; +/** + * CL-6943: lifecycle status surfaced to the parent for the reusable-session + * verbs (close_agent / resume_agent), independent of `SubAgentSessionStatus` + * above (which is the older TUI-transcript status and is left alone here). + * `interrupted` is not produced by anything in this lane — `cancel()` sets + * it today (the pre-existing operator-cancel path), and the interrupt_agent + * lane lands later reusing the same value, not a new one. + */ +export type AgentLifecycleStatus = + "pending_init" | "running" | "interrupted" | "completed" | "shutdown" | "not_found"; + // Compact transcript entries suitable for TUI render without depending on the // TUI ContentBlock type (keeps subagent free of a reverse dependency on tui/). export type SubAgentTranscriptEntry = @@ -76,6 +87,14 @@ export interface SubAgentSession { // a nested (one-hop) dispatch. Undefined for top-level sessions started // directly from the primary session's task tool. parentSessionId?: string; + // CL-6943: lifecycle status for the reusable-session verbs. Defaults to + // "pending_init" until the run wires up markRunning(); see the type doc. + lifecycleStatus: AgentLifecycleStatus; + // True when this session's agent is meant to survive a clean completion + // (spawn_agent opts in). Only a retained session in lifecycleStatus + // "completed" is exempt from pruneCompleted's cap — once close_agent runs, + // this flips back to false and the cap applies normally. + retained?: boolean; } export interface StartSessionInput { @@ -88,6 +107,8 @@ export interface StartSessionInput { // Set when this session is a nested dispatch spawned by an orchestrator // sub-agent, so the strip can render it indented under its parent. parentSessionId?: string; + // CL-6943: opt in to end-of-turn retention (spawn_agent sets this). + retained?: boolean; } export interface SubAgentSessionStoreOptions { @@ -119,6 +140,25 @@ export interface SubAgentSessionStore { cancel(id: string, reason?: string): boolean; // Cancel every running session. Returns the ids that transitioned. cancelAll(reason?: string): string[]; + // CL-6943: flips a "pending_init" session to "running" once its agent + // object actually exists. No-op on an unknown id or one already past init. + markRunning(id: string): void; + // Registers the bounded close function close_agent will call later. Only + // one is kept per id (a later call replaces an earlier one, matching + // start()'s replace-on-reuse behavior for cancelHandles). + registerClose(id: string, close: (deadlineMs?: number) => Promise): void; + // Runs the registered close for one session (bounded by deadlineMs) and + // marks it "shutdown" — terminal, and no longer exempt from pruneCompleted. + // Idempotent: closing an already-shutdown session is a no-op. Resolves + // "not_found" for an unknown id without throwing (callers need the status, + // not an exception, to report per-target results across a descendant walk). + closeOne(id: string, deadlineMs: number): Promise; + // Transitions a retained, still-open ("completed") session back to + // "running" for further input. Fails closed on anything else — a + // "shutdown" session is gone for good (close_agent is permanent), an + // "interrupted" one already tore its agent down, and "running"/ + // "pending_init"/"not_found" have nothing to resume. + resumeOne(id: string): { ok: true } | { ok: false; status: AgentLifecycleStatus }; subscribe(listener: () => void): () => void; clear(): void; } @@ -244,6 +284,10 @@ export function createSubAgentSessionStore( const sessions = new Map(); // Live abort hooks keyed by session id. Cleared on terminal transition. const cancelHandles = new Map void>(); + // CL-6943: bounded close functions keyed by session id, for close_agent. + // Distinct from cancelHandles (a synchronous abort() signal) because + // closing must be awaitable and bounded by a deadline. + const closeHandles = new Map Promise>(); const listeners = new Set<() => void>(); // Per-session revision counters, bumped on every mutation. Notify fires on @@ -278,6 +322,7 @@ export function createSubAgentSessionStore( const markCancelled = (session: SubAgentSession, reason: string): void => { session.status = "cancelled"; + session.lifecycleStatus = "interrupted"; session.finishedAt = now(); session.lastActivityAt = now(); clearToolCalls(session); @@ -288,6 +333,7 @@ export function createSubAgentSessionStore( content: capText(`Cancelled: ${reason}`, maxEntryChars), }); cancelHandles.delete(session.id); + closeHandles.delete(session.id); bumpRevision(session.id); pruneCompleted(); }; @@ -328,7 +374,18 @@ export function createSubAgentSessionStore( return; } const finished = [...sessions.values()] - .filter((s) => s.status !== "running") + .filter( + (s) => + s.status !== "running" && + // CL-6943: a retained session that is still open ("completed", or + // "running" again after resume_agent) is reusable and must not be + // evicted by this display cap out from under it — only a shutdown + // (or never-retained) finished session counts toward the limit. + !( + s.retained === true && + (s.lifecycleStatus === "completed" || s.lifecycleStatus === "running") + ), + ) .sort((a, b) => (a.finishedAt ?? 0) - (b.finishedAt ?? 0)); const excess = finished.length - maxCompleted; if (excess <= 0) return; @@ -374,6 +431,7 @@ export function createSubAgentSessionStore( // Replacing an existing id (e.g. parent reuses a callId) keeps the strip // from growing duplicates when a tool call is retried. cancelHandles.delete(id); + closeHandles.delete(id); forgetRevision(id); const session: SubAgentSession = { id, @@ -389,6 +447,8 @@ export function createSubAgentSessionStore( entries: [], startedAt: now(), lastActivityAt: now(), + lifecycleStatus: "pending_init", + ...(input.retained === true ? { retained: true } : {}), ...(input.parentSessionId !== undefined ? { parentSessionId: input.parentSessionId } : {}), }; sessions.set(id, session); @@ -546,6 +606,12 @@ export function createSubAgentSessionStore( // resurrect the session as done. if (session.status !== "running") return; session.status = "done"; + // CL-6943: retained sessions stay "completed" (open, reusable) here — + // only close_agent (closeOne) moves them to "shutdown". A session + // that never opted into retention has no live agent behind it by the + // time this fires either way, so the distinction only matters for + // whether pruneCompleted's cap may evict the record. + session.lifecycleStatus = "completed"; session.finishedAt = now(); clearToolCalls(session); session.report = report; @@ -563,6 +629,11 @@ export function createSubAgentSessionStore( mutate(id, (session) => { if (session.status !== "running") return; session.status = "failed"; + // A thrown run always tears down its agent in run.ts's finally + // (persist only skips teardown on a clean success) — so there is + // nothing left to resume here, and retained no longer applies. + session.lifecycleStatus = "shutdown"; + session.retained = false; session.finishedAt = now(); clearToolCalls(session); session.error = error; @@ -571,6 +642,7 @@ export function createSubAgentSessionStore( content: capText(`Error: ${error}`, maxEntryChars), }); cancelHandles.delete(id); + closeHandles.delete(id); pruneCompleted(); }); }, @@ -581,6 +653,58 @@ export function createSubAgentSessionStore( cancelHandles.set(id, abort); }, + markRunning(id: string): void { + mutate(id, (session) => { + if (session.lifecycleStatus === "pending_init") session.lifecycleStatus = "running"; + }); + }, + + registerClose(id: string, close: (deadlineMs?: number) => Promise): void { + if (!sessions.has(id)) return; + closeHandles.set(id, close); + }, + + async closeOne(id: string, deadlineMs: number): Promise { + const session = sessions.get(id); + if (session === undefined) return "not_found"; + if (session.lifecycleStatus === "shutdown") return "shutdown"; + const close = closeHandles.get(id); + if (close !== undefined) { + closeHandles.delete(id); + // Bounded here too, defense-in-depth against a caller-registered + // close that does not honor its own deadline argument — a wedged + // descendant must not hang the whole close_agent call. + await Promise.race([ + close(deadlineMs).catch(() => {}), + new Promise((resolve) => setTimeout(resolve, deadlineMs)), + ]); + } + mutate(id, (s) => { + s.lifecycleStatus = "shutdown"; + s.retained = false; + if (s.status === "running") { + s.status = "cancelled"; + s.finishedAt = s.finishedAt ?? now(); + s.error = s.error ?? "Closed by close_agent"; + } + }); + cancelHandles.delete(id); + pruneCompleted(); + return "shutdown"; + }, + + resumeOne(id: string): { ok: true } | { ok: false; status: AgentLifecycleStatus } { + const session = sessions.get(id); + if (session === undefined) return { ok: false, status: "not_found" }; + if (session.lifecycleStatus !== "completed" || session.retained !== true) { + return { ok: false, status: session.lifecycleStatus }; + } + mutate(id, (s) => { + s.lifecycleStatus = "running"; + }); + return { ok: true }; + }, + cancel(id: string, reason = DEFAULT_CANCEL_REASON): boolean { return cancelSession(id, reason); }, @@ -605,6 +729,7 @@ export function createSubAgentSessionStore( // Drop handles without invoking them — callers that need teardown should // cancelAll first (parent stop / /clear). cancelHandles.clear(); + closeHandles.clear(); sessions.clear(); revisions.clear(); snapshotCache.clear(); @@ -628,6 +753,8 @@ function cloneSession(session: SubAgentSession): SubAgentSession { entries: session.entries.map(cloneEntry), startedAt: session.startedAt, lastActivityAt: session.lastActivityAt, + lifecycleStatus: session.lifecycleStatus, + ...(session.retained !== undefined ? { retained: session.retained } : {}), ...(session.finishedAt !== undefined ? { finishedAt: session.finishedAt } : {}), ...(session.report !== undefined ? { report: session.report } : {}), ...(session.error !== undefined ? { error: session.error } : {}), diff --git a/src/subagent/types.ts b/src/subagent/types.ts index 9c7da2ad8..5597b629f 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -148,6 +148,24 @@ export type RunSubAgentParams = { tier?: SubagentTier; /** DirectorPackage.reportContract.outputType, when the resolved leaf declares one. */ reportType?: OutputType; + /** + * CL-6943: when true, a clean successful completion skips the normal + * end-of-turn teardown (agent.close() / posixTools.dispose()) so the + * session stays open and reusable. A failure or an aborted/cancelled run + * still tears down as before — only a clean success is retained. A caller + * that opts in must eventually close the session (close_agent) or it + * leaks its posix tools / workdir lock. + */ + persist?: boolean; + /** + * Fired once the underlying agent object exists (before the prompt is + * sent), with a bounded close function the caller can register for later + * (close_agent). Always fired regardless of `persist`, so a caller can + * close a still-running session too, not only a retained one. The deadline + * argument bounds how long teardown may take; a wedged close is abandoned + * (not awaited further) once it elapses rather than hanging the caller. + */ + onAgentReady?: (close: (deadlineMs?: number) => Promise) => void; } & SubAgentSandboxDeps; /** runSubAgent's result: the parent-facing report plus, when force-stopped, the structured reason why (CL-6946 part 2) — classify outcomes from `stopReason`, never by parsing `report`. */ diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index 6d8f75959..2d0db7267 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -53,6 +53,7 @@ function session(over: Partial): SubAgentSession { entries: [], startedAt: 0, lastActivityAt: 0, + lifecycleStatus: "running", ...over, }; }