diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx index 63f988721c..baa5986320 100644 --- a/docs/hooks/tools.mdx +++ b/docs/hooks/tools.mdx @@ -619,20 +619,21 @@ If a value is too large for the environment, it may be omitted (not set). Mux al
-task (10) - -| Env var | JSON path | Type | Description | -| ---------------------------------- | ------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MUX_TOOL_INPUT_AGENT_ID` | `agentId` | string | — | -| `MUX_TOOL_INPUT_MODEL` | `model` | string | Optional model override for the sub-agent, parsed with the same alias logic as the UI (an alias or a full 'provider:model' string). Omit this unless the user explicitly instructed a specific model — by default the sub-agent inherits the parent's model. Do not assume any particular model is available. | -| `MUX_TOOL_INPUT_N` | `n` | number | Optional best-of count. Use n when several agents should try the same prompt independently. Mutually exclusive with variants; omit both for a single task. Only use grouped runs for sub-agents without interfering side effects, such as read-only agents like explore. | -| `MUX_TOOL_INPUT_PROMPT` | `prompt` | string | — | -| `MUX_TOOL_INPUT_RUN_IN_BACKGROUND` | `run_in_background` | boolean | — | -| `MUX_TOOL_INPUT_SUBAGENT_TYPE` | `subagent_type` | string | — | -| `MUX_TOOL_INPUT_THINKING` | `thinking` | string | Optional thinking/reasoning-level override for the sub-agent. Accepts a level name (off, low, medium, high, xhigh, max) or a numeric index (resolved against the chosen model). Omit this unless the user explicitly instructed a specific thinking level — by default the sub-agent inherits the parent's thinking level. | -| `MUX_TOOL_INPUT_TITLE` | `title` | string | — | -| `MUX_TOOL_INPUT_VARIANTS_` | `variants[]` | string | Optional labels for sibling runs of the same prompt template. Use variants when the task should be repeated across labeled lanes such as issue numbers, commit windows, or frontend/backend/tests/docs review lanes. Mutually exclusive with n. When provided, Mux launches one sibling per label and substitutes ${variant} in the prompt. | -| `MUX_TOOL_INPUT_VARIANTS_COUNT` | `variants.length` | number | Number of elements in variants (Optional labels for sibling runs of the same prompt template. Use variants when the task should be repeated across labeled lanes such as issue numbers, commit windows, or frontend/backend/tests/docs review lanes. Mutually exclusive with n. When provided, Mux launches one sibling per label and substitutes ${variant} in the prompt.) | +task (11) + +| Env var | JSON path | Type | Description | +| ---------------------------------- | ------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MUX_TOOL_INPUT_AGENT_ID` | `agentId` | string | — | +| `MUX_TOOL_INPUT_ISOLATION` | `isolation` | enum | Workspace isolation for the sub-agent. "fork" (the default) runs it in an isolated copy of this workspace created from committed state. "none" runs it directly in this workspace's checkout, sharing the working tree (including uncommitted changes) and skipping the fork + init overhead. Use "none" only for read-only analysis (e.g. the explore agent) or when you instruct the sub-agent to avoid editing shared files, since it can otherwise modify the same files concurrently. Omit to fork. | +| `MUX_TOOL_INPUT_MODEL` | `model` | string | Optional model override for the sub-agent, parsed with the same alias logic as the UI (an alias or a full 'provider:model' string). Omit this unless the user explicitly instructed a specific model — by default the sub-agent inherits the parent's model. Do not assume any particular model is available. | +| `MUX_TOOL_INPUT_N` | `n` | number | Optional best-of count. Use n when several agents should try the same prompt independently. Mutually exclusive with variants; omit both for a single task. Only use grouped runs for sub-agents without interfering side effects, such as read-only agents like explore. | +| `MUX_TOOL_INPUT_PROMPT` | `prompt` | string | — | +| `MUX_TOOL_INPUT_RUN_IN_BACKGROUND` | `run_in_background` | boolean | — | +| `MUX_TOOL_INPUT_SUBAGENT_TYPE` | `subagent_type` | string | — | +| `MUX_TOOL_INPUT_THINKING` | `thinking` | string | Optional thinking/reasoning-level override for the sub-agent. Accepts a level name (off, low, medium, high, xhigh, max) or a numeric index (resolved against the chosen model). Omit this unless the user explicitly instructed a specific thinking level — by default the sub-agent inherits the parent's thinking level. | +| `MUX_TOOL_INPUT_TITLE` | `title` | string | — | +| `MUX_TOOL_INPUT_VARIANTS_` | `variants[]` | string | Optional labels for sibling runs of the same prompt template. Use variants when the task should be repeated across labeled lanes such as issue numbers, commit windows, or frontend/backend/tests/docs review lanes. Mutually exclusive with n. When provided, Mux launches one sibling per label and substitutes ${variant} in the prompt. | +| `MUX_TOOL_INPUT_VARIANTS_COUNT` | `variants.length` | number | Number of elements in variants (Optional labels for sibling runs of the same prompt template. Use variants when the task should be repeated across labeled lanes such as issue numbers, commit windows, or frontend/backend/tests/docs review lanes. Mutually exclusive with n. When provided, Mux launches one sibling per label and substitutes ${variant} in the prompt.) |
diff --git a/src/common/schemas/project.ts b/src/common/schemas/project.ts index 6a570172f7..56ffe4af7d 100644 --- a/src/common/schemas/project.ts +++ b/src/common/schemas/project.ts @@ -173,6 +173,15 @@ export const WorkspaceConfigSchema = z.object({ description: "Trunk branch used to create/init this agent task workspace (used for restart-safe init on queued tasks).", }), + taskIsolation: z + .enum(["fork", "none"]) + .optional() + .meta({ + description: + 'Workspace isolation for an agent task. "none" means the task shares its parent workspace\'s ' + + "checkout (no fork): its `path` points at the parent's checkout, init is skipped, and removal " + + 'must not delete that shared directory. Absent/"fork" is the isolated default.', + }), mcp: WorkspaceMCPOverridesSchema.optional().meta({ description: "LEGACY: Per-workspace MCP overrides (migrated to /.mux/mcp.local.jsonc)", diff --git a/src/common/types/runtime.test.ts b/src/common/types/runtime.test.ts index b82a93206e..7ae7119345 100644 --- a/src/common/types/runtime.test.ts +++ b/src/common/types/runtime.test.ts @@ -1,5 +1,25 @@ import { describe, it, expect } from "@jest/globals"; -import { parseRuntimeModeAndHost, buildRuntimeString, CODER_RUNTIME_PLACEHOLDER } from "./runtime"; +import { + buildRuntimeString, + CODER_RUNTIME_PLACEHOLDER, + parseRuntimeModeAndHost, + RUNTIME_MODE, + runtimeModeSupportsSharedTaskWorkspace, +} from "./runtime"; + +describe("runtimeModeSupportsSharedTaskWorkspace", () => { + it("is true only for worktree and ssh (runtimes whose fork creates a separate checkout)", () => { + expect(runtimeModeSupportsSharedTaskWorkspace(RUNTIME_MODE.WORKTREE)).toBe(true); + expect(runtimeModeSupportsSharedTaskWorkspace(RUNTIME_MODE.SSH)).toBe(true); + }); + + it("is false for local (already shares its dir) and container runtimes", () => { + expect(runtimeModeSupportsSharedTaskWorkspace(RUNTIME_MODE.LOCAL)).toBe(false); + expect(runtimeModeSupportsSharedTaskWorkspace(RUNTIME_MODE.DOCKER)).toBe(false); + expect(runtimeModeSupportsSharedTaskWorkspace(RUNTIME_MODE.DEVCONTAINER)).toBe(false); + expect(runtimeModeSupportsSharedTaskWorkspace(undefined)).toBe(false); + }); +}); describe("parseRuntimeModeAndHost", () => { it("parses SSH mode with host", () => { diff --git a/src/common/types/runtime.ts b/src/common/types/runtime.ts index 21f05598d7..166e425e49 100644 --- a/src/common/types/runtime.ts +++ b/src/common/types/runtime.ts @@ -22,6 +22,31 @@ export const RUNTIME_MODE = { DEVCONTAINER: "devcontainer" as const, } as const; +/** + * Runtime modes whose sub-agent fork creates a *separate* checkout from the parent workspace. + * For these, the task tool may offer `isolation: "none"` to skip the fork and run the sub-agent + * directly in the parent's checkout (shared working tree), avoiding fork + init overhead for + * read-only analysis or when isolation is handled via the prompt. + * + * - `local` already shares the project directory (forking is a no-op), so it never exposes the + * option — the parameter must not even appear in the tool schema for local runtimes. + * - `docker`/`devcontainer` derive their runtime identity (container) from the workspace name in + * the runtime factory, so a differently-named workspace cannot currently resolve the parent's + * container. They are intentionally excluded until that identity override exists. + */ +export const SHARED_TASK_WORKSPACE_RUNTIME_MODES: readonly RuntimeMode[] = [ + RUNTIME_MODE.WORKTREE, + RUNTIME_MODE.SSH, +]; + +/** + * Whether sub-agents on this runtime mode can opt out of forking via `isolation: "none"` and share + * the parent workspace's checkout instead. See {@link SHARED_TASK_WORKSPACE_RUNTIME_MODES}. + */ +export function runtimeModeSupportsSharedTaskWorkspace(mode: RuntimeMode | undefined): boolean { + return mode != null && SHARED_TASK_WORKSPACE_RUNTIME_MODES.includes(mode); +} + /** * Runtime IDs that can be enabled/disabled in Settings → Runtimes. * Note: includes "coder" which is a UI-level choice (not a RuntimeMode). diff --git a/src/common/utils/tools/toolDefinitions.test.ts b/src/common/utils/tools/toolDefinitions.test.ts index 3319cdb1ee..04981917f7 100644 --- a/src/common/utils/tools/toolDefinitions.test.ts +++ b/src/common/utils/tools/toolDefinitions.test.ts @@ -1,5 +1,6 @@ import { RUNTIME_MODE } from "@/common/types/runtime"; import { + buildTaskToolAgentArgsSchema, buildTaskToolDescription, getAvailableTools, supportsGoogleNativeToolsWithFunctionTools, @@ -353,6 +354,37 @@ describe("TOOL_DEFINITIONS", () => { ); }); + describe("task tool isolation parameter", () => { + const validArgs = { agentId: "explore", prompt: "investigate", title: "Investigate" }; + + it("only advertises isolation on runtimes that can share the parent checkout", () => { + // Worktree/SSH expose `isolation`; the (local) variant strips it so it never reaches the model. + const withIsolation = buildTaskToolAgentArgsSchema({ includeIsolation: true }); + const withoutIsolation = buildTaskToolAgentArgsSchema({ includeIsolation: false }); + + expect(withIsolation.safeParse({ ...validArgs, isolation: "none" }).success).toBe(true); + // .strict() rejects the unknown key outright on the local variant. + expect(withoutIsolation.safeParse({ ...validArgs, isolation: "none" }).success).toBe(false); + // Both variants still accept args that omit isolation entirely. + expect(withoutIsolation.safeParse(validArgs).success).toBe(true); + }); + + it("rejects unknown isolation modes", () => { + const schema = buildTaskToolAgentArgsSchema({ includeIsolation: true }); + expect(schema.safeParse({ ...validArgs, isolation: "fork" }).success).toBe(true); + expect(schema.safeParse({ ...validArgs, isolation: "sandbox" }).success).toBe(false); + }); + + it("documents the isolation option only for shareable runtimes", () => { + for (const mode of [RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH]) { + expect(buildTaskToolDescription(mode)).toContain('isolation: "none"'); + } + for (const mode of [RUNTIME_MODE.LOCAL, RUNTIME_MODE.DOCKER, RUNTIME_MODE.DEVCONTAINER]) { + expect(buildTaskToolDescription(mode)).not.toContain('isolation: "none"'); + } + }); + }); + it("accepts ask_user_question headers longer than 12 characters", () => { const parsed = TOOL_DEFINITIONS.ask_user_question.schema.safeParse({ questions: [ diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index 8743397e36..b449f0da1e 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -37,7 +37,11 @@ import { WorkflowRunRecordSchema, WorkflowRunStatusSchema, } from "@/common/orpc/schemas"; -import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime"; +import { + RUNTIME_MODE, + runtimeModeSupportsSharedTaskWorkspace, + type RuntimeMode, +} from "@/common/types/runtime"; import { BASH_HARD_MAX_LINES, BASH_MAX_LINE_BYTES, @@ -198,6 +202,18 @@ const TaskToolVariantSchema = z.string().trim().min(1); const TaskToolVariantsSchema = z.array(TaskToolVariantSchema).min(1).max(20); +/** Sub-agent workspace isolation modes. `fork` matches the historical default. */ +export const TASK_ISOLATION_VALUES = ["fork", "none"] as const; +export type TaskIsolation = (typeof TASK_ISOLATION_VALUES)[number]; +const TaskIsolationSchema = z.enum(TASK_ISOLATION_VALUES); + +const TASK_ISOLATION_PARAM_DESCRIPTION = + 'Workspace isolation for the sub-agent. "fork" (the default) runs it in an isolated copy of this ' + + 'workspace created from committed state. "none" runs it directly in this workspace\'s checkout, ' + + "sharing the working tree (including uncommitted changes) and skipping the fork + init overhead. " + + 'Use "none" only for read-only analysis (e.g. the explore agent) or when you instruct the sub-agent ' + + "to avoid editing shared files, since it can otherwise modify the same files concurrently. Omit to fork."; + function getTaskRuntimeVisibilityGuidance(runtimeMode: RuntimeMode | undefined): string { switch (runtimeMode) { case RUNTIME_MODE.LOCAL: @@ -231,6 +247,13 @@ function getTaskRuntimeVisibilityGuidance(runtimeMode: RuntimeMode | undefined): } export function buildTaskToolDescription(runtimeMode: RuntimeMode | undefined): string { + const isolationGuidance = runtimeModeSupportsSharedTaskWorkspace(runtimeMode) + ? "\n\nWorkspace isolation: by default each sub-agent runs in a forked copy of this workspace. " + + 'On this runtime you may pass isolation: "none" to run the sub-agent directly in this workspace\'s ' + + "checkout (shared working tree, including uncommitted changes), skipping the fork + init overhead. " + + 'Reserve isolation: "none" for read-only analysis (e.g. the explore agent) or when you instruct the ' + + "sub-agent to avoid editing shared files, since concurrent edits to the same files are possible. " + : ""; return ( "Spawn a sub-agent task (child workspace). " + "\n\nIMPORTANT: Whether a sub-agent can see uncommitted changes depends on the runtime. " + @@ -254,87 +277,123 @@ export function buildTaskToolDescription(runtimeMode: RuntimeMode | undefined): "Prefer run_in_background: false when spawning a single task — it is equivalent to spawning background + immediately awaiting, but saves a round-trip. " + "Use run_in_background: true when launching multiple tasks in parallel so you can act on each as it completes via task_await (which returns on the first completion by default); a foreground grouped spawn (run_in_background: false) instead blocks until every sibling finishes and returns all reports at once. " + "Do not call task_await in the same parallel tool-call batch; wait for the returned task metadata first. " + + isolationGuidance + "Use the bash tool to run shell commands." ); } -const TaskToolAgentArgsSchema = z - .object({ - // Prefer agentId. subagent_type is a deprecated alias for backwards compatibility. - agentId: TaskAgentIdSchema.nullish(), - subagent_type: SubagentTypeSchema.nullish(), - prompt: z.string().min(1), - title: z.string().min(1), - run_in_background: z.boolean().default(false), - n: TaskToolBestOfCountSchema.nullish().describe( - "Optional best-of count. Use n when several agents should try the same prompt independently. Mutually exclusive with variants; omit both for a single task. Only use grouped runs for sub-agents without interfering side effects, such as read-only agents like explore." - ), - variants: TaskToolVariantsSchema.nullish().describe( - `Optional labels for sibling runs of the same prompt template. Use variants when the task should be repeated across labeled lanes such as issue numbers, commit windows, or frontend/backend/tests/docs review lanes. Mutually exclusive with n. When provided, Mux launches one sibling per label and substitutes ${TASK_VARIANT_PLACEHOLDER} in the prompt.` - ), - model: TaskToolModelSchema.nullish().describe( - "Optional model override for the sub-agent, parsed with the same alias logic as the UI (an alias or a full 'provider:model' string). Omit this unless the user explicitly instructed a specific model — by default the sub-agent inherits the parent's model. Do not assume any particular model is available." - ), - thinking: TaskToolThinkingSchema.nullish().describe( - "Optional thinking/reasoning-level override for the sub-agent. Accepts a level name (off, low, medium, high, xhigh, max) or a numeric index (resolved against the chosen model). Omit this unless the user explicitly instructed a specific thinking level — by default the sub-agent inherits the parent's thinking level." - ), - }) - .strict() - .superRefine((args, ctx) => { - const hasAgentId = typeof args.agentId === "string" && args.agentId.length > 0; - const hasSubagentType = typeof args.subagent_type === "string" && args.subagent_type.length > 0; +/** Shared validation across both task-arg schema variants (with/without `isolation`). */ +function refineTaskToolAgentArgs( + args: { + agentId?: string | null; + subagent_type?: string | null; + prompt: string; + n?: number | null; + variants?: string[] | null; + }, + ctx: z.RefinementCtx +): void { + const hasAgentId = typeof args.agentId === "string" && args.agentId.length > 0; + const hasSubagentType = typeof args.subagent_type === "string" && args.subagent_type.length > 0; + + if (!hasAgentId && !hasSubagentType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Provide agentId (preferred) or subagent_type", + path: ["agentId"], + }); + return; + } - if (!hasAgentId && !hasSubagentType) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Provide agentId (preferred) or subagent_type", - path: ["agentId"], - }); - return; - } + // GPT models often send both fields with identical values — allow that. + // Only reject when they conflict, since the handler silently prefers agentId. + if (hasAgentId && hasSubagentType && args.agentId !== args.subagent_type) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "agentId and subagent_type must match when both are provided", + path: ["agentId"], + }); + return; + } - // GPT models often send both fields with identical values — allow that. - // Only reject when they conflict, since the handler silently prefers agentId. - if (hasAgentId && hasSubagentType && args.agentId !== args.subagent_type) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "agentId and subagent_type must match when both are provided", - path: ["agentId"], - }); - return; - } + if (args.n != null && args.variants != null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "n and variants are mutually exclusive", + path: ["variants"], + }); + } - if (args.n != null && args.variants != null) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "n and variants are mutually exclusive", - path: ["variants"], - }); - } + if (args.variants == null) { + return; + } - if (args.variants == null) { - return; - } + const uniqueVariants = new Set(args.variants); + if (uniqueVariants.size !== args.variants.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "variants must be unique", + path: ["variants"], + }); + } - const uniqueVariants = new Set(args.variants); - if (uniqueVariants.size !== args.variants.length) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "variants must be unique", - path: ["variants"], - }); - } + if (!args.prompt.includes(TASK_VARIANT_PLACEHOLDER)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `prompt must reference ${TASK_VARIANT_PLACEHOLDER} when variants are provided`, + path: ["prompt"], + }); + } +} - if (!args.prompt.includes(TASK_VARIANT_PLACEHOLDER)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `prompt must reference ${TASK_VARIANT_PLACEHOLDER} when variants are provided`, - path: ["prompt"], - }); - } - }); +const taskToolBaseShape = { + // Prefer agentId. subagent_type is a deprecated alias for backwards compatibility. + agentId: TaskAgentIdSchema.nullish(), + subagent_type: SubagentTypeSchema.nullish(), + prompt: z.string().min(1), + title: z.string().min(1), + run_in_background: z.boolean().default(false), + n: TaskToolBestOfCountSchema.nullish().describe( + "Optional best-of count. Use n when several agents should try the same prompt independently. Mutually exclusive with variants; omit both for a single task. Only use grouped runs for sub-agents without interfering side effects, such as read-only agents like explore." + ), + variants: TaskToolVariantsSchema.nullish().describe( + `Optional labels for sibling runs of the same prompt template. Use variants when the task should be repeated across labeled lanes such as issue numbers, commit windows, or frontend/backend/tests/docs review lanes. Mutually exclusive with n. When provided, Mux launches one sibling per label and substitutes ${TASK_VARIANT_PLACEHOLDER} in the prompt.` + ), + model: TaskToolModelSchema.nullish().describe( + "Optional model override for the sub-agent, parsed with the same alias logic as the UI (an alias or a full 'provider:model' string). Omit this unless the user explicitly instructed a specific model — by default the sub-agent inherits the parent's model. Do not assume any particular model is available." + ), + thinking: TaskToolThinkingSchema.nullish().describe( + "Optional thinking/reasoning-level override for the sub-agent. Accepts a level name (off, low, medium, high, xhigh, max) or a numeric index (resolved against the chosen model). Omit this unless the user explicitly instructed a specific thinking level — by default the sub-agent inherits the parent's thinking level." + ), +}; + +// Canonical schema (always includes `isolation`) — used for the execute() re-parse and token +// counting so `isolation` is accepted regardless of the runtime the args were produced on. +export const TaskToolArgsSchema = z + .object({ + ...taskToolBaseShape, + isolation: TaskIsolationSchema.nullish().describe(TASK_ISOLATION_PARAM_DESCRIPTION), + }) + .strict() + .superRefine(refineTaskToolAgentArgs); + +// Variant WITHOUT `isolation`, advertised on runtimes that cannot share the parent checkout (e.g. +// local). `.strict()` makes it reject the field outright, so it never enters LLM context there. +const TaskToolArgsSchemaWithoutIsolation = z + .object(taskToolBaseShape) + .strict() + .superRefine(refineTaskToolAgentArgs); -export const TaskToolArgsSchema = TaskToolAgentArgsSchema; +/** + * Pick the task tool input schema for a runtime. `isolation` is only advertised on runtimes that + * support sharing the parent checkout (see {@link runtimeModeSupportsSharedTaskWorkspace}); on + * local runtimes the parameter is omitted from the schema entirely so it never enters LLM context. + */ +export function buildTaskToolAgentArgsSchema(options: { + includeIsolation: boolean; +}): typeof TaskToolArgsSchema | typeof TaskToolArgsSchemaWithoutIsolation { + return options.includeIsolation ? TaskToolArgsSchema : TaskToolArgsSchemaWithoutIsolation; +} const TaskToolSpawnedTaskSchema = z .object({ diff --git a/src/node/runtime/WorktreeRuntime.test.ts b/src/node/runtime/WorktreeRuntime.test.ts new file mode 100644 index 0000000000..851ff8d2ad --- /dev/null +++ b/src/node/runtime/WorktreeRuntime.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { execSync } from "node:child_process"; +import * as os from "os"; +import * as path from "path"; +import * as fs from "fs/promises"; + +import { WorktreeRuntime } from "./WorktreeRuntime"; + +describe("WorktreeRuntime workspacePath override", () => { + let rootDir: string; + + beforeEach(async () => { + rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-worktree-rt-")); + }); + + afterEach(async () => { + await fs.rm(rootDir, { recursive: true, force: true }); + }); + + it("returns the persisted path for its own workspace and the derived path otherwise", () => { + const srcBaseDir = path.join(rootDir, "src"); + const projectPath = path.join(rootDir, "repo"); + const sharedPath = path.join(rootDir, "parent-checkout"); + + // A shared (isolation: "none") task: unique child name, but path points at the parent checkout. + const runtime = new WorktreeRuntime(srcBaseDir, { + projectPath, + workspaceName: "agent_explore_child", + workspacePath: sharedPath, + }); + + // Its own identity resolves to the persisted shared path... + expect(runtime.getWorkspacePath(projectPath, "agent_explore_child")).toBe(sharedPath); + // ...while other workspaces still use the name-derived worktree path. + const derivedSibling = runtime.getWorkspacePath(projectPath, "sibling"); + expect(derivedSibling).not.toBe(sharedPath); + expect(derivedSibling).toContain("sibling"); + }); + + it("reports ready when the shared checkout is a git repo even though the derived path is absent", async () => { + const srcBaseDir = path.join(rootDir, "src"); + const projectPath = path.join(rootDir, "repo"); + const sharedPath = path.join(rootDir, "parent-checkout"); + await fs.mkdir(sharedPath, { recursive: true }); + execSync("git init -b main", { cwd: sharedPath, stdio: "ignore" }); + + // Name-derived path (//agent_explore_child) was never created. + const runtime = new WorktreeRuntime(srcBaseDir, { + projectPath, + workspaceName: "agent_explore_child", + workspacePath: sharedPath, + }); + + const ready = await runtime.ensureReady(); + expect(ready.ready).toBe(true); + }); + + it("reports not-ready without an override when the derived path does not exist", async () => { + const srcBaseDir = path.join(rootDir, "src"); + const projectPath = path.join(rootDir, "repo"); + + const runtime = new WorktreeRuntime(srcBaseDir, { + projectPath, + workspaceName: "missing-workspace", + }); + + const ready = await runtime.ensureReady(); + expect(ready.ready).toBe(false); + }); + + it("forks from the persisted shared checkout, not the name-derived path", async () => { + const srcBaseDir = path.join(rootDir, "src"); + const projectPath = path.join(rootDir, "repo"); + + await fs.mkdir(projectPath, { recursive: true }); + execSync("git init -b main", { cwd: projectPath, stdio: "ignore" }); + // CI runners have no global git identity/signing config. + execSync('git config user.email "test@example.com"', { cwd: projectPath, stdio: "ignore" }); + execSync('git config user.name "test"', { cwd: projectPath, stdio: "ignore" }); + execSync("git config commit.gpgsign false", { cwd: projectPath, stdio: "ignore" }); + execSync("git commit --allow-empty -m init", { cwd: projectPath, stdio: "ignore" }); + + const nullLogger = { + logStep: () => undefined, + logStdout: () => undefined, + logStderr: () => undefined, + logComplete: () => undefined, + }; + + // Materialize a real parent worktree (branch "parent"), the checkout a shared task reuses. + const parentRuntime = new WorktreeRuntime(srcBaseDir, { + projectPath, + workspaceName: "parent", + }); + const created = await parentRuntime.createWorkspace({ + projectPath, + branchName: "parent", + trunkBranch: "main", + directoryName: "parent", + initLogger: nullLogger, + }); + expect(created.success).toBe(true); + const parentPath = parentRuntime.getWorkspacePath(projectPath, "parent"); + + // Shared (isolation: "none") task identity: synthetic name, persisted path = parent checkout. + const runtime = new WorktreeRuntime(srcBaseDir, { + projectPath, + workspaceName: "agent_explore_child", + workspacePath: parentPath, + }); + + // The name-derived path for agent_explore_child was never created; the fork source must be + // resolved through the override to the parent checkout (branch "parent"). + const result = await runtime.forkWorkspace({ + projectPath, + sourceWorkspaceName: "agent_explore_child", + newWorkspaceName: "forked-from-shared", + initLogger: nullLogger, + }); + + expect(result.success).toBe(true); + }); +}); diff --git a/src/node/runtime/WorktreeRuntime.ts b/src/node/runtime/WorktreeRuntime.ts index 38bdb259fd..5e896b9a81 100644 --- a/src/node/runtime/WorktreeRuntime.ts +++ b/src/node/runtime/WorktreeRuntime.ts @@ -26,21 +26,37 @@ export class WorktreeRuntime extends LocalBaseRuntime { private readonly worktreeManager: WorktreeManager; private readonly currentProjectPath?: string; private readonly currentWorkspaceName?: string; + // Persisted checkout path for this runtime's own workspace. Set when a workspace's on-disk path + // diverges from the name-derived worktree path — e.g. an isolation: "none" task that shares its + // parent's checkout (its name is unique but its path points at the parent's worktree). + private readonly currentWorkspacePath?: string; constructor( srcBaseDir: string, options?: { projectPath?: string; workspaceName?: string; + workspacePath?: string; } ) { super(); this.worktreeManager = new WorktreeManager(srcBaseDir); this.currentProjectPath = options?.projectPath; this.currentWorkspaceName = options?.workspaceName; + this.currentWorkspacePath = options?.workspacePath; } getWorkspacePath(projectPath: string, workspaceName: string): string { + // Honor an explicit persisted path for this runtime's own workspace so callers (cwd resolution, + // ensureReady, agent discovery) land in the shared parent checkout instead of a name-derived + // directory that was never created. Mirrors SSHRuntime.getWorkspacePath. + if ( + this.currentWorkspacePath && + this.currentProjectPath === projectPath && + this.currentWorkspaceName === workspaceName + ) { + return this.currentWorkspacePath; + } return this.worktreeManager.getWorkspacePath(projectPath, workspaceName); } @@ -135,6 +151,11 @@ export class WorktreeRuntime extends LocalBaseRuntime { } async forkWorkspace(params: WorkspaceForkParams): Promise { - return this.worktreeManager.forkWorkspace(params); + // Resolve the source path through this runtime's override-aware getWorkspacePath so forks + // FROM a workspace with a persisted path override (e.g. an isolation: "none" task sharing + // its parent's checkout) read the real source checkout, not a name-derived path. + return this.worktreeManager.forkWorkspace(params, { + sourceWorkspacePath: this.getWorkspacePath(params.projectPath, params.sourceWorkspaceName), + }); } } diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index d0d6f9ee66..d7f0ae1c08 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4503,20 +4503,21 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "", "
", - "task (10)", - "", - "| Env var | JSON path | Type | Description |", - "| ---------------------------------- | ------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |", - "| `MUX_TOOL_INPUT_AGENT_ID` | `agentId` | string | — |", - "| `MUX_TOOL_INPUT_MODEL` | `model` | string | Optional model override for the sub-agent, parsed with the same alias logic as the UI (an alias or a full 'provider:model' string). Omit this unless the user explicitly instructed a specific model — by default the sub-agent inherits the parent's model. Do not assume any particular model is available. |", - "| `MUX_TOOL_INPUT_N` | `n` | number | Optional best-of count. Use n when several agents should try the same prompt independently. Mutually exclusive with variants; omit both for a single task. Only use grouped runs for sub-agents without interfering side effects, such as read-only agents like explore. |", - "| `MUX_TOOL_INPUT_PROMPT` | `prompt` | string | — |", - "| `MUX_TOOL_INPUT_RUN_IN_BACKGROUND` | `run_in_background` | boolean | — |", - "| `MUX_TOOL_INPUT_SUBAGENT_TYPE` | `subagent_type` | string | — |", - "| `MUX_TOOL_INPUT_THINKING` | `thinking` | string | Optional thinking/reasoning-level override for the sub-agent. Accepts a level name (off, low, medium, high, xhigh, max) or a numeric index (resolved against the chosen model). Omit this unless the user explicitly instructed a specific thinking level — by default the sub-agent inherits the parent's thinking level. |", - "| `MUX_TOOL_INPUT_TITLE` | `title` | string | — |", - "| `MUX_TOOL_INPUT_VARIANTS_` | `variants[]` | string | Optional labels for sibling runs of the same prompt template. Use variants when the task should be repeated across labeled lanes such as issue numbers, commit windows, or frontend/backend/tests/docs review lanes. Mutually exclusive with n. When provided, Mux launches one sibling per label and substitutes ${variant} in the prompt. |", - "| `MUX_TOOL_INPUT_VARIANTS_COUNT` | `variants.length` | number | Number of elements in variants (Optional labels for sibling runs of the same prompt template. Use variants when the task should be repeated across labeled lanes such as issue numbers, commit windows, or frontend/backend/tests/docs review lanes. Mutually exclusive with n. When provided, Mux launches one sibling per label and substitutes ${variant} in the prompt.) |", + "task (11)", + "", + "| Env var | JSON path | Type | Description |", + "| ---------------------------------- | ------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |", + "| `MUX_TOOL_INPUT_AGENT_ID` | `agentId` | string | — |", + '| `MUX_TOOL_INPUT_ISOLATION` | `isolation` | enum | Workspace isolation for the sub-agent. "fork" (the default) runs it in an isolated copy of this workspace created from committed state. "none" runs it directly in this workspace\'s checkout, sharing the working tree (including uncommitted changes) and skipping the fork + init overhead. Use "none" only for read-only analysis (e.g. the explore agent) or when you instruct the sub-agent to avoid editing shared files, since it can otherwise modify the same files concurrently. Omit to fork. |', + "| `MUX_TOOL_INPUT_MODEL` | `model` | string | Optional model override for the sub-agent, parsed with the same alias logic as the UI (an alias or a full 'provider:model' string). Omit this unless the user explicitly instructed a specific model — by default the sub-agent inherits the parent's model. Do not assume any particular model is available. |", + "| `MUX_TOOL_INPUT_N` | `n` | number | Optional best-of count. Use n when several agents should try the same prompt independently. Mutually exclusive with variants; omit both for a single task. Only use grouped runs for sub-agents without interfering side effects, such as read-only agents like explore. |", + "| `MUX_TOOL_INPUT_PROMPT` | `prompt` | string | — |", + "| `MUX_TOOL_INPUT_RUN_IN_BACKGROUND` | `run_in_background` | boolean | — |", + "| `MUX_TOOL_INPUT_SUBAGENT_TYPE` | `subagent_type` | string | — |", + "| `MUX_TOOL_INPUT_THINKING` | `thinking` | string | Optional thinking/reasoning-level override for the sub-agent. Accepts a level name (off, low, medium, high, xhigh, max) or a numeric index (resolved against the chosen model). Omit this unless the user explicitly instructed a specific thinking level — by default the sub-agent inherits the parent's thinking level. |", + "| `MUX_TOOL_INPUT_TITLE` | `title` | string | — |", + "| `MUX_TOOL_INPUT_VARIANTS_` | `variants[]` | string | Optional labels for sibling runs of the same prompt template. Use variants when the task should be repeated across labeled lanes such as issue numbers, commit windows, or frontend/backend/tests/docs review lanes. Mutually exclusive with n. When provided, Mux launches one sibling per label and substitutes ${variant} in the prompt. |", + "| `MUX_TOOL_INPUT_VARIANTS_COUNT` | `variants.length` | number | Number of elements in variants (Optional labels for sibling runs of the same prompt template. Use variants when the task should be repeated across labeled lanes such as issue numbers, commit windows, or frontend/backend/tests/docs review lanes. Mutually exclusive with n. When provided, Mux launches one sibling per label and substitutes ${variant} in the prompt.) |", "", "
", "", diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 6f48dbd080..19138805bc 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -1258,6 +1258,336 @@ describe("TaskService", () => { } }, 20_000); + test("isolation: none shares the parent worktree without forking or re-initializing", async () => { + const config = await createTestConfig(rootDir); + const projectPath = await createTestProject(rootDir); + + const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir }; + const runtime = createRuntime(runtimeConfig, { projectPath }); + const initLogger = createNullInitLogger(); + + const parentName = "parent"; + await runtime.createWorkspace({ + projectPath, + branchName: parentName, + trunkBranch: "main", + directoryName: parentName, + initLogger, + }); + const parentPath = runtime.getWorkspacePath(projectPath, parentName); + + const parentId = "1111111111"; + const childTaskId = "2222222222"; + stubStableIds(config, [childTaskId]); + + await saveWorkspaces( + config, + projectPath, + [ + { + path: parentPath, + id: parentId, + name: parentName, + createdAt: new Date().toISOString(), + runtimeConfig, + }, + ], + testTaskSettings() + ); + + // orchestrateFork must NOT be called for isolation: "none"; runBackgroundInit is stubbed only + // so a stray call would be observable (it should not be invoked either). + const forkSpy = spyOn(forkOrchestrator, "orchestrateFork"); + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( + () => undefined + ); + try { + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + const result = await createAgentTask(taskService, parentId, "read-only analysis", { + isolation: "none", + }); + + expect(result.success).toBe(true); + assert(result.success, "Expected shared-workspace task to be created"); + expect(result.data.status).toBe("running"); + expect(result.data.taskId).toBe(childTaskId); + + // No fork and no init: the sub-agent reuses the parent's live checkout. + expect(forkSpy).not.toHaveBeenCalled(); + expect(runBackgroundInitSpy).not.toHaveBeenCalled(); + + // The persisted child entry points at the parent's checkout and is flagged shared. + const childEntry = findWorkspaceInConfig(config, childTaskId); + assert(childEntry, "Expected child task workspace to be persisted"); + expect(childEntry.path).toBe(parentPath); + expect(childEntry.taskIsolation).toBe("none"); + expect(childEntry.runtimeConfig?.type).toBe("worktree"); + + expect(sendMessage).toHaveBeenCalledWith( + childTaskId, + "read-only analysis", + expect.anything(), + expect.objectContaining({ agentInitiated: true }) + ); + } finally { + runBackgroundInitSpy.mockRestore(); + forkSpy.mockRestore(); + } + }, 20_000); + + test("dequeued isolation: none task reuses the parent checkout without forking or init", async () => { + const config = await createTestConfig(rootDir); + const projectPath = await createTestProject(rootDir); + + const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir }; + const runtime = createRuntime(runtimeConfig, { projectPath }); + const initLogger = createNullInitLogger(); + + const parentName = "parent"; + await runtime.createWorkspace({ + projectPath, + branchName: parentName, + trunkBranch: "main", + directoryName: parentName, + initLogger, + }); + const parentPath = runtime.getWorkspacePath(projectPath, parentName); + + const parentId = "1111111111"; + const queuedTaskId = "task-shared-queued"; + const queuedWorkspaceName = "agent_explore_task-shared-queued"; + await saveWorkspaces( + config, + projectPath, + [ + { + path: parentPath, + id: parentId, + name: parentName, + createdAt: new Date().toISOString(), + runtimeConfig, + }, + { + // Shared queued tasks persist the parent's checkout path (see TaskService.create). + path: parentPath, + id: queuedTaskId, + name: queuedWorkspaceName, + title: "Shared queued task", + createdAt: new Date().toISOString(), + runtimeConfig, + parentWorkspaceId: parentId, + agentId: "explore", + agentType: "explore", + taskStatus: "queued", + taskPrompt: "queued shared analysis", + taskModelString: defaultModel, + taskTrunkBranch: parentName, + taskIsolation: "none", + }, + ], + testTaskSettings() + ); + + const forkSpy = spyOn(forkOrchestrator, "orchestrateFork"); + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( + () => undefined + ); + try { + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + await taskService.initialize(); + await waitForWorkspaceTaskStatus(config, queuedTaskId, "running"); + + // Dequeue must reuse the existing shared checkout: no fork, no init. + expect(forkSpy).not.toHaveBeenCalled(); + expect(runBackgroundInitSpy).not.toHaveBeenCalled(); + + const entry = findWorkspaceInConfig(config, queuedTaskId); + assert(entry, "Expected queued shared task to remain persisted"); + expect(entry.path).toBe(parentPath); + expect(entry.taskIsolation).toBe("none"); + + expect(sendMessage).toHaveBeenCalledWith( + queuedTaskId, + "queued shared analysis", + expect.anything(), + expect.objectContaining({ agentInitiated: true }) + ); + } finally { + runBackgroundInitSpy.mockRestore(); + forkSpy.mockRestore(); + } + }, 20_000); + + test("nested isolation: none task inherits the shared parent's real branch and checkout", async () => { + const config = await createTestConfig(rootDir); + const projectPath = await createTestProject(rootDir); + + const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir }; + const runtime = createRuntime(runtimeConfig, { projectPath }); + const initLogger = createNullInitLogger(); + + const grandparentName = "parent"; + await runtime.createWorkspace({ + projectPath, + branchName: grandparentName, + trunkBranch: "main", + directoryName: grandparentName, + initLogger, + }); + const checkoutPath = runtime.getWorkspacePath(projectPath, grandparentName); + + const grandparentId = "1111111111"; + const sharedParentId = "2222222222"; + const nestedChildId = "4444444444"; + stubStableIds(config, [nestedChildId]); + + await saveWorkspaces( + config, + projectPath, + [ + { + path: checkoutPath, + id: grandparentId, + name: grandparentName, + createdAt: new Date().toISOString(), + runtimeConfig, + }, + { + // The parent is itself a shared task: synthetic name, path = grandparent's checkout, + // and taskTrunkBranch names the real branch checked out there. + path: checkoutPath, + id: sharedParentId, + name: "agent_explore_shared-parent", + createdAt: new Date().toISOString(), + runtimeConfig, + parentWorkspaceId: grandparentId, + agentId: "explore", + agentType: "explore", + taskStatus: "running", + taskModelString: defaultModel, + taskTrunkBranch: grandparentName, + taskIsolation: "none", + }, + ], + testTaskSettings() + ); + + const forkSpy = spyOn(forkOrchestrator, "orchestrateFork"); + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( + () => undefined + ); + try { + const { workspaceService } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + const result = await createAgentTask(taskService, sharedParentId, "nested analysis", { + isolation: "none", + }); + + expect(result.success).toBe(true); + assert(result.success, "Expected nested shared task to be created"); + expect(forkSpy).not.toHaveBeenCalled(); + + const childEntry = findWorkspaceInConfig(config, nestedChildId); + assert(childEntry, "Expected nested shared task to be persisted"); + // Path resolves through the parent's persisted (shared) checkout, not its synthetic name. + expect(childEntry.path).toBe(checkoutPath); + // The persisted trunk branch is the REAL branch in the shared checkout (the grandparent's), + // not the parent's synthetic agent workspace name — fork fallbacks depend on it existing. + expect(childEntry.taskTrunkBranch).toBe(grandparentName); + expect(childEntry.taskIsolation).toBe("none"); + } finally { + runBackgroundInitSpy.mockRestore(); + forkSpy.mockRestore(); + } + }, 20_000); + + test("createMany honors isolation: none by reusing the parent checkout", async () => { + const config = await createTestConfig(rootDir); + const projectPath = await createTestProject(rootDir); + + const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir }; + const runtime = createRuntime(runtimeConfig, { projectPath }); + const initLogger = createNullInitLogger(); + + const parentName = "parent"; + await runtime.createWorkspace({ + projectPath, + branchName: parentName, + trunkBranch: "main", + directoryName: parentName, + initLogger, + }); + const parentPath = runtime.getWorkspacePath(projectPath, parentName); + + const parentId = "1111111111"; + const childTaskId = "3333333333"; + stubStableIds(config, [childTaskId]); + + await saveWorkspaces( + config, + projectPath, + [ + { + path: parentPath, + id: parentId, + name: parentName, + createdAt: new Date().toISOString(), + runtimeConfig, + }, + ], + testTaskSettings() + ); + + const forkSpy = spyOn(forkOrchestrator, "orchestrateFork"); + const runBackgroundInitSpy = spyOn(runtimeFactory, "runBackgroundInit").mockImplementation( + () => undefined + ); + try { + const { workspaceService, sendMessage } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + const result = await taskService.createMany([ + { + parentWorkspaceId: parentId, + kind: "agent" as const, + agentId: "explore", + prompt: "batched shared analysis", + title: "Batched shared task", + isolation: "none" as const, + }, + ]); + + expect(result.success).toBe(true); + assert(result.success, "Expected createMany to succeed"); + expect(result.data[0]?.status).toBe("starting"); + + // The reserved entry must point at the parent's checkout and carry the shared flag so + // the reservation launch path reuses it (no fork, no init) and removal preserves it. + const entry = findWorkspaceInConfig(config, childTaskId); + assert(entry, "Expected batched shared task to be persisted"); + expect(entry.path).toBe(parentPath); + expect(entry.taskIsolation).toBe("none"); + + await waitForWorkspaceTaskStatus(config, childTaskId, "running"); + expect(forkSpy).not.toHaveBeenCalled(); + expect(runBackgroundInitSpy).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledWith( + childTaskId, + "batched shared analysis", + expect.anything(), + expect.objectContaining({ agentInitiated: true }) + ); + } finally { + runBackgroundInitSpy.mockRestore(); + forkSpy.mockRestore(); + } + }, 20_000); + test("interrupts queued tasks when the primary project loses trust before dequeue", async () => { const config = await createTestConfig(rootDir); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 5518c46a17..602edf8c92 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -58,8 +58,9 @@ import { import { defaultModel, normalizeToCanonical } from "@/common/utils/ai/models"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; -import type { RuntimeConfig } from "@/common/types/runtime"; -import type { WorkspaceMetadata } from "@/common/types/workspace"; +import { runtimeModeSupportsSharedTaskWorkspace, type RuntimeConfig } from "@/common/types/runtime"; +import type { ProjectRef, WorkspaceMetadata } from "@/common/types/workspace"; +import { getRuntimeType } from "@/node/runtime/initHook"; import { AgentIdSchema } from "@/common/orpc/schemas"; import { normalizeAgentId, @@ -84,6 +85,7 @@ import { AgentReportSubmittedReportSchema, TaskToolResultSchema, TaskToolArgsSchema, + type TaskIsolation, } from "@/common/utils/tools/toolDefinitions"; import { isPlanLikeInResolvedChain } from "@/common/utils/agentTools"; import { formatSendMessageError } from "@/node/services/utils/sendMessageError"; @@ -153,6 +155,12 @@ export interface TaskCreateArgs { * in resolveTaskAISettings, mirroring the UI's `/model+level` semantics. */ thinkingLevel?: ParsedThinkingInput; + /** + * Workspace isolation for this task. "none" runs the sub-agent directly in the parent + * workspace's checkout (shared working tree, no fork) on runtimes that support it; defaults to + * "fork" (isolated copy) when omitted. Ignored (treated as "fork") on unsupported runtimes. + */ + isolation?: TaskIsolation; parentRuntimeAiSettings?: { modelString?: string; thinkingLevel?: ThinkingLevel }; /** * Model-refusal policy persisted on the child workspace. "fail" opts the task @@ -1633,7 +1641,11 @@ export class TaskService { return Ok([]); } - const plans: Array = []; + // sharedWorkspacePath is set for honored isolation: "none" plans; the entry is persisted + // pointing at the parent's checkout and startReservedAgentTask reuses it without fork/init. + const plans: Array< + TaskLaunchPlan & { status: "queued" | "starting"; sharedWorkspacePath?: string } + > = []; const results: TaskCreateResult[] = []; await using _lock = await this.mutex.acquire(); @@ -1741,15 +1753,45 @@ export class TaskService { const parentRuntimeConfig = parentMeta.runtimeConfig; const taskRuntimeConfig: RuntimeConfig = parentRuntimeConfig; + // Supply the parent's persisted path so override-aware runtimes (worktree/SSH) resolve the + // parent's REAL checkout when the parent is itself an isolation: "none" task (see create()). const runtime = createRuntimeForWorkspace({ runtimeConfig: taskRuntimeConfig, projectPath: parentMeta.projectPath, name: parentMeta.name, + namedWorkspacePath: coerceNonEmptyString(parentEntry?.workspace.path), }); + // Prefer the parent's persisted checkout path over the name-derived one: when the parent is + // itself an isolation: "none" task, its name is synthetic and the derived path does not + // exist — its real checkout is the persisted (shared) path. const isInPlace = parentMeta.projectPath === parentMeta.name; const parentWorkspacePath = isInPlace ? parentMeta.projectPath - : runtime.getWorkspacePath(parentMeta.projectPath, parentMeta.name); + : (coerceNonEmptyString(parentEntry?.workspace.path) ?? + runtime.getWorkspacePath(parentMeta.projectPath, parentMeta.name)); + + // isolation: "none" — same gating as create(): only worktree/SSH single-project parents + // share the parent checkout; everything else falls back to the normal fork path. + const taskRuntimeMode = getRuntimeType(taskRuntimeConfig); + const parentIsMultiProject = (parentMeta.projects?.length ?? 0) > 1; + const useSharedWorkspace = + args.isolation === "none" && + runtimeModeSupportsSharedTaskWorkspace(taskRuntimeMode) && + !parentIsMultiProject; + const sharedWorkspacePath = useSharedWorkspace ? parentWorkspacePath : undefined; + // Branch actually checked out in the parent's checkout (see create() for rationale). + const parentIsSharedTask = parentEntry?.workspace.taskIsolation === "none"; + const parentBranchName = parentIsSharedTask + ? (coerceNonEmptyString(parentEntry?.workspace.taskTrunkBranch) ?? + coerceNonEmptyString(parentMeta.name)) + : coerceNonEmptyString(parentMeta.name); + if (args.isolation === "none" && !useSharedWorkspace) { + log.debug("Task.createMany: isolation=none not honored; falling back to fork", { + taskId, + runtimeMode: taskRuntimeMode, + parentIsMultiProject, + }); + } const getRunnableHint = async (): Promise => { try { @@ -1831,6 +1873,14 @@ export class TaskService { experiments: args.experiments, onRefusal: args.onRefusal, status, + ...(sharedWorkspacePath != null ? { sharedWorkspacePath } : {}), + // Real branch checked out in the parent's checkout: persisted as taskTrunkBranch and used + // by orchestrateFork's create-fallback when the fork cannot detect a source branch + // (a shared parent's synthetic name never names a real branch). Gated to shared parents + // to keep the existing branch-discovery fallback otherwise. + ...(parentIsSharedTask && parentBranchName != null + ? { preferredTrunkBranch: parentBranchName } + : {}), }); results.push({ taskId, kind: "agent", status }); } @@ -1849,11 +1899,12 @@ export class TaskService { projectPath: plan.parentMeta.projectPath, name: plan.parentMeta.name, }); - const workspacePath = runtime.getWorkspacePath( - plan.parentMeta.projectPath, - plan.workspaceName - ); - const trunkBranch = coerceNonEmptyString(plan.parentMeta.name); + const workspacePath = + plan.sharedWorkspacePath ?? + runtime.getWorkspacePath(plan.parentMeta.projectPath, plan.workspaceName); + const trunkBranch = + coerceNonEmptyString(plan.preferredTrunkBranch) ?? + coerceNonEmptyString(plan.parentMeta.name); if (!trunkBranch) { throw new Error("Task.createMany: parent workspace name missing"); } @@ -1885,6 +1936,7 @@ export class TaskService { taskThinkingLevel: plan.effectiveThinkingLevel, taskOnRefusal: plan.onRefusal, taskExperiments: plan.experiments, + taskIsolation: plan.sharedWorkspacePath != null ? "none" : undefined, projects: plan.parentMeta.projects, }); } @@ -1910,25 +1962,38 @@ export class TaskService { runtime: Runtime, projectPath: string, workspaceName: string, - taskId: string + taskId: string, + options?: { + /** + * Skip physical workspace deletion. Required for isolation: "none" tasks whose runtime + * resolves this task's name to the shared parent checkout (e.g. SSHRuntime.deleteWorkspace + * goes through the persisted-path override) — deleting it would destroy the parent's + * working tree. Session/config cleanup still runs. + */ + preservePhysicalWorkspace?: boolean; + } ): Promise { assert(projectPath.length > 0, "cleanupMaterializedTaskWorkspace requires projectPath"); assert(workspaceName.length > 0, "cleanupMaterializedTaskWorkspace requires workspaceName"); assert(taskId.length > 0, "cleanupMaterializedTaskWorkspace requires taskId"); - try { - const deleteResult = await runtime.deleteWorkspace(projectPath, workspaceName, true); - if (!deleteResult.success) { - log.error("Task launch cleanup: failed to delete materialized workspace", { + if (options?.preservePhysicalWorkspace) { + log.debug("Task launch cleanup: preserving shared parent checkout", { taskId }); + } else { + try { + const deleteResult = await runtime.deleteWorkspace(projectPath, workspaceName, true); + if (!deleteResult.success) { + log.error("Task launch cleanup: failed to delete materialized workspace", { + taskId, + error: deleteResult.error, + }); + } + } catch (error: unknown) { + log.error("Task launch cleanup: runtime.deleteWorkspace threw", { taskId, - error: deleteResult.error, + error: getErrorMessage(error), }); } - } catch (error: unknown) { - log.error("Task launch cleanup: runtime.deleteWorkspace threw", { - taskId, - error: getErrorMessage(error), - }); } try { @@ -2107,11 +2172,26 @@ export class TaskService { return; } + // isolation: "none" tasks were queued pointing at the parent's checkout. When that checkout + // still exists, materialization reuses it (no fork); if it disappeared, materialization falls + // back to forking a real workspace and the shared flag must be cleared below. + const taskWasShared = entryAtStart.workspace.taskIsolation === "none"; + const persistedSharedPath = taskWasShared + ? coerceNonEmptyString(entryAtStart.workspace.path) + : undefined; + const initLogger = this.startWorkspaceInit(plan.taskId, plan.parentMeta.projectPath); + // Supply the parent's persisted path so override-aware runtimes (worktree/SSH) fork from the + // parent's REAL checkout when the parent is itself an isolation: "none" task (see create()). + const parentEntryForLaunch = findWorkspaceEntry( + this.config.loadConfigOrDefault(), + plan.parentWorkspaceId + ); const runtime = createRuntimeForWorkspace({ runtimeConfig: plan.taskRuntimeConfig, projectPath: plan.parentMeta.projectPath, name: plan.parentMeta.name, + namedWorkspacePath: coerceNonEmptyString(parentEntryForLaunch?.workspace.path), }); let materialized: MaterializedTaskLaunch | null; @@ -2126,6 +2206,11 @@ export class TaskService { return; } + // Reuse of the persisted shared path means the task still runs in the parent's checkout; + // any other materialized path means the fork fallback created a real (deletable) workspace. + const sharesParentCheckout = + taskWasShared && materialized.workspacePath === persistedSharedPath; + const entryAfterMaterialize = findWorkspaceEntry( this.config.loadConfigOrDefault(), plan.taskId @@ -2136,7 +2221,8 @@ export class TaskService { materialized.runtimeForTaskWorkspace, plan.parentMeta.projectPath, plan.workspaceName, - plan.taskId + plan.taskId, + { preservePhysicalWorkspace: sharesParentCheckout } ); return; } @@ -2185,6 +2271,11 @@ export class TaskService { ws.taskBaseCommitSha = taskBaseCommitSha ?? undefined; ws.taskBaseCommitShaByProjectPath = taskBaseCommitShaByProjectPath; ws.projects = inheritedProjects; + // The shared parent checkout was gone, so this task had to fork a real workspace. + // Clear the shared flag so removal cleans up the new worktree. + if (taskWasShared && !sharesParentCheckout) { + ws.taskIsolation = undefined; + } }, { allowMissing: true } ); @@ -2197,7 +2288,8 @@ export class TaskService { runtimeForTaskWorkspace, plan.parentMeta.projectPath, plan.workspaceName, - plan.taskId + plan.taskId, + { preservePhysicalWorkspace: sharesParentCheckout } ); return; } @@ -2206,27 +2298,34 @@ export class TaskService { return; } - const secrets = await secretsToRecord( - this.config.getEffectiveSecrets(plan.parentMeta.projectPath), - this.opResolver - ); - runBackgroundInit( - runtimeForTaskWorkspace, - { - projectPath: plan.parentMeta.projectPath, - branchName: plan.workspaceName, - trunkBranch, - workspacePath, - initLogger, - env: secrets, - skipInitHook: plan.skipInitHook, - trusted: - this.config - .loadConfigOrDefault() - .projects.get(stripTrailingSlashes(plan.parentMeta.projectPath))?.trusted ?? false, - }, - plan.taskId - ); + if (sharesParentCheckout) { + // The parent's checkout is already initialized and live; re-running init would redundantly + // (and possibly disruptively) mutate it. Skip init entirely. + initLogger.logStep("Sharing parent workspace (isolation: none) — skipping fork and init"); + initLogger.logComplete(0); + } else { + const secrets = await secretsToRecord( + this.config.getEffectiveSecrets(plan.parentMeta.projectPath), + this.opResolver + ); + runBackgroundInit( + runtimeForTaskWorkspace, + { + projectPath: plan.parentMeta.projectPath, + branchName: plan.workspaceName, + trunkBranch, + workspacePath, + initLogger, + env: secrets, + skipInitHook: plan.skipInitHook, + trusted: + this.config + .loadConfigOrDefault() + .projects.get(stripTrailingSlashes(plan.parentMeta.projectPath))?.trusted ?? false, + }, + plan.taskId + ); + } const startOptions = { model: plan.taskModelString, @@ -2253,7 +2352,8 @@ export class TaskService { runtimeForTaskWorkspace, plan.parentMeta.projectPath, plan.workspaceName, - plan.taskId + plan.taskId, + { preservePhysicalWorkspace: sharesParentCheckout } ); throw new Error(message); } @@ -2389,17 +2489,52 @@ export class TaskService { const parentRuntimeConfig = parentMeta.runtimeConfig; const taskRuntimeConfig: RuntimeConfig = parentRuntimeConfig; + // Supply the parent's persisted path so override-aware runtimes (worktree/SSH) resolve the + // parent's REAL checkout — critical when the parent is itself an isolation: "none" task whose + // synthetic name has no derived checkout (agent discovery + fork source both depend on it). const runtime = createRuntimeForWorkspace({ runtimeConfig: taskRuntimeConfig, projectPath: parentMeta.projectPath, name: parentMeta.name, + namedWorkspacePath: coerceNonEmptyString(parentEntry?.workspace.path), }); // Validate the agent definition exists and is runnable as a sub-agent. + // Prefer the parent's persisted checkout path over the name-derived one: when the parent is + // itself an isolation: "none" task, its name is synthetic and the derived path does not exist — + // its real checkout is the persisted (shared) path. Persisted paths are canonical elsewhere too + // (see runtimeHelpers.resolveWorkspaceRootPath). const isInPlace = parentMeta.projectPath === parentMeta.name; const parentWorkspacePath = isInPlace ? parentMeta.projectPath - : runtime.getWorkspacePath(parentMeta.projectPath, parentMeta.name); + : (coerceNonEmptyString(parentEntry?.workspace.path) ?? + runtime.getWorkspacePath(parentMeta.projectPath, parentMeta.name)); + + // isolation: "none" — run the sub-agent directly in the parent workspace's checkout instead of + // forking a new one. Only honored on runtimes where the fork creates a separate checkout we can + // safely bypass (worktree/SSH) and for single-project parents; otherwise fall back to forking. + const taskRuntimeMode = getRuntimeType(taskRuntimeConfig); + const parentIsMultiProject = (parentMeta.projects?.length ?? 0) > 1; + const useSharedWorkspace = + args.isolation === "none" && + runtimeModeSupportsSharedTaskWorkspace(taskRuntimeMode) && + !parentIsMultiProject; + // The branch actually checked out in the parent's checkout. When the parent is itself an + // isolation: "none" task, parentMeta.name is a synthetic agent workspace name with no real + // branch — the shared checkout sits on the parent's own persisted taskTrunkBranch. Persisting + // the real branch keeps dequeue fork-fallbacks (preferredTrunkBranch) on an existing base. + const parentIsSharedTask = parentEntry?.workspace.taskIsolation === "none"; + const parentBranchName = parentIsSharedTask + ? (coerceNonEmptyString(parentEntry?.workspace.taskTrunkBranch) ?? + coerceNonEmptyString(parentMeta.name)) + : coerceNonEmptyString(parentMeta.name); + if (args.isolation === "none" && !useSharedWorkspace) { + log.debug("Task.create: isolation=none not honored; falling back to fork", { + taskId, + runtimeMode: taskRuntimeMode, + parentIsMultiProject, + }); + } // Helper to build error hint with all available runnable agents. // NOTE: This resolves frontmatter inheritance so same-name overrides (e.g. project exec.md @@ -2489,7 +2624,7 @@ export class TaskService { }); if (shouldQueue) { - const trunkBranch = coerceNonEmptyString(parentMeta.name); + const trunkBranch = parentBranchName; if (!trunkBranch) { return Err("Task.create: parent workspace name missing (cannot queue task)"); } @@ -2497,7 +2632,11 @@ export class TaskService { // NOTE: Queued tasks are persisted immediately, but their workspace is created later // when a parallel slot is available. This ensures queued tasks don't create worktrees // or run init hooks until they actually start. - const workspacePath = runtime.getWorkspacePath(parentMeta.projectPath, workspaceName); + // Shared-workspace (isolation: "none") tasks point at the parent's existing checkout, so the + // dequeue path sees the directory already exists and skips fork + init. + const workspacePath = useSharedWorkspace + ? parentWorkspacePath + : runtime.getWorkspacePath(parentMeta.projectPath, workspaceName); taskQueueDebug("TaskService.create queued (persist-only)", { taskId, @@ -2534,6 +2673,7 @@ export class TaskService { taskThinkingLevel: effectiveThinkingLevel, taskOnRefusal: args.onRefusal, taskExperiments: args.experiments, + taskIsolation: useSharedWorkspace ? "none" : undefined, projects: parentMeta.projects, }); return config; @@ -2558,50 +2698,89 @@ export class TaskService { const initLogger = this.startWorkspaceInit(taskId, parentMeta.projectPath); - // Note: Local project-dir runtimes share the same directory (unsafe by design). - // For worktree/ssh runtimes we attempt a fork first; otherwise fall back to createWorkspace. + let workspacePath: string; + let trunkBranch: string; + let forkedRuntimeConfig: RuntimeConfig; + let runtimeForTaskWorkspace: Runtime; + let forkedFromSource: boolean; + let inheritedProjects: ProjectRef[] | undefined; + + if (useSharedWorkspace) { + // isolation: "none" — run the sub-agent directly in the parent workspace's checkout instead + // of forking. Mirrors local-runtime semantics for worktree/SSH so read-only analysis (or + // prompt-isolated work) skips the fork + init overhead and sees the parent's uncommitted work. + // + // SAFETY: the task still gets a unique workspace name, and workspace deletion is keyed on that + // name (runtime.deleteWorkspace(projectPath, name)), so removing this task never deletes the + // shared parent checkout. workspaceService.remove additionally skips physical deletion for + // tasks persisted with taskIsolation === "none". + workspacePath = parentWorkspacePath; + trunkBranch = parentBranchName ?? "main"; + forkedRuntimeConfig = parentRuntimeConfig; + forkedFromSource = false; + inheritedProjects = parentMeta.projects; + // Build the runtime with the child's identity but the parent's checkout path. Worktree/SSH + // runtimes honor this persisted path override (see *Runtime.getWorkspacePath), so cwd + // resolution and ensureReady land in the shared parent checkout instead of a name-derived + // directory that was never created. This mirrors the runtime rebuilt from the persisted entry. + runtimeForTaskWorkspace = createRuntimeForWorkspace({ + runtimeConfig: parentRuntimeConfig, + projectPath: parentMeta.projectPath, + name: workspaceName, + namedWorkspacePath: parentWorkspacePath, + }); + initLogger.logStep("Sharing parent workspace (isolation: none) — skipping fork and init"); + initLogger.logComplete(0); + } else { + // Note: Local project-dir runtimes share the same directory (unsafe by design). + // For worktree/ssh runtimes we attempt a fork first; otherwise fall back to createWorkspace. + const forkResult = await orchestrateFork({ + sourceRuntime: runtime, + projectPath: parentMeta.projectPath, + sourceWorkspaceName: parentMeta.name, + newWorkspaceName: workspaceName, + initLogger, + config: this.config, + sourceWorkspaceId: parentWorkspaceId, + sourceRuntimeConfig: parentRuntimeConfig, + parentMetadata: parentMeta, + allowCreateFallback: true, + // Create-fallback base when the fork cannot detect a source branch — a shared parent's + // synthetic name never names a real branch, so supply the actual checked-out branch. + // Gated to shared parents to keep the existing branch-discovery fallback otherwise. + ...(parentIsSharedTask && parentBranchName != null + ? { preferredTrunkBranch: parentBranchName } + : {}), + trusted: + this.config + .loadConfigOrDefault() + .projects.get(stripTrailingSlashes(parentMeta.projectPath))?.trusted ?? false, + multiProjectExperimentEnabled: this.workspaceService.isExperimentEnabled( + EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES + ), + }); - const forkResult = await orchestrateFork({ - sourceRuntime: runtime, - projectPath: parentMeta.projectPath, - sourceWorkspaceName: parentMeta.name, - newWorkspaceName: workspaceName, - initLogger, - config: this.config, - sourceWorkspaceId: parentWorkspaceId, - sourceRuntimeConfig: parentRuntimeConfig, - parentMetadata: parentMeta, - allowCreateFallback: true, - trusted: - this.config.loadConfigOrDefault().projects.get(stripTrailingSlashes(parentMeta.projectPath)) - ?.trusted ?? false, - multiProjectExperimentEnabled: this.workspaceService.isExperimentEnabled( - EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES - ), - }); + if (forkResult.success && forkResult.data.sourceRuntimeConfigUpdate) { + await this.config.updateWorkspaceMetadata(parentWorkspaceId, { + runtimeConfig: forkResult.data.sourceRuntimeConfigUpdate, + }); + // Ensure UI gets the updated runtimeConfig for the parent workspace. + await this.emitWorkspaceMetadata(parentWorkspaceId); + } - if (forkResult.success && forkResult.data.sourceRuntimeConfigUpdate) { - await this.config.updateWorkspaceMetadata(parentWorkspaceId, { - runtimeConfig: forkResult.data.sourceRuntimeConfigUpdate, - }); - // Ensure UI gets the updated runtimeConfig for the parent workspace. - await this.emitWorkspaceMetadata(parentWorkspaceId); - } + if (!forkResult.success) { + initLogger.logComplete(-1); + return Err(`Task fork failed: ${forkResult.error}`); + } - if (!forkResult.success) { - initLogger.logComplete(-1); - return Err(`Task fork failed: ${forkResult.error}`); + workspacePath = forkResult.data.workspacePath; + trunkBranch = forkResult.data.trunkBranch; + forkedRuntimeConfig = forkResult.data.forkedRuntimeConfig; + runtimeForTaskWorkspace = forkResult.data.targetRuntime; + forkedFromSource = forkResult.data.forkedFromSource; + inheritedProjects = forkResult.data.projects; } - const { - workspacePath, - trunkBranch, - forkedRuntimeConfig, - targetRuntime: runtimeForTaskWorkspace, - forkedFromSource, - projects: inheritedProjects, - } = forkResult.data; - // Multi-project forks need per-project secrets for each runtime's init hook. this.configureMultiProjectRuntimeEnvResolver(runtimeForTaskWorkspace); @@ -2654,6 +2833,7 @@ export class TaskService { taskThinkingLevel: effectiveThinkingLevel, taskOnRefusal: args.onRefusal, taskExperiments: args.experiments, + taskIsolation: useSharedWorkspace ? "none" : undefined, projects: inheritedProjects, }); return config; @@ -2662,28 +2842,32 @@ export class TaskService { // Emit metadata update so the UI sees the workspace immediately. await this.emitWorkspaceMetadata(taskId); - // Kick init (best-effort, async). - const secrets = await secretsToRecord( - this.config.getEffectiveSecrets(parentMeta.projectPath), - this.opResolver - ); - runBackgroundInit( - runtimeForTaskWorkspace, - { - projectPath: parentMeta.projectPath, - branchName: workspaceName, - trunkBranch, - workspacePath, - initLogger, - env: secrets, - skipInitHook, - trusted: - this.config - .loadConfigOrDefault() - .projects.get(stripTrailingSlashes(parentMeta.projectPath))?.trusted ?? false, - }, - taskId - ); + // Kick init (best-effort, async). Shared-workspace (isolation: "none") tasks reuse the parent's + // already-initialized checkout, so re-running init would redundantly (and possibly disruptively) + // mutate the live parent workspace — skip it entirely. + if (!useSharedWorkspace) { + const secrets = await secretsToRecord( + this.config.getEffectiveSecrets(parentMeta.projectPath), + this.opResolver + ); + runBackgroundInit( + runtimeForTaskWorkspace, + { + projectPath: parentMeta.projectPath, + branchName: workspaceName, + trunkBranch, + workspacePath, + initLogger, + env: secrets, + skipInitHook, + trusted: + this.config + .loadConfigOrDefault() + .projects.get(stripTrailingSlashes(parentMeta.projectPath))?.trusted ?? false, + }, + taskId + ); + } // Start immediately (counts towards parallel limit). const sendResult = await this.workspaceService.sendMessage( @@ -2706,7 +2890,8 @@ export class TaskService { runtimeForTaskWorkspace, parentMeta.projectPath, workspaceName, - taskId + taskId, + { preservePhysicalWorkspace: useSharedWorkspace } ); return Err(message); } @@ -3004,7 +3189,16 @@ export class TaskService { runtime: Runtime, projectPath: string, workspaceName: string, - taskId: string + taskId: string, + options?: { + /** + * Skip physical workspace deletion. Required for isolation: "none" tasks whose runtime + * resolves this task's name to the shared parent checkout (e.g. SSHRuntime.deleteWorkspace + * goes through the persisted-path override) — deleting it would destroy the parent's + * working tree. Session/config cleanup still runs. + */ + preservePhysicalWorkspace?: boolean; + } ): Promise { try { await this.config.removeWorkspace(taskId); @@ -3017,19 +3211,23 @@ export class TaskService { this.workspaceService.emit("metadata", { workspaceId: taskId, metadata: null }); - try { - const deleteResult = await runtime.deleteWorkspace(projectPath, workspaceName, true); - if (!deleteResult.success) { - log.error("Task.create rollback: failed to delete workspace", { + if (options?.preservePhysicalWorkspace) { + log.debug("Task.create rollback: preserving shared parent checkout", { taskId }); + } else { + try { + const deleteResult = await runtime.deleteWorkspace(projectPath, workspaceName, true); + if (!deleteResult.success) { + log.error("Task.create rollback: failed to delete workspace", { + taskId, + error: deleteResult.error, + }); + } + } catch (error: unknown) { + log.error("Task.create rollback: runtime.deleteWorkspace threw", { taskId, - error: deleteResult.error, + error: getErrorMessage(error), }); } - } catch (error: unknown) { - log.error("Task.create rollback: runtime.deleteWorkspace threw", { - taskId, - error: getErrorMessage(error), - }); } try { diff --git a/src/node/services/tools/task.test.ts b/src/node/services/tools/task.test.ts index 5157fd8389..115665520e 100644 --- a/src/node/services/tools/task.test.ts +++ b/src/node/services/tools/task.test.ts @@ -63,6 +63,96 @@ describe("task tool", () => { expect(tool.description).toContain("Uncommitted changes from the parent are not available"); }); + // The advertised inputSchema is the raw (strict) Zod schema. A `.strict()` schema that omits + // `isolation` rejects the field outright, proving it never enters LLM context for that runtime. + const parseWithIsolation = (tool: ReturnType) => + (tool.inputSchema as { safeParse: (v: unknown) => { success: boolean } }).safeParse({ + agentId: "explore", + prompt: "look", + title: "Look", + isolation: "none", + }); + + it("omits the isolation parameter from the schema on local runtimes", () => { + using tempDir = new TestTempDir("test-task-tool-local-isolation-schema"); + const tool = createTaskTool({ + ...createTestToolConfig(tempDir.path), + muxEnv: { MUX_RUNTIME: "local" }, + }); + + expect(parseWithIsolation(tool).success).toBe(false); + }); + + it("advertises the isolation parameter in the schema on worktree runtimes", () => { + using tempDir = new TestTempDir("test-task-tool-worktree-isolation-schema"); + const tool = createTaskTool({ + ...createTestToolConfig(tempDir.path), + muxEnv: { MUX_RUNTIME: "worktree" }, + }); + + expect(parseWithIsolation(tool).success).toBe(true); + }); + + it("forwards isolation to taskService.create", async () => { + using tempDir = new TestTempDir("test-task-tool-isolation-passthrough"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + + const create = mock((_: { isolation?: unknown }) => + Ok({ taskId: "child-task", kind: "agent" as const, status: "running" as const }) + ); + const waitForAgentReport = mock(() => Promise.resolve({ reportMarkdown: "ignored" })); + const taskService = { create, waitForAgentReport } as unknown as TaskService; + + const tool = createTaskTool({ + ...baseConfig, + muxEnv: { MUX_RUNTIME: "worktree" }, + taskService, + }); + + await Promise.resolve( + tool.execute!( + { + subagent_type: "explore", + prompt: "read-only look", + title: "Child task", + run_in_background: true, + isolation: "none", + }, + mockToolCallOptions + ) + ); + + expect(create).toHaveBeenCalledTimes(1); + expect(create.mock.calls[0]?.[0]?.isolation).toBe("none"); + }); + + it("omits isolation from taskService.create when not provided", async () => { + using tempDir = new TestTempDir("test-task-tool-isolation-default"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + + const create = mock((_: { isolation?: unknown }) => + Ok({ taskId: "child-task", kind: "agent" as const, status: "queued" as const }) + ); + const waitForAgentReport = mock(() => Promise.resolve({ reportMarkdown: "ignored" })); + const taskService = { create, waitForAgentReport } as unknown as TaskService; + + const tool = createTaskTool({ + ...baseConfig, + muxEnv: { MUX_RUNTIME: "worktree" }, + taskService, + }); + + await Promise.resolve( + tool.execute!( + { subagent_type: "explore", prompt: "do it", title: "Child task", run_in_background: true }, + mockToolCallOptions + ) + ); + + expect(create).toHaveBeenCalledTimes(1); + expect(create.mock.calls[0]?.[0]?.isolation).toBeUndefined(); + }); + it("should return immediately when run_in_background is true", async () => { using tempDir = new TestTempDir("test-task-tool"); const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); diff --git a/src/node/services/tools/task.ts b/src/node/services/tools/task.ts index 353c37f83d..fc566e471d 100644 --- a/src/node/services/tools/task.ts +++ b/src/node/services/tools/task.ts @@ -7,9 +7,14 @@ import type { ToolConfiguration, ToolFactory } from "@/common/utils/tools/tools" import { TaskToolResultSchema, TOOL_DEFINITIONS, + buildTaskToolAgentArgsSchema, buildTaskToolDescription, } from "@/common/utils/tools/toolDefinitions"; -import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime"; +import { + RUNTIME_MODE, + runtimeModeSupportsSharedTaskWorkspace, + type RuntimeMode, +} from "@/common/types/runtime"; import type { TaskCreatedEvent } from "@/common/types/stream"; import { log } from "@/node/services/log"; import { ForegroundWaitBackgroundedError } from "@/node/services/taskService"; @@ -26,16 +31,20 @@ import { import { normalizeModelInput } from "@/common/utils/ai/normalizeModelInput"; import { coerceNonEmptyString } from "@/node/services/taskUtils"; +/** Resolve the parent workspace's runtime mode from the injected MUX_RUNTIME env. */ +function resolveRuntimeMode(config: ToolConfiguration): RuntimeMode | undefined { + const runtimeValue = config.muxEnv?.MUX_RUNTIME; + return runtimeValue != null && Object.values(RUNTIME_MODE).includes(runtimeValue as RuntimeMode) + ? (runtimeValue as RuntimeMode) + : undefined; +} + /** * Build dynamic task tool description with runtime-specific workspace visibility * guidance and the currently available sub-agents. */ function buildTaskDescription(config: ToolConfiguration): string { - const runtimeValue = config.muxEnv?.MUX_RUNTIME; - const runtimeMode = - runtimeValue != null && Object.values(RUNTIME_MODE).includes(runtimeValue as RuntimeMode) - ? (runtimeValue as RuntimeMode) - : undefined; + const runtimeMode = resolveRuntimeMode(config); const baseDescription = buildTaskToolDescription(runtimeMode); const subagents = config.availableSubagents?.filter((a) => a.subagentRunnable) ?? []; @@ -306,9 +315,16 @@ function normalizePendingTaskStatuses(params: { } export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { + // Only advertise the `isolation` parameter on runtimes where sharing the parent checkout is + // supported. On local runtimes the field is omitted from the schema entirely, so it never + // enters LLM context. + const runtimeMode = resolveRuntimeMode(config); + const inputSchema = buildTaskToolAgentArgsSchema({ + includeIsolation: runtimeModeSupportsSharedTaskWorkspace(runtimeMode), + }); return tool({ description: buildTaskDescription(config), - inputSchema: TOOL_DEFINITIONS.task.schema, + inputSchema, execute: async (args, { abortSignal, toolCallId }): Promise => { // Defensive: tool() should have already validated args via inputSchema, // but keep runtime validation here to preserve type-safety. @@ -340,6 +356,7 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { variants, model, thinking, + isolation, } = validatedArgs; const requestedAgentId = typeof agentId === "string" && agentId.trim().length > 0 ? agentId : subagent_type; @@ -390,6 +407,7 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { ...(aiOverrides.thinkingLevel != null ? { thinkingLevel: aiOverrides.thinkingLevel } : {}), + ...(isolation != null ? { isolation } : {}), ...(parentRuntimeAiSettings != null ? { parentRuntimeAiSettings } : {}), bestOf: taskGroupId != null diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 7ed7c00b0c..cd44ad0623 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -4787,6 +4787,225 @@ describe("WorkspaceService remove timing rollup", () => { }); }); +describe("WorkspaceService remove shared-workspace guard", () => { + const projectPath = "/tmp/proj-shared"; + const workspaceId = "child-shared"; + const sharedPath = path.join(projectPath, "parent-ws"); + const runtimeConfig = { type: "worktree" as const, srcBaseDir: "/tmp/src" }; + + function buildConfig(taskIsolation?: "none" | "fork"): Partial { + return { + srcDir: "/tmp/src", + getSessionDir: mock((id: string) => path.join(tmpdir(), "mux-shared-guard", id)), + removeWorkspace: mock(() => Promise.resolve()), + findWorkspace: mock(() => ({ workspacePath: sharedPath, projectPath })), + loadConfigOrDefault: mock(() => ({ + projects: new Map([ + [ + projectPath, + { + trusted: true, + workspaces: [ + { + id: workspaceId, + name: "agent_explore_child", + path: sharedPath, + runtimeConfig, + taskIsolation, + }, + ], + }, + ], + ]), + })), + } as unknown as Partial; + } + + function buildAiService(): AIService { + class FakeAIService extends EventEmitter { + isStreaming = mock(() => false); + stopStream = mock(() => Promise.resolve({ success: true as const, data: undefined })); + getWorkspaceMetadata = mock(() => + Promise.resolve({ + success: true as const, + data: { + id: workspaceId, + name: "agent_explore_child", + projectPath, + runtimeConfig, + }, + }) + ); + } + return new FakeAIService() as unknown as AIService; + } + + test("does not delete the shared parent checkout for isolation: none tasks", async () => { + const deleteWorkspace = mock(() => + Promise.resolve({ success: true as const, deletedPath: sharedPath }) + ); + const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockReturnValue({ + deleteWorkspace, + } as unknown as ReturnType); + try { + const workspaceService = createWorkspaceServiceForTest({ + config: buildConfig("none"), + aiService: buildAiService(), + }); + + const result = await workspaceService.remove(workspaceId, true); + expect(result.success).toBe(true); + // The parent's checkout must never be physically deleted on behalf of a shared task. + expect(deleteWorkspace).not.toHaveBeenCalled(); + } finally { + createRuntimeSpy.mockRestore(); + } + }); + + test("deletes the workspace for normal (forked) tasks", async () => { + const deleteWorkspace = mock(() => + Promise.resolve({ success: true as const, deletedPath: sharedPath }) + ); + const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockReturnValue({ + deleteWorkspace, + } as unknown as ReturnType); + try { + const workspaceService = createWorkspaceServiceForTest({ + config: buildConfig(undefined), + aiService: buildAiService(), + }); + + const result = await workspaceService.remove(workspaceId, true); + expect(result.success).toBe(true); + expect(deleteWorkspace).toHaveBeenCalledTimes(1); + } finally { + createRuntimeSpy.mockRestore(); + } + }); + + // Inverse direction: removing the PARENT while a live shared child points at its checkout. + function buildParentConfig(childTaskStatus: string): Partial { + return { + srcDir: "/tmp/src", + getSessionDir: mock((id: string) => path.join(tmpdir(), "mux-shared-guard", id)), + removeWorkspace: mock(() => Promise.resolve()), + findWorkspace: mock(() => ({ workspacePath: sharedPath, projectPath })), + loadConfigOrDefault: mock(() => ({ + projects: new Map([ + [ + projectPath, + { + trusted: true, + workspaces: [ + { + id: "parent-ws-id", + name: "parent-ws", + path: sharedPath, + runtimeConfig, + }, + { + id: workspaceId, + name: "agent_explore_child", + path: sharedPath, + runtimeConfig, + parentWorkspaceId: "parent-ws-id", + taskIsolation: "none", + taskStatus: childTaskStatus, + }, + ], + }, + ], + ]), + })), + } as unknown as Partial; + } + + function buildParentAiService(): AIService { + class FakeAIService extends EventEmitter { + isStreaming = mock(() => false); + stopStream = mock(() => Promise.resolve({ success: true as const, data: undefined })); + getWorkspaceMetadata = mock(() => + Promise.resolve({ + success: true as const, + data: { + id: "parent-ws-id", + name: "parent-ws", + projectPath, + runtimeConfig, + }, + }) + ); + } + return new FakeAIService() as unknown as AIService; + } + + test("does not delete a parent checkout shared by an active isolation: none child", async () => { + const deleteWorkspace = mock(() => + Promise.resolve({ success: true as const, deletedPath: sharedPath }) + ); + const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockReturnValue({ + deleteWorkspace, + } as unknown as ReturnType); + try { + const workspaceService = createWorkspaceServiceForTest({ + config: buildParentConfig("running"), + aiService: buildParentAiService(), + }); + + const result = await workspaceService.remove("parent-ws-id", true); + expect(result.success).toBe(true); + // The running shared child still uses this checkout as its cwd. + expect(deleteWorkspace).not.toHaveBeenCalled(); + } finally { + createRuntimeSpy.mockRestore(); + } + }); + + test("deletes a parent checkout when its shared child is only queued (fails fast at dequeue like forked tasks)", async () => { + const deleteWorkspace = mock(() => + Promise.resolve({ success: true as const, deletedPath: sharedPath }) + ); + const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockReturnValue({ + deleteWorkspace, + } as unknown as ReturnType); + try { + const workspaceService = createWorkspaceServiceForTest({ + config: buildParentConfig("queued"), + aiService: buildParentAiService(), + }); + + const result = await workspaceService.remove("parent-ws-id", true); + expect(result.success).toBe(true); + // Queued children require the parent config entry to launch regardless of isolation, so + // they fail fast at dequeue either way — preserving the checkout would only leak it. + expect(deleteWorkspace).toHaveBeenCalledTimes(1); + } finally { + createRuntimeSpy.mockRestore(); + } + }); + + test("deletes a parent checkout when its shared child already reported", async () => { + const deleteWorkspace = mock(() => + Promise.resolve({ success: true as const, deletedPath: sharedPath }) + ); + const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockReturnValue({ + deleteWorkspace, + } as unknown as ReturnType); + try { + const workspaceService = createWorkspaceServiceForTest({ + config: buildParentConfig("reported"), + aiService: buildParentAiService(), + }); + + const result = await workspaceService.remove("parent-ws-id", true); + expect(result.success).toBe(true); + expect(deleteWorkspace).toHaveBeenCalledTimes(1); + } finally { + createRuntimeSpy.mockRestore(); + } + }); +}); + describe("WorkspaceService remove desktop session cleanup", () => { const workspaceId = "ws-remove-desktop"; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 514ab2255e..f55a2b981d 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -3204,6 +3204,35 @@ export class WorkspaceService extends EventEmitter { const persistedWorkspacePath = persistedWorkspace?.workspacePath; + // Tasks spawned with isolation: "none" share their parent workspace's checkout (their + // persisted path points at it). Physically deleting that directory would destroy the + // parent's working tree, so skip runtime deletion and only remove config/session state. + // Runtime deletion is keyed on the task's unique name today (a safe no-op), but guard + // explicitly so this stays correct if runtime deletion ever resolves the persisted path. + const taskSharesParentCheckout = + findWorkspaceEntry(configSnapshot, workspaceId)?.workspace.taskIsolation === "none"; + + // Inverse direction: this workspace's checkout may be shared by live isolation: "none" + // descendants (their persisted path points at it). Deleting it would yank the cwd out + // from under their started streams, so preserve the directory and only clean up this + // workspace's config/session state. Reported/interrupted shared tasks don't block + // deletion, and neither do "queued" ones: dequeue requires the parent config entry + // regardless of isolation, so a queued child of a removed parent fails fast at launch + // ("Queued task parent not found") exactly like a queued forked task — preserving its + // checkout would only leak the directory. + const activeSharedTaskStatuses = new Set(["starting", "running", "awaiting_report"]); + const checkoutSharedByActiveTask = + persistedWorkspacePath != null && + Array.from(configSnapshot.projects.values()).some((project) => + project.workspaces.some( + (ws) => + ws.id !== workspaceId && + ws.taskIsolation === "none" && + ws.path === persistedWorkspacePath && + activeSharedTaskStatuses.has(ws.taskStatus ?? "") + ) + ); + if (isMultiProject(metadata)) { const projects = getProjects(metadata); const deleteErrors: string[] = []; @@ -3347,6 +3376,21 @@ export class WorkspaceService extends EventEmitter { `Failed to fully delete multi-project workspace from disk, but force=true. Removing from config. Errors: ${deleteErrors.join("; ")}` ); } + } else if (taskSharesParentCheckout) { + // Shared checkout (isolation: "none"): do not touch the filesystem — the directory + // belongs to the parent workspace. Config/session cleanup below still runs. + log.debug("Skipping runtime deletion for shared-workspace task", { + workspaceId, + workspacePath: persistedWorkspacePath, + }); + } else if (checkoutSharedByActiveTask) { + // This checkout is the live cwd of one or more isolation: "none" descendants. Removing + // the workspace from config/sessions is fine, but deleting the directory would break + // those running/queued tasks mid-flight. + log.warn("Skipping runtime deletion: checkout is shared by an active sub-agent task", { + workspaceId, + workspacePath: persistedWorkspacePath, + }); } else { const projectPath = metadata.projectPath; const runtime = createRuntime(metadata.runtimeConfig, { diff --git a/src/node/worktree/WorktreeManager.ts b/src/node/worktree/WorktreeManager.ts index 0a41319af6..498e4eb3c6 100644 --- a/src/node/worktree/WorktreeManager.ts +++ b/src/node/worktree/WorktreeManager.ts @@ -885,11 +885,22 @@ export class WorktreeManager { return MISSING_WORKTREE_ERROR_PATTERNS.some((pattern) => normalizedError.includes(pattern)); } - async forkWorkspace(params: WorkspaceForkParams): Promise { + async forkWorkspace( + params: WorkspaceForkParams, + options?: { + /** + * Explicit source checkout path. Overrides the name-derived path for sources whose + * persisted path diverges from their name (e.g. isolation: "none" tasks sharing a parent + * checkout). See WorktreeRuntime.forkWorkspace. + */ + sourceWorkspacePath?: string; + } + ): Promise { const { projectPath, sourceWorkspaceName, newWorkspaceName, initLogger } = params; // Get source workspace path - const sourceWorkspacePath = this.getWorkspacePath(projectPath, sourceWorkspaceName); + const sourceWorkspacePath = + options?.sourceWorkspacePath ?? this.getWorkspacePath(projectPath, sourceWorkspaceName); // Get current branch from source workspace try {