feat(task): add fuse-overlay isolation backend
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user