diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 11f8dcc54..d47005cb7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Breaking Changes + +- Renamed `task.isolation.enabled` (boolean) setting to `task.isolation.mode` (enum: `none`, `worktree`, `fuse-overlay`). Existing `true`/`false` values are auto-migrated to `worktree`/`none`. + +### Added + +- Added `fuse-overlay` isolation mode for subagents using `fuse-overlayfs` (copy-on-write overlay, no baseline patch apply needed) + ## [13.2.0] - 2026-02-23 ### Breaking Changes diff --git a/packages/coding-agent/DEVELOPMENT.md b/packages/coding-agent/DEVELOPMENT.md index 4698000b0..408477d72 100644 --- a/packages/coding-agent/DEVELOPMENT.md +++ b/packages/coding-agent/DEVELOPMENT.md @@ -906,7 +906,9 @@ Despite the name `runSubprocess`, `packages/coding-agent/src/task/executor.ts` c What _is_ isolated is execution context and artifacts, not process memory: -- Optional git worktree isolation is handled by `TaskTool.execute(...)` in `index.ts` using `ensureWorktree(...)`, `applyBaseline(...)`, `captureDeltaPatch(...)`, `cleanupWorktree(...)`. +- Optional filesystem isolation is controlled by the `task.isolation.mode` setting (`"none"`, `"worktree"`, or `"fuse-overlay"`). + - **worktree**: `ensureWorktree(...)`, `applyBaseline(...)`, `captureDeltaPatch(...)`, `cleanupWorktree(...)`. + - **fuse-overlay**: `ensureFuseOverlay(...)` (mounts a copy-on-write overlay via `fuse-overlayfs`), `captureDeltaPatch(...)`, `cleanupFuseOverlay(...)`. No baseline apply step needed since the overlay reflects the full working tree. - Child session JSONL/markdown outputs are written under the task artifacts directory (`.jsonl`, `.md`, and in isolated mode `.patch`). ### Tooling Surface in Child Sessions diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 1177433f1..a14363e07 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -544,13 +544,14 @@ export const SETTINGS_SCHEMA = { // ───────────────────────────────────────────────────────────────────────── // Task tool settings // ───────────────────────────────────────────────────────────────────────── - "task.isolation.enabled": { - type: "boolean", - default: false, + "task.isolation.mode": { + type: "enum", + values: ["none", "worktree", "fuse-overlay"] as const, + default: "none", ui: { tab: "tools", label: "Task isolation", - description: "Run subagents in isolated git worktrees", + description: "Isolation mode for subagents (none, git worktree, or fuse-overlay)", submenu: true, }, }, diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 95fb49615..54d761108 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -546,6 +546,16 @@ export class Settings { } } + // task.isolation.enabled (boolean) -> task.isolation.mode (enum) + const taskObj = raw.task as Record | undefined; + const isolationObj = taskObj?.isolation as Record | undefined; + if (isolationObj && "enabled" in isolationObj) { + if (typeof isolationObj.enabled === "boolean") { + isolationObj.mode = isolationObj.enabled ? "worktree" : "none"; + } + delete isolationObj.enabled; + } + return raw; } diff --git a/packages/coding-agent/src/prompts/tools/task.md b/packages/coding-agent/src/prompts/tools/task.md index 03e22c22e..73d2546ef 100644 --- a/packages/coding-agent/src/prompts/tools/task.md +++ b/packages/coding-agent/src/prompts/tools/task.md @@ -18,7 +18,7 @@ Subagents lack your conversation history. Every decision, file content, and user - `context`: Shared background prepended to every assignment. Session-specific info only. - `schema`: JTD schema for expected output. Format lives here — MUST NOT be duplicated in assignments. - `tasks`: Tasks to execute in parallel. -- `isolated`: Run in isolated git worktree; returns patches. Use when tasks edit overlapping files. +- `isolated`: Run in isolated environment; returns patches. Use when tasks edit overlapping files. diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index 88c073b21..2ed4b6b92 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -49,7 +49,9 @@ import { applyBaseline, captureBaseline, captureDeltaPatch, + cleanupFuseOverlay, cleanupWorktree, + ensureFuseOverlay, ensureWorktree, getRepoRoot, type WorktreeBaseline, @@ -145,11 +147,11 @@ export class TaskTool implements AgentTool { get description(): string { const disabledAgents = this.session.settings.get("task.disabledAgents") as string[]; const maxConcurrency = this.session.settings.get("task.maxConcurrency"); - const isolationEnabled = this.session.settings.get("task.isolation.enabled"); + const isolationMode = this.session.settings.get("task.isolation.mode"); return renderDescription( this.#discoveredAgents, maxConcurrency, - isolationEnabled, + isolationMode !== "none", this.session.settings.get("async.enabled"), disabledAgents, ); @@ -168,9 +170,9 @@ export class TaskTool implements AgentTool { * Create a TaskTool instance with async agent discovery. */ static async create(session: ToolSession): Promise { - const isolationEnabled = session.settings.get("task.isolation.enabled"); + const isolationMode = session.settings.get("task.isolation.mode"); const { agents } = await discoverAgents(session.cwd); - return new TaskTool(session, agents, isolationEnabled); + return new TaskTool(session, agents, isolationMode !== "none"); } async execute( @@ -422,18 +424,18 @@ export class TaskTool implements AgentTool { const startTime = Date.now(); const { agents, projectAgentsDir } = await discoverAgents(this.session.cwd); const { agent: agentName, context, schema: outputSchema } = params; - const isolationEnabled = this.session.settings.get("task.isolation.enabled"); + const isolationMode = this.session.settings.get("task.isolation.mode"); const isolationRequested = "isolated" in params ? params.isolated === true : false; - const isIsolated = isolationEnabled && isolationRequested; + const isIsolated = isolationMode !== "none" && isolationRequested; const maxConcurrency = this.session.settings.get("task.maxConcurrency"); const taskDepth = this.session.taskDepth ?? 0; - if (!isolationEnabled && "isolated" in params) { + if (isolationMode === "none" && "isolated" in params) { return { content: [ { type: "text", - text: "Task isolation is disabled. Remove the isolated argument to run subagents.", + text: "Task isolation is disabled. Remove the isolated argument or set task.isolation.mode to 'worktree' or 'fuse-overlay'.", }, ], details: { @@ -789,16 +791,23 @@ export class TaskTool implements AgentTool { } const taskStart = Date.now(); - let worktreeDir: string | undefined; + let isolationDir: string | undefined; try { if (!repoRoot || !baseline) { throw new Error("Isolated task execution not initialized."); } - worktreeDir = await ensureWorktree(repoRoot, task.id); - await applyBaseline(worktreeDir, baseline); + + if (isolationMode === "fuse-overlay") { + isolationDir = await ensureFuseOverlay(repoRoot, task.id); + // Overlay already reflects the full working tree state — no baseline apply needed + } else { + isolationDir = await ensureWorktree(repoRoot, task.id); + await applyBaseline(isolationDir, baseline); + } + const result = await runSubprocess({ cwd: this.session.cwd, - worktree: worktreeDir, + worktree: isolationDir, agent, task: task.task, description: task.description, @@ -830,7 +839,7 @@ export class TaskTool implements AgentTool { preloadedSkills: task.preloadedSkills, promptTemplates, }); - const patch = await captureDeltaPatch(worktreeDir, baseline); + const patch = await captureDeltaPatch(isolationDir, baseline); const patchPath = path.join(effectiveArtifactsDir, `${task.id}.patch`); await Bun.write(patchPath, patch); return { @@ -856,8 +865,12 @@ export class TaskTool implements AgentTool { error: message, }; } finally { - if (worktreeDir) { - await cleanupWorktree(worktreeDir); + if (isolationDir) { + if (isolationMode === "fuse-overlay") { + await cleanupFuseOverlay(isolationDir); + } else { + await cleanupWorktree(isolationDir); + } } } }; diff --git a/packages/coding-agent/src/task/types.ts b/packages/coding-agent/src/task/types.ts index fbe6d43cd..a726d799c 100644 --- a/packages/coding-agent/src/task/types.ts +++ b/packages/coding-agent/src/task/types.ts @@ -77,7 +77,7 @@ const createTaskSchema = (options: { isolationEnabled: boolean }) => { ...properties, isolated: Type.Optional( Type.Boolean({ - description: "Run in isolated git worktree; returns patches. Use when tasks edit overlapping files.", + description: "Run in isolated environment; returns patches. Use when tasks edit overlapping files.", }), ), }); diff --git a/packages/coding-agent/src/task/worktree.ts b/packages/coding-agent/src/task/worktree.ts index 33570ffc8..433047e0b 100644 --- a/packages/coding-agent/src/task/worktree.ts +++ b/packages/coding-agent/src/task/worktree.ts @@ -168,3 +168,59 @@ export async function cleanupWorktree(dir: string): Promise { await fs.rm(dir, { recursive: true, force: true }); } } + +// ═══════════════════════════════════════════════════════════════════════════ +// Fuse-overlay isolation +// ═══════════════════════════════════════════════════════════════════════════ + +export async function ensureFuseOverlay(baseCwd: string, id: string): Promise { + const repoRoot = await getRepoRoot(baseCwd); + const encodedProject = getEncodedProjectName(repoRoot); + const baseDir = getWorktreeDir(encodedProject, id); + const upperDir = path.join(baseDir, "upper"); + const workDir = path.join(baseDir, "work"); + const mergedDir = path.join(baseDir, "merged"); + + // Clean up any stale mount at this path + const fusermount = Bun.which("fusermount3") ?? Bun.which("fusermount"); + if (fusermount) { + await $`${fusermount} -u ${mergedDir}`.quiet().nothrow(); + } + await fs.rm(baseDir, { recursive: true, force: true }); + + await fs.mkdir(upperDir, { recursive: true }); + await fs.mkdir(workDir, { recursive: true }); + await fs.mkdir(mergedDir, { recursive: true }); + + const binary = Bun.which("fuse-overlayfs"); + if (!binary) { + await fs.rm(baseDir, { recursive: true, force: true }); + throw new Error( + "fuse-overlayfs not found. Install it (e.g. `apt install fuse-overlayfs` or `pacman -S fuse-overlayfs`) to use fuse-overlay isolation.", + ); + } + + const result = await $`${binary} -o lowerdir=${repoRoot},upperdir=${upperDir},workdir=${workDir} ${mergedDir}` + .quiet() + .nothrow(); + if (result.exitCode !== 0) { + const stderr = result.stderr.toString().trim(); + await fs.rm(baseDir, { recursive: true, force: true }); + throw new Error(`fuse-overlayfs mount failed (exit ${result.exitCode}): ${stderr}`); + } + + return mergedDir; +} + +export async function cleanupFuseOverlay(mergedDir: string): Promise { + try { + const fusermount = Bun.which("fusermount3") ?? Bun.which("fusermount"); + if (fusermount) { + await $`${fusermount} -u ${mergedDir}`.quiet().nothrow(); + } + } finally { + // baseDir is the parent of the merged directory + const baseDir = path.dirname(mergedDir); + await fs.rm(baseDir, { recursive: true, force: true }); + } +}