feat(task): add fuse-overlay isolation backend

This commit is contained in:
DeprecatedLuke
2026-02-23 19:17:40 +00:00
parent 90b2b881fc
commit 86cd66b3d5
8 changed files with 112 additions and 22 deletions
+8
View File
@@ -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
+3 -1
View File
@@ -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 (`<id>.jsonl`, `<id>.md`, and in isolated mode `<id>.patch`).
### Tooling Surface in Child Sessions
@@ -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,
},
},
@@ -546,6 +546,16 @@ export class Settings {
}
}
// task.isolation.enabled (boolean) -> task.isolation.mode (enum)
const taskObj = raw.task as Record<string, unknown> | undefined;
const isolationObj = taskObj?.isolation as Record<string, unknown> | undefined;
if (isolationObj && "enabled" in isolationObj) {
if (typeof isolationObj.enabled === "boolean") {
isolationObj.mode = isolationObj.enabled ? "worktree" : "none";
}
delete isolationObj.enabled;
}
return raw;
}
@@ -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.
</parameters>
<critical>
+28 -15
View File
@@ -49,7 +49,9 @@ import {
applyBaseline,
captureBaseline,
captureDeltaPatch,
cleanupFuseOverlay,
cleanupWorktree,
ensureFuseOverlay,
ensureWorktree,
getRepoRoot,
type WorktreeBaseline,
@@ -145,11 +147,11 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
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<TaskSchema, TaskToolDetails, Theme> {
* Create a TaskTool instance with async agent discovery.
*/
static async create(session: ToolSession): Promise<TaskTool> {
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<TaskSchema, TaskToolDetails, Theme> {
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<TaskSchema, TaskToolDetails, Theme> {
}
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<TaskSchema, TaskToolDetails, Theme> {
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<TaskSchema, TaskToolDetails, Theme> {
error: message,
};
} finally {
if (worktreeDir) {
await cleanupWorktree(worktreeDir);
if (isolationDir) {
if (isolationMode === "fuse-overlay") {
await cleanupFuseOverlay(isolationDir);
} else {
await cleanupWorktree(isolationDir);
}
}
}
};
+1 -1
View File
@@ -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.",
}),
),
});
@@ -168,3 +168,59 @@ export async function cleanupWorktree(dir: string): Promise<void> {
await fs.rm(dir, { recursive: true, force: true });
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Fuse-overlay isolation
// ═══════════════════════════════════════════════════════════════════════════
export async function ensureFuseOverlay(baseCwd: string, id: string): Promise<string> {
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<void> {
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 });
}
}