diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx index cffe84a863..4d9694e394 100644 --- a/docs/hooks/tools.mdx +++ b/docs/hooks/tools.mdx @@ -600,18 +600,19 @@ If a value is too large for the environment, it may be omitted (not set). Mux al
-task (8) - -| Env var | JSON path | Type | Description | -| ---------------------------------- | ------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MUX_TOOL_INPUT_AGENT_ID` | `agentId` | string | — | -| `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_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 (9) + +| 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_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_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 a9437bd92e..61fd4bb766 100644 --- a/src/common/schemas/project.ts +++ b/src/common/schemas/project.ts @@ -166,6 +166,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 b74ab9c6fd..9595a4998b 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, TaskToolArgsSchema, @@ -352,6 +353,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 95c02dfab0..3822f96794 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, @@ -188,6 +192,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: @@ -221,6 +237,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. " + @@ -242,81 +265,117 @@ 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.` - ), - }) - .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.` + ), +}; + +// 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..8660fbde6b --- /dev/null +++ b/src/node/runtime/WorktreeRuntime.test.ts @@ -0,0 +1,70 @@ +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); + }); +}); diff --git a/src/node/runtime/WorktreeRuntime.ts b/src/node/runtime/WorktreeRuntime.ts index 38bdb259fd..15c06febfd 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); } diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 5fe2e7dcf4..300acbecd5 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4402,18 +4402,19 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "", "
", - "task (8)", - "", - "| Env var | JSON path | Type | Description |", - "| ---------------------------------- | ------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |", - "| `MUX_TOOL_INPUT_AGENT_ID` | `agentId` | string | — |", - "| `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_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 (9)", + "", + "| 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_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_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 85e5f9fb9d..4db7b181f6 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -954,6 +954,85 @@ 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("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 4a71f0edb3..dd78e4c23b 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -57,8 +57,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, @@ -80,6 +81,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"; @@ -137,6 +139,12 @@ export interface TaskCreateArgs { title: string; modelString?: string; thinkingLevel?: ThinkingLevel; + /** + * 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 }; /** Shared grouping metadata when one tool call spawns multiple sibling tasks. */ bestOf?: { @@ -1562,6 +1570,26 @@ export class TaskService { ? parentMeta.projectPath : 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; + // Prefer the parent's persisted checkout path; fall back to the reconstructed path. + const sharedWorkspacePath = + coerceNonEmptyString(parentEntry?.workspace.path) ?? parentWorkspacePath; + 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 // with base: exec) still count as runnable. @@ -1658,7 +1686,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 + ? sharedWorkspacePath + : runtime.getWorkspacePath(parentMeta.projectPath, workspaceName); taskQueueDebug("TaskService.create queued (persist-only)", { taskId, @@ -1694,6 +1726,7 @@ export class TaskService { taskModelString, taskThinkingLevel: effectiveThinkingLevel, taskExperiments: args.experiments, + taskIsolation: useSharedWorkspace ? "none" : undefined, projects: parentMeta.projects, }); return config; @@ -1718,50 +1751,83 @@ 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 = sharedWorkspacePath; + trunkBranch = coerceNonEmptyString(parentMeta.name) ?? "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: sharedWorkspacePath, + }); + 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, + 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); @@ -1813,6 +1879,7 @@ export class TaskService { taskModelString, taskThinkingLevel: effectiveThinkingLevel, taskExperiments: args.experiments, + taskIsolation: useSharedWorkspace ? "none" : undefined, projects: inheritedProjects, }); return config; @@ -1821,28 +1888,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( @@ -3239,7 +3310,11 @@ export class TaskService { trunkBranch = "main"; } - let shouldRunInit = !inMemoryInit && !persistedInit; + // Shared-workspace (isolation: "none") tasks reuse the parent's already-initialized checkout + // (task.path points at it, so workspaceExists is true below and the fork branch is skipped). + // Never re-run init on that live parent directory. + const taskUsesSharedWorkspace = task.taskIsolation === "none"; + let shouldRunInit = !taskUsesSharedWorkspace && !inMemoryInit && !persistedInit; let initLogger: InitLogger | null = null; const getInitLogger = (): InitLogger => { if (initLogger) return initLogger; @@ -3413,6 +3488,11 @@ export class TaskService { ws.taskTrunkBranch = trunkBranch; ws.runtimeConfig = forkedRuntimeConfig; ws.projects = inheritedProjects; + // We reached this branch because 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 (taskUsesSharedWorkspace) { + ws.taskIsolation = undefined; + } }, { allowMissing: true } ); diff --git a/src/node/services/tools/task.test.ts b/src/node/services/tools/task.test.ts index 0f749b2023..1d42e11cff 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 7da730fa63..6a427f17fd 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"; @@ -20,16 +25,20 @@ import { getErrorMessage } from "@/common/utils/errors"; import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; 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) ?? []; @@ -259,9 +268,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. @@ -283,7 +299,7 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { throw new Error("Interrupted"); } - const { agentId, subagent_type, prompt, title, run_in_background, n, variants } = + const { agentId, subagent_type, prompt, title, run_in_background, n, variants, isolation } = validatedArgs; const requestedAgentId = typeof agentId === "string" && agentId.trim().length > 0 ? agentId : subagent_type; @@ -325,6 +341,7 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { prompt: launch.prompt, title, experiments: config.experiments, + ...(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 220e6df1dd..dd67c2588a 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -4697,6 +4697,103 @@ 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(); + } + }); +}); + 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 1231f3fc5a..ab87d74131 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -3155,6 +3155,14 @@ 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"; + if (isMultiProject(metadata)) { const projects = getProjects(metadata); const deleteErrors: string[] = []; @@ -3298,6 +3306,13 @@ 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 { const projectPath = metadata.projectPath; const runtime = createRuntime(metadata.runtimeConfig, {