Skip to content

Commit 9eff6d6

Browse files
author
Mux
committed
feat: add sticky subagent workspaces
Allow task tool callers to request sticky subagents that remain available after reporting instead of being auto-deleted. Persist the sticky flag on child task workspaces, surface it in task_list, and block non-force parent removal while sticky descendants exist.\n\nCo-authored-by: Mux <noreply@coder.com>
1 parent ee6d335 commit 9eff6d6

14 files changed

Lines changed: 213 additions & 6 deletions

File tree

docs/hooks/tools.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,14 +595,15 @@ If a value is too large for the environment, it may be omitted (not set). Mux al
595595
</details>
596596

597597
<details>
598-
<summary>task (8)</summary>
598+
<summary>task (9)</summary>
599599

600600
| Env var | JSON path | Type | Description |
601601
| ---------------------------------- | ------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
602602
| `MUX_TOOL_INPUT_AGENT_ID` | `agentId` | string ||
603603
| `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. |
604604
| `MUX_TOOL_INPUT_PROMPT` | `prompt` | string ||
605605
| `MUX_TOOL_INPUT_RUN_IN_BACKGROUND` | `run_in_background` | boolean ||
606+
| `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. |
606607
| `MUX_TOOL_INPUT_SUBAGENT_TYPE` | `subagent_type` | string ||
607608
| `MUX_TOOL_INPUT_TITLE` | `title` | string ||
608609
| `MUX_TOOL_INPUT_VARIANTS_<INDEX>` | `variants[<INDEX>]` | 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. |

src/common/orpc/schemas/workspace.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ export const WorkspaceMetadataSchema = z.object({
111111
bestOf: BestOfGroupSchema.optional().meta({
112112
description: "Grouping metadata for child tasks spawned from the same parent tool call.",
113113
}),
114+
taskSticky: z.boolean().optional().meta({
115+
description:
116+
"If true, this completed child task workspace is preserved instead of being auto-deleted.",
117+
}),
114118
taskStatus: z
115119
.enum(["queued", "running", "awaiting_report", "interrupted", "reported"])
116120
.optional()

src/common/schemas/project.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ export const WorkspaceConfigSchema = z.object({
105105
bestOf: BestOfGroupSchema.optional().meta({
106106
description: "Grouping metadata for child tasks spawned from the same parent tool call.",
107107
}),
108+
taskSticky: z.boolean().optional().meta({
109+
description:
110+
"If true, this completed child task workspace is preserved instead of being auto-deleted.",
111+
}),
108112
taskStatus: z
109113
.enum(["queued", "running", "awaiting_report", "interrupted", "reported"])
110114
.optional()

src/common/utils/tools/toolDefinitions.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,20 @@ describe("TOOL_DEFINITIONS", () => {
3535
}
3636
});
3737

38+
it("accepts optional sticky task tool flag", () => {
39+
const parsed = TaskToolArgsSchema.safeParse({
40+
subagent_type: "explore",
41+
prompt: "do the thing",
42+
title: "Test",
43+
sticky: true,
44+
});
45+
46+
expect(parsed.success).toBe(true);
47+
if (parsed.success) {
48+
expect(parsed.data.sticky).toBe(true);
49+
}
50+
});
51+
3852
it("accepts task tool best-of counts between 1 and 20", () => {
3953
expect(
4054
TaskToolArgsSchema.safeParse({

src/common/utils/tools/toolDefinitions.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,8 @@ export function buildTaskToolDescription(runtimeMode: RuntimeMode | undefined):
216216
"Spawn a sub-agent task (child workspace). " +
217217
"\n\nIMPORTANT: Whether a sub-agent can see uncommitted changes depends on the runtime. " +
218218
`${getTaskRuntimeVisibilityGuidance(runtimeMode)} ` +
219-
"\n\nProvide agentId (preferred) or subagent_type, prompt, title, run_in_background, and optional n or variants. " +
219+
"\n\nProvide agentId (preferred) or subagent_type, prompt, title, run_in_background, optional sticky, and optional n or variants. " +
220+
"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. " +
220221
`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. ` +
221222
"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. " +
222223
`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
244245
prompt: z.string().min(1),
245246
title: z.string().min(1),
246247
run_in_background: z.boolean().default(false),
248+
sticky: z
249+
.boolean()
250+
.nullish()
251+
.describe(
252+
"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."
253+
),
247254
n: TaskToolBestOfCountSchema.nullish().describe(
248255
"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."
249256
),
@@ -744,6 +751,7 @@ export const TaskListToolTaskSchema = z
744751
createdAt: z.string().optional(),
745752
modelString: z.string().optional(),
746753
thinkingLevel: TaskListThinkingLevelSchema.optional(),
754+
sticky: z.boolean().optional(),
747755
depth: z.number().int().min(0),
748756
})
749757
.strict();

src/node/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,6 +1506,7 @@ export class Config {
15061506
agentType: workspace.agentType,
15071507
agentId: workspace.agentId,
15081508
bestOf: workspace.bestOf,
1509+
taskSticky: workspace.taskSticky,
15091510
taskStatus: workspace.taskStatus,
15101511
reportedAt: workspace.reportedAt,
15111512
taskModelString: workspace.taskModelString,
@@ -1604,6 +1605,7 @@ export class Config {
16041605
metadata.agentType ??= workspace.agentType;
16051606
metadata.agentId ??= workspace.agentId;
16061607
metadata.bestOf ??= workspace.bestOf;
1608+
metadata.taskSticky ??= workspace.taskSticky;
16071609
metadata.taskStatus ??= workspace.taskStatus;
16081610
metadata.reportedAt ??= workspace.reportedAt;
16091611
metadata.taskModelString ??= workspace.taskModelString;
@@ -1633,6 +1635,7 @@ export class Config {
16331635
workspace.createdAt = metadata.createdAt;
16341636
workspace.runtimeConfig = metadata.runtimeConfig;
16351637
workspace.forkFamilyBaseName = metadata.forkFamilyBaseName;
1638+
workspace.taskSticky = metadata.taskSticky;
16361639
configModified = true;
16371640

16381641
if (!workspace.projects && metadata.projects) {
@@ -1672,6 +1675,7 @@ export class Config {
16721675
agentType: workspace.agentType,
16731676
agentId: workspace.agentId,
16741677
bestOf: workspace.bestOf,
1678+
taskSticky: workspace.taskSticky,
16751679
taskStatus: workspace.taskStatus,
16761680
reportedAt: workspace.reportedAt,
16771681
taskModelString: workspace.taskModelString,
@@ -1722,6 +1726,7 @@ export class Config {
17221726
agentType: workspace.agentType,
17231727
agentId: workspace.agentId,
17241728
bestOf: workspace.bestOf,
1729+
taskSticky: workspace.taskSticky,
17251730
taskStatus: workspace.taskStatus,
17261731
reportedAt: workspace.reportedAt,
17271732
taskModelString: workspace.taskModelString,
@@ -1790,6 +1795,7 @@ export class Config {
17901795
agentType: metadata.agentType,
17911796
agentId: metadata.agentId,
17921797
bestOf: metadata.bestOf,
1798+
taskSticky: metadata.taskSticky,
17931799
taskStatus: metadata.taskStatus,
17941800
reportedAt: metadata.reportedAt,
17951801
taskModelString: metadata.taskModelString,

src/node/services/agentSkills/builtInSkillContent.generated.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4242,14 +4242,15 @@ export const BUILTIN_SKILL_FILES: Record<string, Record<string, string>> = {
42424242
"</details>",
42434243
"",
42444244
"<details>",
4245-
"<summary>task (8)</summary>",
4245+
"<summary>task (9)</summary>",
42464246
"",
42474247
"| Env var | JSON path | Type | Description |",
42484248
"| ---------------------------------- | ------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |",
42494249
"| `MUX_TOOL_INPUT_AGENT_ID` | `agentId` | string | — |",
42504250
"| `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. |",
42514251
"| `MUX_TOOL_INPUT_PROMPT` | `prompt` | string | — |",
42524252
"| `MUX_TOOL_INPUT_RUN_IN_BACKGROUND` | `run_in_background` | boolean | — |",
4253+
"| `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. |",
42534254
"| `MUX_TOOL_INPUT_SUBAGENT_TYPE` | `subagent_type` | string | — |",
42544255
"| `MUX_TOOL_INPUT_TITLE` | `title` | string | — |",
42554256
"| `MUX_TOOL_INPUT_VARIANTS_<INDEX>` | `variants[<INDEX>]` | 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. |",

src/node/services/taskService.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1746,6 +1746,29 @@ describe("TaskService", () => {
17461746
);
17471747
}, 20_000);
17481748

1749+
test("create persists sticky task preference on the child workspace", async () => {
1750+
const config = await createTestConfig(rootDir);
1751+
stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb");
1752+
const { parentId } = await saveLocalParentWorkspace(config, rootDir);
1753+
1754+
const { workspaceService } = createWorkspaceServiceMocks();
1755+
const { taskService } = createTaskServiceHarness(config, { workspaceService });
1756+
1757+
const created = await taskService.create({
1758+
parentWorkspaceId: parentId,
1759+
kind: "agent",
1760+
agentType: "exec",
1761+
prompt: "run sticky exec task",
1762+
title: "Sticky task",
1763+
sticky: true,
1764+
});
1765+
expect(created.success).toBe(true);
1766+
if (!created.success) return;
1767+
1768+
const childEntry = findWorkspaceInConfig(config, created.data.taskId);
1769+
expect(childEntry?.taskSticky).toBe(true);
1770+
}, 20_000);
1771+
17491772
test("parent runtime AI settings outrank persisted parent workspace settings", async () => {
17501773
const config = await createTestConfig(rootDir);
17511774
stubStableIds(config, ["aaaaaaaaaa"], "bbbbbbbbbb");
@@ -8888,6 +8911,7 @@ describe("TaskService", () => {
88888911
name: string;
88898912
agentType: string;
88908913
taskStatus?: "reported" | "interrupted";
8914+
taskSticky?: boolean;
88918915
reportedAt?: string;
88928916
}
88938917

@@ -8960,6 +8984,7 @@ describe("TaskService", () => {
89608984
parentWorkspaceId,
89618985
agentType: task.agentType,
89628986
taskStatus: task.taskStatus ?? "reported",
8987+
taskSticky: task.taskSticky,
89638988
reportedAt: task.reportedAt,
89648989
});
89658990
parentWorkspaceId = task.id;
@@ -9128,6 +9153,46 @@ describe("TaskService", () => {
91289153
}
91299154
});
91309155

9156+
test("sticky completed descendants are never auto-cleaned", async () => {
9157+
const parentTaskId = "parent-222";
9158+
const childTaskId = "child-333";
9159+
const { config, remove, rootWorkspaceId, taskService, internal } =
9160+
await setupReportedTaskChain({
9161+
preserveSubagentsUntilArchive: false,
9162+
taskChain: [
9163+
{
9164+
id: parentTaskId,
9165+
directoryName: "parent-task",
9166+
name: "agent_exec_parent",
9167+
agentType: "exec",
9168+
taskStatus: "reported",
9169+
},
9170+
{
9171+
id: childTaskId,
9172+
directoryName: "child-task",
9173+
name: "agent_explore_child",
9174+
agentType: "explore",
9175+
taskStatus: "reported",
9176+
taskSticky: true,
9177+
},
9178+
],
9179+
});
9180+
9181+
await archiveWorkspaceInTestConfig(config, rootWorkspaceId);
9182+
9183+
const cleanupEligibility = await internal.canCleanupReportedTask(childTaskId);
9184+
expect(cleanupEligibility).toEqual({ ok: false, reason: "sticky" });
9185+
9186+
await internal.cleanupReportedLeafTask(childTaskId);
9187+
await taskService.cleanupReportedDescendantsAfterArchive(rootWorkspaceId);
9188+
9189+
expect(remove).not.toHaveBeenCalled();
9190+
expect(findWorkspaceInConfig(config, childTaskId)).toBeTruthy();
9191+
expect(findWorkspaceInConfig(config, parentTaskId)).toBeTruthy();
9192+
expect(taskService.hasStickyCompletedDescendants(rootWorkspaceId)).toBe(true);
9193+
expect(taskService.hasPreservedCompletedDescendants(rootWorkspaceId)).toBe(true);
9194+
});
9195+
91319196
test("with toggle off, current cleanup behavior remains unchanged", async () => {
91329197
const { config, remove, taskChain, internal } = await setupReportedTaskChain({
91339198
preserveSubagentsUntilArchive: false,

0 commit comments

Comments
 (0)