From 387962f967c7ee2ad707f545b8672faacacf37a4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 07:44:28 -0700 Subject: [PATCH 1/3] Give spawn_agent worktree, allowlist, and nested spawn spawn_agent could not isolate a cwd, honor a parent spawn matrix, or mount fleet verbs on maySpawn directors. Those gaps kept task() as the only complete dispatch path. spawn_agent now carries the same isolation and spawn-matrix surface for closed directors. --- src/agent/tools.ts | 1 + src/subagent/agent-fleet.test.ts | 66 ++++++++++++ src/subagent/agent-fleet.ts | 123 ++++++++++++++++++++-- src/subagent/run.ts | 3 + src/subagent/spawn-agent-worktree.test.ts | 116 ++++++++++++++++++++ 5 files changed, 302 insertions(+), 7 deletions(-) create mode 100644 src/subagent/spawn-agent-worktree.test.ts diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 1055a3933..7c75933b9 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -337,6 +337,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise { gate.resolve({ report: "done" }); }); }); + +describe("spawn_agent parity with task", () => { + test("refuses skywalker as a spawned worker", async () => { + const deps = makeDeps(async () => ({ report: "no" })); + const spawn = createSpawnAgentTool(deps); + const raw = await callToolRaw(spawn, { + description: "nope", + prompt: "do it", + agent: "skywalker", + }); + expect(raw.isError).toBe(true); + expect(raw.content).toContain("skywalker is the primary session identity"); + }); + + test("rejects a child outside this director allowlist", async () => { + const deps = makeDeps(async () => ({ report: "no" })); + deps.spawnAllowlist = ["intern", "explorer", "critic"]; + const spawn = createSpawnAgentTool(deps); + const raw = await callToolRaw(spawn, { + description: "build", + prompt: "ship it", + agent: "builder", + }); + expect(raw.isError).toBe(true); + expect(raw.content).toContain("allowlist"); + expect(raw.content).toContain("builder"); + }); + + test("a maySpawn director is launched as an orchestrator with nestedDispatch", async () => { + const captured: RunSubAgentParams[] = []; + const deps = makeDeps(async (params) => { + captured.push(params); + return { report: "ok" }; + }); + const spawn = createSpawnAgentTool(deps); + await callTool(spawn, { + description: "arch", + prompt: "judge this", + agent: "greybeard", + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(captured).toHaveLength(1); + expect(captured[0]!.orchestrator).toBe(true); + expect(captured[0]!.orchestratorTier).toBe("nested-orchestrator"); + expect(captured[0]!.nestedDispatch).toBeDefined(); + expect(captured[0]!.nestedDispatch?.spawnAllowlist).toEqual(["intern", "explorer", "critic"]); + }); + + test("allowOrchestrator false strips nested spawn even for maySpawn directors", async () => { + const captured: RunSubAgentParams[] = []; + const deps = makeDeps(async (params) => { + captured.push(params); + return { report: "ok" }; + }); + deps.allowOrchestrator = false; + const spawn = createSpawnAgentTool(deps); + await callTool(spawn, { + description: "arch", + prompt: "judge this", + agent: "greybeard", + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(captured[0]!.orchestrator).toBeUndefined(); + expect(captured[0]!.nestedDispatch).toBeUndefined(); + }); +}); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index dd417b412..d7053bac3 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -31,21 +31,25 @@ * * Argument shape intentionally mirrors `task()`'s (description/prompt/ * context/goals/intent/success_criteria/do_not/report_focus) so a - * caller can swap one for the other. Scope is deliberately narrower than - * `task()` for this first cut: only closed-director dispatch (`agent=` a - * director id, or `intent=`) is supported — no custom AgentProfile lookup, - * no nested orchestration, no re-dispatch ledger. Those remain `task()`-only - * for now; nothing here stops adding them later. + * caller can swap one for the other. Closed-director dispatch also carries + * task()'s isolation and spawn-matrix: worktree cwd, parent allowlist, + * maySpawn nestedDispatch, and deadline. Custom AgentProfile lookup and the + * re-dispatch ledger remain task()-only until task becomes a thin wrapper. * */ +import { join } from "node:path"; + import { tool } from "@intx/agent"; import type { AgentTool } from "@intx/agent"; import { type } from "arktype"; import type { ToolDefinition, ToolResult } from "@intx/types/runtime"; import type { ReactorEmittedEvent } from "@intx/inference"; +import { getLogger } from "@intx/log"; +import { LOG_NAMESPACE_ROOT } from "../branding.js"; import type { ProviderCatalogEntry } from "../config/index.js"; +import { generateSessionId } from "../session/index.js"; import { isDirectorId, packageToCapabilities, @@ -61,13 +65,18 @@ import { isCodexProviderName } from "../config/codex-providers.js"; import { buildDispatchBrief, type TaskIntent } from "./report.js"; import type { SubAgentSessionStore } from "./session-store.js"; import type { + NestedDispatchDeps, RunSubAgentParams, RunSubAgentResult, SubAgentProvider, SubAgentSandboxDeps, } from "./types.js"; +import { cleanupSubAgentWorktree, createSubAgentWorktree, WorktreeError } from "./worktree.js"; import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js"; import { classifyAgentName } from "../telemetry/classify.js"; +import type { DirectorPackage } from "../agent/directors/types.js"; + +const log = getLogger([LOG_NAMESPACE_ROOT, "subagent", "agent-fleet"]); /** Terminal (or running) record for one spawned agent, keyed by agent id. */ interface FleetRecord { @@ -346,6 +355,14 @@ export type AgentFleetDeps = SubAgentSandboxDeps & { * Omit on the primary session — its children are top-level. */ parentSessionId?: string; + /** When set, only these director ids may be spawned. */ + spawnAllowlist?: readonly string[]; + /** When false, maySpawn directors cannot remount fleet verbs. Defaults true. */ + allowOrchestrator?: boolean; + /** Isolate each spawn in a git worktree branched from dispatcher HEAD. */ + useWorktree?: boolean; + /** Optional wall-clock budget (ms) forwarded to runSubAgent. */ + deadlineMs?: number; settings?: Settings | (() => Settings | undefined); catalog?: readonly ProviderCatalogEntry[] | (() => readonly ProviderCatalogEntry[]); onEvent?: (event: ReactorEmittedEvent) => void; @@ -373,6 +390,7 @@ export function resolveDirectorDispatch( systemPromptRole: string; capabilities: ReturnType; roleDefault: ReturnType; + pkg: DirectorPackage; } | { ok: false; error: string } { if (agentId !== undefined && agentId.length > 0) { @@ -391,6 +409,7 @@ export function resolveDirectorDispatch( systemPromptRole: formatDirectorSystemPrompt(pkg), capabilities: packageToCapabilities(pkg), roleDefault: defaultEffortForDirector(pkg), + pkg, }; } if (intent !== undefined) { @@ -403,6 +422,7 @@ export function resolveDirectorDispatch( systemPromptRole: formatDirectorSystemPrompt(pkg), capabilities: packageToCapabilities(pkg), roleDefault: defaultEffortForDirector(pkg), + pkg, }; } return { @@ -451,12 +471,34 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { const resolved = resolveDirectorDispatch(agentId, intent); if (!resolved.ok) return fleetResult(call.id, resolved.error); + if (agentId === "skywalker" || resolved.directorId === "skywalker") { + return fleetResult( + call.id, + "Error: skywalker is the primary session identity, not a spawned worker. Pass spawn_agent(agent=…) for a specialist (builder, explorer, counsel, critic, …).", + ); + } + if (deps.spawnAllowlist !== undefined && deps.spawnAllowlist.length > 0) { + if (!deps.spawnAllowlist.includes(resolved.directorId)) { + return fleetResult( + call.id, + `Error: spawn of "${resolved.directorId}" is outside this director's allowlist. Allowed: ${deps.spawnAllowlist.join(", ")}.`, + ); + } + } const settings = deps.settings !== undefined ? resolveDep(deps.settings) : undefined; + const orchestrator = resolved.pkg.spawn.maySpawn === true && deps.allowOrchestrator !== false; + const nestedSpawnAllowlist = + orchestrator && + resolved.pkg.spawn.allowlist !== undefined && + resolved.pkg.spawn.allowlist.length > 0 + ? resolved.pkg.spawn.allowlist + : undefined; + let provider: SubAgentProvider = resolveDep(deps.provider); const effort = resolveEffortForRole({ - orchestrator: false, + orchestrator, roleDefault: resolved.roleDefault, ...(provider.reasoningEffort !== undefined ? { parentEffort: provider.reasoningEffort } @@ -503,6 +545,52 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { }; const catalog = deps.catalog !== undefined ? resolveDep(deps.catalog) : undefined; + let worktreeCwd: string | undefined; + let worktreeStashBaseline: readonly string[] | null = []; + let worktreeHeadAtCreate: string | undefined; + if (deps.useWorktree === true) { + const worktreePath = join(deps.getWorkdirBase(), "worktrees", generateSessionId()); + try { + const worktree = await createSubAgentWorktree(deps.cwd, worktreePath); + worktreeCwd = worktree.path; + worktreeStashBaseline = worktree.stashBaseline; + worktreeHeadAtCreate = worktree.headAtCreate; + } catch (err) { + const message = + err instanceof WorktreeError + ? err.message + : `sub-agent worktree setup failed: ${err instanceof Error ? err.message : String(err)}`; + deps.fleetRecords.reject(session.id, message); + deps.sessions.fail(session.id, message); + return fleetResult(call.id, `Error: ${message}`); + } + } + + const nestedDispatch: NestedDispatchDeps | undefined = orchestrator + ? { + permissionGate: deps.permissionGate, + ...(deps.inheritMcpTools !== undefined + ? { inheritMcpTools: deps.inheritMcpTools } + : {}), + ...(deps.shellTimeout !== undefined ? { shellTimeout: deps.shellTimeout } : {}), + ...(deps.shellEnv !== undefined ? { shellEnv: deps.shellEnv } : {}), + ...(deps.extraToolPlugins !== undefined + ? { extraToolPlugins: deps.extraToolPlugins } + : {}), + ...(deps.getBlobReader !== undefined ? { getBlobReader: deps.getBlobReader } : {}), + getWorkdirBase: deps.getWorkdirBase, + provider: deps.provider, + ...(deps.onEvent !== undefined ? { onEvent: deps.onEvent } : {}), + ...(deps.onProgress !== undefined ? { onProgress: deps.onProgress } : {}), + sessions: deps.sessions, + ...(settings !== undefined ? { settings } : {}), + ...(catalog !== undefined ? { catalog } : {}), + parentSessionId: session.id, + ...(deps.useWorktree !== undefined ? { useWorktree: deps.useWorktree } : {}), + ...(nestedSpawnAllowlist !== undefined ? { spawnAllowlist: nestedSpawnAllowlist } : {}), + } + : undefined; + const params: RunSubAgentParams = { // Name the trace directory after the session-store id so the // descendant-scoping check behind read_agent_trace can resolve this @@ -514,7 +602,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { ...(deps.shellEnv !== undefined ? { shellEnv: deps.shellEnv } : {}), ...(deps.extraToolPlugins !== undefined ? { extraToolPlugins: deps.extraToolPlugins } : {}), ...(deps.getBlobReader !== undefined ? { getBlobReader: deps.getBlobReader } : {}), - cwd: deps.cwd, + cwd: worktreeCwd ?? deps.cwd, workdirBase: deps.getWorkdirBase(), provider, ...(settings !== undefined ? { settings } : {}), @@ -533,6 +621,18 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { ...(resolved.capabilities !== undefined ? { capabilities: resolved.capabilities } : {}), systemPromptRole: resolved.systemPromptRole, directorId: resolved.directorId, + ...(orchestrator + ? { + orchestrator: true, + orchestratorTier: resolved.pkg.tier, + nestedDispatch: nestedDispatch!, + } + : {}), + ...(deps.deadlineMs !== undefined ? { deadlineMs: deps.deadlineMs } : {}), + tier: resolved.pkg.tier, + ...(resolved.pkg.reportContract?.outputType !== undefined + ? { reportType: resolved.pkg.reportContract.outputType } + : {}), // Keep the session open after a clean completion, and hand the // store a bounded close for close_agent to call later. persist: true, @@ -594,6 +694,15 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { status: deps.sessions.get(session.id)?.status ?? "completed", duration_ms: Date.now() - startedAt, }); + if (worktreeCwd === undefined) return; + void cleanupSubAgentWorktree(deps.cwd, worktreeCwd, { + stashBaseline: worktreeStashBaseline, + ...(worktreeHeadAtCreate !== undefined ? { headAtCreate: worktreeHeadAtCreate } : {}), + }).catch((err: unknown) => { + log.error("spawn_agent worktree cleanup failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); }); return fleetResult(call.id, JSON.stringify({ agent_id: session.id, status: "running" })); diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 3a82c05e6..49826c96f 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -598,7 +598,10 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir !== undefined) await rm(dir, { recursive: true, force: true }); + } +}); + +async function makeRepo(): Promise { + const dir = await mkdtemp(join(tmpdir(), "corbits-spawn-wt-")); + await run("git", ["init"], { cwd: dir }); + await run("git", ["config", "user.email", "t@t.test"], { cwd: dir }); + await run("git", ["config", "user.name", "t"], { cwd: dir }); + await writeFile(join(dir, "seed.txt"), "seed"); + await run("git", ["add", "."], { cwd: dir }); + await run("git", ["commit", "-m", "seed"], { cwd: dir }); + return dir; +} + +describe("spawn_agent worktree isolation", () => { + test("propagates a fresh worktree path as the worker cwd", async () => { + const repo = await makeRepo(); + tempDirs.push(repo); + const workdirBase = await mkdtemp(join(tmpdir(), "corbits-workdir-")); + tempDirs.push(workdirBase); + + let captured: RunSubAgentParams | undefined; + const tool = createSpawnAgentTool({ + permissionGate: testPermissionGate, + cwd: repo, + getWorkdirBase: () => workdirBase, + provider, + useWorktree: true, + run: async (params) => { + captured = params; + return { report: "done" }; + }, + sessions: createSubAgentSessionStore(), + fleetRecords: createFleetRecords(), + }); + if (tool.kind !== "full") throw new Error("expected full tool"); + const result = await tool.handler( + { + id: "c1", + name: "spawn_agent", + arguments: { description: "Isolated job", prompt: "Do the work", intent: "explore" }, + }, + new AbortController().signal, + ); + expect(typeof result.content === "string" ? result.content : "").toContain("running"); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(captured?.cwd).toBeDefined(); + expect(captured?.cwd).not.toBe(repo); + expect(captured?.cwd?.startsWith(workdirBase)).toBe(true); + }); + + test("fails closed when the dispatcher cwd is not a git repository", async () => { + const notARepo = await mkdtemp(join(tmpdir(), "corbits-not-a-repo-")); + tempDirs.push(notARepo); + const workdirBase = await mkdtemp(join(tmpdir(), "corbits-workdir-")); + tempDirs.push(workdirBase); + + let ran = false; + const tool = createSpawnAgentTool({ + permissionGate: testPermissionGate, + cwd: notARepo, + getWorkdirBase: () => workdirBase, + provider, + useWorktree: true, + run: async () => { + ran = true; + return { report: "no" }; + }, + sessions: createSubAgentSessionStore(), + fleetRecords: createFleetRecords(), + }); + if (tool.kind !== "full") throw new Error("expected full tool"); + const result = await tool.handler( + { + id: "c2", + name: "spawn_agent", + arguments: { description: "bad", prompt: "Do the work", intent: "explore" }, + }, + new AbortController().signal, + ); + expect(result.isError).toBe(true); + expect(ran).toBe(false); + }); +}); From 85c7716e8666d0fee1b0eb74f1e98e4e3315c659 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 07:48:50 -0700 Subject: [PATCH 2/3] Reuse the parent tool call id as the spawn session id task() correlated Agents-strip progress with the parent tool call by using that call id as the session id. spawn_agent minted a fresh id, so a task() wrapper would break that correlation. --- src/subagent/agent-fleet.test.ts | 17 +++++++++++++++++ src/subagent/agent-fleet.ts | 1 + 2 files changed, 18 insertions(+) diff --git a/src/subagent/agent-fleet.test.ts b/src/subagent/agent-fleet.test.ts index c02a3a0fc..6acd0fb7a 100644 --- a/src/subagent/agent-fleet.test.ts +++ b/src/subagent/agent-fleet.test.ts @@ -924,6 +924,23 @@ describe("list_agents", () => { }); describe("spawn_agent parity with task", () => { + test("uses the parent tool call id as the session id", async () => { + const deps = makeDeps(async () => ({ report: "done" })); + const spawn = createSpawnAgentTool(deps); + if (spawn.kind !== "full") throw new Error("expected full tool"); + const result = await spawn.handler( + { + id: "call-fixed-id", + name: "spawn_agent", + arguments: { description: "job", prompt: "do it", intent: "explore" }, + }, + new AbortController().signal, + ); + const content = typeof result.content === "string" ? result.content : ""; + expect(JSON.parse(content).agent_id).toBe("call-fixed-id"); + expect(deps.sessions.get("call-fixed-id")).toBeDefined(); + }); + test("refuses skywalker as a spawned worker", async () => { const deps = makeDeps(async () => ({ report: "no" })); const spawn = createSpawnAgentTool(deps); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index d7053bac3..1619b5a06 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -520,6 +520,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { }); const session = deps.sessions.start({ + id: call.id, description, agentId: resolved.directorId, brief, From 05ad41fc742045796bb3042455e030a8b293b863 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 08:31:28 -0700 Subject: [PATCH 3/3] Defer spawn_agent worktree cleanup while the session is retained A retained or interrupt_agent keep-alive session still needs its cwd for followup_task. Cleaning the worktree in the run's finally removed that path too early; reclaim it from the close handle instead, matching run.ts's persisting gate. --- src/subagent/agent-fleet.ts | 47 ++++-- src/subagent/spawn-agent-worktree.test.ts | 165 +++++++++++++++++++++- 2 files changed, 199 insertions(+), 13 deletions(-) diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 1619b5a06..acd2ce641 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -592,6 +592,26 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { } : undefined; + // Aligns with run.ts: (persist && turnSucceeded) || interruptedKeepAlive. + // When true, the worktree stays until close_agent / eviction calls the + // wrapped close below; otherwise the run's finally reclaims it immediately. + let keepWorktreeAlive = false; + const reclaimWorktree = async (): Promise => { + if (worktreeCwd === undefined) return; + const path = worktreeCwd; + worktreeCwd = undefined; + try { + await cleanupSubAgentWorktree(deps.cwd, path, { + stashBaseline: worktreeStashBaseline, + ...(worktreeHeadAtCreate !== undefined ? { headAtCreate: worktreeHeadAtCreate } : {}), + }); + } catch (err: unknown) { + log.error("spawn_agent worktree cleanup failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + } + }; + const params: RunSubAgentParams = { // Name the trace directory after the session-store id so the // descendant-scoping check behind read_agent_trace can resolve this @@ -636,9 +656,19 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { : {}), // Keep the session open after a clean completion, and hand the // store a bounded close for close_agent to call later. + // Worktree cleanup is deferred until that close when the session + // stays alive for followup (agentRetained / interrupt keep-alive) — + // matching run.ts's persisting gate so followup_task does not hit a + // removed cwd. persist: true, onAgentReady: ({ close, interrupt, followup, deliver }) => { - deps.sessions.registerClose(session.id, close); + deps.sessions.registerClose(session.id, async (deadlineMs) => { + try { + await close(deadlineMs); + } finally { + await reclaimWorktree(); + } + }); deps.sessions.registerInterrupt(session.id, interrupt); deps.sessions.registerFollowup(session.id, followup); deps.sessions.registerDeliver(session.id, deliver); @@ -662,6 +692,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { // "completed" status. Still terminalize fleetRecords so a waiter // that never saw interrupt_agent (or raced it) cannot hang. if (result.interrupted === true) { + keepWorktreeAlive = true; deps.fleetRecords.interrupt(session.id, result.report); return; } @@ -676,8 +707,10 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { // its agent first, so the store must not treat it as resumable // just because retained:true was requested at spawn. // complete() no-ops when status is already cancelled. + const agentRetained = result.agentRetained === true; + if (agentRetained) keepWorktreeAlive = true; deps.sessions.complete(session.id, result.report, { - agentRetained: result.agentRetained === true, + agentRetained, ...(result.stopReason !== undefined ? { stopReason: result.stopReason } : {}), }); }) @@ -695,15 +728,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { status: deps.sessions.get(session.id)?.status ?? "completed", duration_ms: Date.now() - startedAt, }); - if (worktreeCwd === undefined) return; - void cleanupSubAgentWorktree(deps.cwd, worktreeCwd, { - stashBaseline: worktreeStashBaseline, - ...(worktreeHeadAtCreate !== undefined ? { headAtCreate: worktreeHeadAtCreate } : {}), - }).catch((err: unknown) => { - log.error("spawn_agent worktree cleanup failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); + if (!keepWorktreeAlive) void reclaimWorktree(); }); return fleetResult(call.id, JSON.stringify({ agent_id: session.id, status: "running" })); diff --git a/src/subagent/spawn-agent-worktree.test.ts b/src/subagent/spawn-agent-worktree.test.ts index 04d3b176b..d1298301e 100644 --- a/src/subagent/spawn-agent-worktree.test.ts +++ b/src/subagent/spawn-agent-worktree.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { execFile } from "node:child_process"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { access, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -8,7 +8,7 @@ import { promisify } from "node:util"; import { createFleetRecords, createSpawnAgentTool } from "./agent-fleet.js"; import { createSubAgentSessionStore } from "./session-store.js"; import { createPermissionGate } from "../permission/gate.js"; -import type { RunSubAgentParams } from "./types.js"; +import type { RunSubAgentParams, RunSubAgentResult } from "./types.js"; const run = promisify(execFile); @@ -44,6 +44,26 @@ async function makeRepo(): Promise { return dir; } +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +function deferred(): { + promise: Promise; + resolve: (v: T) => void; +} { + let resolve!: (v: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + describe("spawn_agent worktree isolation", () => { test("propagates a fresh worktree path as the worker cwd", async () => { const repo = await makeRepo(); @@ -113,4 +133,145 @@ describe("spawn_agent worktree isolation", () => { expect(result.isError).toBe(true); expect(ran).toBe(false); }); + + test("defers worktree cleanup while the session is retained for followup", async () => { + const repo = await makeRepo(); + tempDirs.push(repo); + const workdirBase = await mkdtemp(join(tmpdir(), "corbits-workdir-")); + tempDirs.push(workdirBase); + + const settle = deferred(); + let workerCwd: string | undefined; + const sessions = createSubAgentSessionStore(); + const tool = createSpawnAgentTool({ + permissionGate: testPermissionGate, + cwd: repo, + getWorkdirBase: () => workdirBase, + provider, + useWorktree: true, + run: async (params) => { + workerCwd = params.cwd; + params.onAgentReady?.({ + close: async () => {}, + interrupt: () => {}, + followup: async () => "", + deliver: () => {}, + }); + return settle.promise; + }, + sessions, + fleetRecords: createFleetRecords(), + }); + if (tool.kind !== "full") throw new Error("expected full tool"); + const spawned = await tool.handler( + { + id: "retain-wt", + name: "spawn_agent", + arguments: { description: "keep alive", prompt: "Do the work", intent: "explore" }, + }, + new AbortController().signal, + ); + const content = typeof spawned.content === "string" ? spawned.content : ""; + const agentId = (JSON.parse(content) as { agent_id: string }).agent_id; + + settle.resolve({ report: "## Summary\nDone.", agentRetained: true }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(workerCwd).toBeDefined(); + expect(await pathExists(workerCwd!)).toBe(true); + + await sessions.closeOne(agentId, 1000); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(await pathExists(workerCwd!)).toBe(false); + }); + + test("defers worktree cleanup while the session is interrupted for followup", async () => { + const repo = await makeRepo(); + tempDirs.push(repo); + const workdirBase = await mkdtemp(join(tmpdir(), "corbits-workdir-")); + tempDirs.push(workdirBase); + + const settle = deferred(); + let workerCwd: string | undefined; + const sessions = createSubAgentSessionStore(); + const tool = createSpawnAgentTool({ + permissionGate: testPermissionGate, + cwd: repo, + getWorkdirBase: () => workdirBase, + provider, + useWorktree: true, + run: async (params) => { + workerCwd = params.cwd; + params.onAgentReady?.({ + close: async () => {}, + interrupt: () => {}, + followup: async () => "", + deliver: () => {}, + }); + return settle.promise; + }, + sessions, + fleetRecords: createFleetRecords(), + }); + if (tool.kind !== "full") throw new Error("expected full tool"); + const spawned = await tool.handler( + { + id: "interrupt-wt", + name: "spawn_agent", + arguments: { description: "interrupt me", prompt: "Do the work", intent: "explore" }, + }, + new AbortController().signal, + ); + const content = typeof spawned.content === "string" ? spawned.content : ""; + const agentId = (JSON.parse(content) as { agent_id: string }).agent_id; + + settle.resolve({ + report: "## Summary\nStopped.\n## Findings\npartial\n## Blockers\ninterrupted\n## Paths\n", + interrupted: true, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(workerCwd).toBeDefined(); + expect(await pathExists(workerCwd!)).toBe(true); + + await sessions.closeOne(agentId, 1000); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(await pathExists(workerCwd!)).toBe(false); + }); + + test("reclaims the worktree immediately when the agent is not retained", async () => { + const repo = await makeRepo(); + tempDirs.push(repo); + const workdirBase = await mkdtemp(join(tmpdir(), "corbits-workdir-")); + tempDirs.push(workdirBase); + + let workerCwd: string | undefined; + const tool = createSpawnAgentTool({ + permissionGate: testPermissionGate, + cwd: repo, + getWorkdirBase: () => workdirBase, + provider, + useWorktree: true, + run: async (params) => { + workerCwd = params.cwd; + // Salvage / non-persist path: no agentRetained flag. + return { report: "## Summary\nSalvaged." }; + }, + sessions: createSubAgentSessionStore(), + fleetRecords: createFleetRecords(), + }); + if (tool.kind !== "full") throw new Error("expected full tool"); + await tool.handler( + { + id: "no-retain-wt", + name: "spawn_agent", + arguments: { description: "one shot", prompt: "Do the work", intent: "explore" }, + }, + new AbortController().signal, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(workerCwd).toBeDefined(); + expect(await pathExists(workerCwd!)).toBe(false); + }); });