diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx index b92cfb76d3..6c34783694 100644 --- a/docs/hooks/tools.mdx +++ b/docs/hooks/tools.mdx @@ -595,7 +595,7 @@ If a value is too large for the environment, it may be omitted (not set). Mux al
-task (8) +task (9) | Env var | JSON path | Type | Description | | ---------------------------------- | ------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -603,6 +603,7 @@ If a value is too large for the environment, it may be omitted (not set). Mux al | `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_STICKY` | `sticky` | boolean | Only set this when the user explicitly asks to keep or preserve the child workspace after reporting; do not decide to set it on your own. When true, the completed sub-agent workspace is preserved for inspection instead of being auto-deleted. | | `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. | diff --git a/src/common/orpc/schemas/workspace.ts b/src/common/orpc/schemas/workspace.ts index 7f1a4326b9..7b80087795 100644 --- a/src/common/orpc/schemas/workspace.ts +++ b/src/common/orpc/schemas/workspace.ts @@ -111,6 +111,10 @@ export const WorkspaceMetadataSchema = z.object({ bestOf: BestOfGroupSchema.optional().meta({ description: "Grouping metadata for child tasks spawned from the same parent tool call.", }), + taskSticky: z.boolean().optional().meta({ + description: + "If true, this completed child task workspace is preserved instead of being auto-deleted.", + }), taskStatus: z .enum(["queued", "running", "awaiting_report", "interrupted", "reported"]) .optional() diff --git a/src/common/schemas/project.ts b/src/common/schemas/project.ts index fbe2d8f02d..8fb0cc8871 100644 --- a/src/common/schemas/project.ts +++ b/src/common/schemas/project.ts @@ -105,6 +105,10 @@ export const WorkspaceConfigSchema = z.object({ bestOf: BestOfGroupSchema.optional().meta({ description: "Grouping metadata for child tasks spawned from the same parent tool call.", }), + taskSticky: z.boolean().optional().meta({ + description: + "If true, this completed child task workspace is preserved instead of being auto-deleted.", + }), taskStatus: z .enum(["queued", "running", "awaiting_report", "interrupted", "reported"]) .optional() diff --git a/src/common/utils/tools/toolDefinitions.test.ts b/src/common/utils/tools/toolDefinitions.test.ts index 9011bb01db..11e5a5694b 100644 --- a/src/common/utils/tools/toolDefinitions.test.ts +++ b/src/common/utils/tools/toolDefinitions.test.ts @@ -35,6 +35,20 @@ describe("TOOL_DEFINITIONS", () => { } }); + it("accepts optional sticky task tool flag", () => { + const parsed = TaskToolArgsSchema.safeParse({ + subagent_type: "explore", + prompt: "do the thing", + title: "Test", + sticky: true, + }); + + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.sticky).toBe(true); + } + }); + it("accepts task tool best-of counts between 1 and 20", () => { expect( TaskToolArgsSchema.safeParse({ diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index bd5439e5a2..6d255e8c15 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -216,7 +216,8 @@ export function buildTaskToolDescription(runtimeMode: RuntimeMode | undefined): "Spawn a sub-agent task (child workspace). " + "\n\nIMPORTANT: Whether a sub-agent can see uncommitted changes depends on the runtime. " + `${getTaskRuntimeVisibilityGuidance(runtimeMode)} ` + - "\n\nProvide agentId (preferred) or subagent_type, prompt, title, run_in_background, and optional n or variants. " + + "\n\nProvide agentId (preferred) or subagent_type, prompt, title, run_in_background, optional sticky, and optional n or variants. " + + "Do not set sticky on your own; set sticky=true only when the user explicitly asks to keep or preserve the child workspace after reporting. Sticky sub-agents are not auto-deleted. " + `Use n when you want several agents to try the same prompt independently. Use variants when you want several agents to run the same prompt template with a different ${TASK_VARIANT_PLACEHOLDER} substituted into each run. ` + "Examples: solve GitHub issues 45, 32, and 69 with one shared issue-solving template; investigate a regression across commit windows like A..B and B..C with one shared investigation template; or split a review into frontend/backend/tests/docs lanes with one shared review template. " + `For variants, keep the shared template in the prompt and put the per-lane difference into ${TASK_VARIANT_PLACEHOLDER}. ` + @@ -244,6 +245,12 @@ const TaskToolAgentArgsSchema = z prompt: z.string().min(1), title: z.string().min(1), run_in_background: z.boolean().default(false), + sticky: z + .boolean() + .nullish() + .describe( + "Only set this when the user explicitly asks to keep or preserve the child workspace after reporting; do not decide to set it on your own. When true, the completed sub-agent workspace is preserved for inspection instead of being auto-deleted." + ), 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." ), @@ -744,6 +751,7 @@ export const TaskListToolTaskSchema = z createdAt: z.string().optional(), modelString: z.string().optional(), thinkingLevel: TaskListThinkingLevelSchema.optional(), + sticky: z.boolean().optional(), depth: z.number().int().min(0), }) .strict(); diff --git a/src/node/config.ts b/src/node/config.ts index 25fe09a3d3..f498b3df17 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -1506,6 +1506,7 @@ export class Config { agentType: workspace.agentType, agentId: workspace.agentId, bestOf: workspace.bestOf, + taskSticky: workspace.taskSticky, taskStatus: workspace.taskStatus, reportedAt: workspace.reportedAt, taskModelString: workspace.taskModelString, @@ -1604,6 +1605,7 @@ export class Config { metadata.agentType ??= workspace.agentType; metadata.agentId ??= workspace.agentId; metadata.bestOf ??= workspace.bestOf; + metadata.taskSticky ??= workspace.taskSticky; metadata.taskStatus ??= workspace.taskStatus; metadata.reportedAt ??= workspace.reportedAt; metadata.taskModelString ??= workspace.taskModelString; @@ -1633,6 +1635,7 @@ export class Config { workspace.createdAt = metadata.createdAt; workspace.runtimeConfig = metadata.runtimeConfig; workspace.forkFamilyBaseName = metadata.forkFamilyBaseName; + workspace.taskSticky = metadata.taskSticky; configModified = true; if (!workspace.projects && metadata.projects) { @@ -1672,6 +1675,7 @@ export class Config { agentType: workspace.agentType, agentId: workspace.agentId, bestOf: workspace.bestOf, + taskSticky: workspace.taskSticky, taskStatus: workspace.taskStatus, reportedAt: workspace.reportedAt, taskModelString: workspace.taskModelString, @@ -1722,6 +1726,7 @@ export class Config { agentType: workspace.agentType, agentId: workspace.agentId, bestOf: workspace.bestOf, + taskSticky: workspace.taskSticky, taskStatus: workspace.taskStatus, reportedAt: workspace.reportedAt, taskModelString: workspace.taskModelString, @@ -1790,6 +1795,7 @@ export class Config { agentType: metadata.agentType, agentId: metadata.agentId, bestOf: metadata.bestOf, + taskSticky: metadata.taskSticky, taskStatus: metadata.taskStatus, reportedAt: metadata.reportedAt, taskModelString: metadata.taskModelString, diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 187e714652..e058848d17 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4242,7 +4242,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "
", "", "
", - "task (8)", + "task (9)", "", "| Env var | JSON path | Type | Description |", "| ---------------------------------- | ------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |", @@ -4250,6 +4250,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "| `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_STICKY` | `sticky` | boolean | Only set this when the user explicitly asks to keep or preserve the child workspace after reporting; do not decide to set it on your own. When true, the completed sub-agent workspace is preserved for inspection instead of being auto-deleted. |", "| `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. |", diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index c0a9d68ca3..fc4f6f44e3 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -1746,6 +1746,29 @@ describe("TaskService", () => { ); }, 20_000); + test("create persists sticky task preference on the child workspace", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); + const { parentId } = await saveLocalParentWorkspace(config, rootDir); + + const { workspaceService } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + + const created = await taskService.create({ + parentWorkspaceId: parentId, + kind: "agent", + agentType: "exec", + prompt: "run sticky exec task", + title: "Sticky task", + sticky: true, + }); + expect(created.success).toBe(true); + if (!created.success) return; + + const childEntry = findWorkspaceInConfig(config, created.data.taskId); + expect(childEntry?.taskSticky).toBe(true); + }, 20_000); + test("parent runtime AI settings outrank persisted parent workspace settings", async () => { const config = await createTestConfig(rootDir); stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb"); @@ -8888,6 +8911,7 @@ describe("TaskService", () => { name: string; agentType: string; taskStatus?: "reported" | "interrupted"; + taskSticky?: boolean; reportedAt?: string; } @@ -8960,6 +8984,7 @@ describe("TaskService", () => { parentWorkspaceId, agentType: task.agentType, taskStatus: task.taskStatus ?? "reported", + taskSticky: task.taskSticky, reportedAt: task.reportedAt, }); parentWorkspaceId = task.id; @@ -9128,6 +9153,46 @@ describe("TaskService", () => { } }); + test("sticky completed descendants are never auto-cleaned", async () => { + const parentTaskId = "parent-222"; + const childTaskId = "child-333"; + const { config, remove, rootWorkspaceId, taskService, internal } = + await setupReportedTaskChain({ + preserveSubagentsUntilArchive: false, + taskChain: [ + { + id: parentTaskId, + directoryName: "parent-task", + name: "agent_exec_parent", + agentType: "exec", + taskStatus: "reported", + }, + { + id: childTaskId, + directoryName: "child-task", + name: "agent_explore_child", + agentType: "explore", + taskStatus: "reported", + taskSticky: true, + }, + ], + }); + + await archiveWorkspaceInTestConfig(config, rootWorkspaceId); + + const cleanupEligibility = await internal.canCleanupReportedTask(childTaskId); + expect(cleanupEligibility).toEqual({ ok: false, reason: "sticky" }); + + await internal.cleanupReportedLeafTask(childTaskId); + await taskService.cleanupReportedDescendantsAfterArchive(rootWorkspaceId); + + expect(remove).not.toHaveBeenCalled(); + expect(findWorkspaceInConfig(config, childTaskId)).toBeTruthy(); + expect(findWorkspaceInConfig(config, parentTaskId)).toBeTruthy(); + expect(taskService.hasStickyCompletedDescendants(rootWorkspaceId)).toBe(true); + expect(taskService.hasPreservedCompletedDescendants(rootWorkspaceId)).toBe(true); + }); + test("with toggle off, current cleanup behavior remains unchanged", async () => { const { config, remove, taskChain, internal } = await setupReportedTaskChain({ preserveSubagentsUntilArchive: false, diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index bea5c35487..c08fea1df6 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -108,6 +108,8 @@ export interface TaskCreateArgs { prompt: string; /** Human-readable title for the task (displayed in sidebar) */ title: string; + /** Preserve this completed child workspace instead of auto-deleting it after report delivery. */ + sticky?: boolean; modelString?: string; thinkingLevel?: ThinkingLevel; parentRuntimeAiSettings?: { modelString?: string; thinkingLevel?: ThinkingLevel }; @@ -148,6 +150,7 @@ export interface DescendantAgentTaskInfo { createdAt?: string; modelString?: string; thinkingLevel?: ThinkingLevel; + sticky?: boolean; depth: number; } @@ -1074,6 +1077,8 @@ export class TaskService { agentId, agentType, bestOf: normalizedBestOf, + // Sticky task workspaces remain inspectable after agent_report instead of being auto-deleted. + taskSticky: args.sticky === true ? true : undefined, taskStatus: "queued", taskPrompt: prompt, taskTrunkBranch: trunkBranch, @@ -1191,6 +1196,8 @@ export class TaskService { parentWorkspaceId, agentType, bestOf: normalizedBestOf, + // Sticky task workspaces remain inspectable after agent_report instead of being auto-deleted. + taskSticky: args.sticky === true ? true : undefined, taskStatus: "running", taskTrunkBranch: trunkBranch, taskBaseCommitSha: taskBaseCommitSha ?? undefined, @@ -1960,6 +1967,16 @@ export class TaskService { return this.hasActiveDescendantAgentTasks(cfg, workspaceId); } + hasStickyCompletedDescendants(workspaceId: string): boolean { + assert(workspaceId.length > 0, "hasStickyCompletedDescendants: workspaceId must be non-empty"); + + const cfg = this.config.loadConfigOrDefault(); + const index = this.buildAgentTaskIndex(cfg); + return this.listCompletedDescendantAgentTaskIds(index, workspaceId).some( + (descendantId) => index.byId.get(descendantId)?.taskSticky === true + ); + } + hasPreservedCompletedDescendants(workspaceId: string): boolean { assert( workspaceId.length > 0, @@ -1967,13 +1984,19 @@ export class TaskService { ); const cfg = this.config.loadConfigOrDefault(); + const index = this.buildAgentTaskIndex(cfg); + const completedDescendants = this.listCompletedDescendantAgentTaskIds(index, workspaceId); + if ( + completedDescendants.some((descendantId) => index.byId.get(descendantId)?.taskSticky === true) + ) { + return true; + } + const taskSettings = normalizeTaskSettings(cfg.taskSettings); if (!taskSettings.preserveSubagentsUntilArchive) { return false; } - const index = this.buildAgentTaskIndex(cfg); - const completedDescendants = this.listCompletedDescendantAgentTaskIds(index, workspaceId); return completedDescendants.some( (descendantId) => !this.hasArchivedAncestor(index, cfg, descendantId) ); @@ -2058,6 +2081,7 @@ export class TaskService { createdAt: entry.createdAt, modelString: entry.aiSettings?.model, thinkingLevel: entry.aiSettings?.thinkingLevel, + sticky: entry.taskSticky === true ? true : undefined, depth: next.depth, }); } @@ -4556,6 +4580,10 @@ export class TaskService { return { ok: false, reason: "task_not_reported" }; } + if (entry.workspace.taskSticky === true) { + return { ok: false, reason: "sticky" }; + } + if (entry.workspace.bestOf?.total != null && entry.workspace.bestOf.total > 1) { if ( await this.shouldDeferBestOfFallback({ diff --git a/src/node/services/tools/task.test.ts b/src/node/services/tools/task.test.ts index 9f6d7c52b4..b4843189ac 100644 --- a/src/node/services/tools/task.test.ts +++ b/src/node/services/tools/task.test.ts @@ -98,6 +98,39 @@ describe("task tool", () => { expectQueuedOrRunningTaskToolResult(result, { status: "queued", taskId: "child-task" }); }); + it("passes sticky preference when spawning a task", async () => { + using tempDir = new TestTempDir("test-task-tool-sticky"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + + const create = mock((_args: { sticky?: boolean }) => + 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, + taskService, + }); + + await Promise.resolve( + tool.execute!( + { + agentId: "explore", + prompt: "do it", + title: "Sticky child task", + run_in_background: true, + sticky: true, + }, + mockToolCallOptions + ) + ); + + expect(create).toHaveBeenCalledTimes(1); + expect(create.mock.calls[0]?.[0].sticky).toBe(true); + expect(waitForAgentReport).not.toHaveBeenCalled(); + }); + it("passes parent MUX_MODEL_STRING/MUX_THINKING_LEVEL as a runtime fallback hint", async () => { using tempDir = new TestTempDir("test-task-tool-parent-ai-env"); 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 18feff807b..a70a23cb60 100644 --- a/src/node/services/tools/task.ts +++ b/src/node/services/tools/task.ts @@ -280,7 +280,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, sticky, n, variants } = validatedArgs; const requestedAgentId = typeof agentId === "string" && agentId.trim().length > 0 ? agentId : subagent_type; @@ -321,6 +321,9 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { agentType: requestedAgentId, prompt: launch.prompt, title, + // Sticky sub-agents are intentionally preserved after reporting so the + // agent can leave an inspectable child workspace when the task outcome warrants it. + ...(sticky === true ? { sticky: true } : {}), experiments: config.experiments, ...(parentRuntimeAiSettings != null ? { parentRuntimeAiSettings } : {}), bestOf: diff --git a/src/node/services/tools/task_list.test.ts b/src/node/services/tools/task_list.test.ts index 8c1a782eb5..d330bd5fa2 100644 --- a/src/node/services/tools/task_list.test.ts +++ b/src/node/services/tools/task_list.test.ts @@ -62,6 +62,7 @@ describe("task_list tool", () => { createdAt: "2025-01-01T00:00:00.000Z", modelString: "anthropic:claude-haiku-4-5", thinkingLevel: "low", + sticky: true, depth: 1, }, ]); @@ -83,6 +84,7 @@ describe("task_list tool", () => { createdAt: "2025-01-01T00:00:00.000Z", modelString: "anthropic:claude-haiku-4-5", thinkingLevel: "low", + sticky: true, depth: 1, }, ], diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 4d4084ad4e..1d1f451622 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -3269,6 +3269,38 @@ describe("WorkspaceService remove preserved descendants", () => { await cleanupHistory(); }); + test("remove() blocks parent removal when sticky completed descendants exist", async () => { + const hasStickyCompletedDescendants = mock(() => true); + const hasCompletedDescendants = mock(() => false); + workspaceService.setTaskService({ + hasStickyCompletedDescendants, + hasCompletedDescendants, + } as unknown as TaskService); + const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockReturnValue({ + deleteWorkspace: deleteWorkspaceMock, + } as unknown as ReturnType); + + try { + const result = await workspaceService.remove(workspaceId); + + expect(result).toEqual( + Err( + "This workspace has sticky completed sub-agent workspaces. Remove those sub-agents first, or force-remove the workspace." + ) + ); + expect(hasStickyCompletedDescendants).toHaveBeenCalledTimes(1); + expect(hasStickyCompletedDescendants).toHaveBeenCalledWith(workspaceId); + expect(hasCompletedDescendants).not.toHaveBeenCalled(); + expect(stopStreamMock).not.toHaveBeenCalled(); + expect(getWorkspaceMetadataMock).not.toHaveBeenCalled(); + expect(createRuntimeSpy).not.toHaveBeenCalled(); + expect(deleteWorkspaceMock).not.toHaveBeenCalled(); + expect(removeWorkspaceMock).not.toHaveBeenCalled(); + } finally { + createRuntimeSpy.mockRestore(); + } + }); + test("remove() blocks direct removal of unarchived workspace with preserved completed descendants", async () => { const hasCompletedDescendants = mock(() => true); workspaceService.setTaskService({ diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 0bb166a244..79c8634a1f 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2799,6 +2799,12 @@ export class WorkspaceService extends EventEmitter { // Try to remove from runtime (filesystem) try { if (!force) { + if (this.taskService?.hasStickyCompletedDescendants?.(workspaceId)) { + return Err( + "This workspace has sticky completed sub-agent workspaces. Remove those sub-agents first, or force-remove the workspace." + ); + } + const config = this.config.loadConfigOrDefault(); const taskSettings = normalizeTaskSettings(config.taskSettings); if (