From 80bbe34f9e10459f9e550d8d387340220a9f55ad Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 4 Jun 2026 15:06:52 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20hide=20workflow-owned?= =?UTF-8?q?=20tasks=20from=20task=5Flist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hide workflow-owned sub-agents and their background bash tasks from task_list/default task_await discovery so parent agents only see vetted workflow-run output.\n\nValidation:\n- bun test src/node/services/tools/task_list.test.ts src/node/services/tools/task.bash.test.ts src/node/services/taskService.test.ts\n- make typecheck\n- make lint\n\n---\n\n_Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `.19`_\n\n --- .mux/workflows/.scratch/.gitignore | 2 + src/common/utils/tools/toolDefinitions.ts | 2 +- src/node/services/taskService.test.ts | 58 +++++++++++ src/node/services/taskService.ts | 41 +++++++- src/node/services/tools/task.bash.test.ts | 119 ++++++++++++++++++++++ src/node/services/tools/task_await.ts | 7 ++ src/node/services/tools/task_list.test.ts | 2 + src/node/services/tools/task_list.ts | 12 ++- 8 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 .mux/workflows/.scratch/.gitignore diff --git a/.mux/workflows/.scratch/.gitignore b/.mux/workflows/.scratch/.gitignore new file mode 100644 index 0000000000..d6b7ef32c8 --- /dev/null +++ b/.mux/workflows/.scratch/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index cb728727ad..febd846281 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -1542,7 +1542,7 @@ export const TOOL_DEFINITIONS = { task_list: { description: "List descendant tasks for the current workspace, including status + metadata. " + - "This includes sub-agent tasks and background bash tasks. " + + "This includes sub-agent tasks and background bash tasks, but omits workflow-owned sub-agents (and their background bash tasks) whose reports are consumed through their workflow run. " + "Use this after compaction or interruptions to rediscover which tasks are still active. " + "This is a discovery tool, NOT a waiting mechanism. If the current request actually depends on a task's output, call task_await with the specific task IDs you need; do not await all active tasks just because they appear here.", schema: TaskListToolArgsSchema, diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index fe497e98e2..25c3593de7 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -3480,6 +3480,64 @@ describe("TaskService", () => { expect(tasks.find((workspace) => workspace.id === otherTaskId)?.taskStatus).toBe("running"); }); + test("listDescendantAgentTasks can exclude workflow-owned descendants", async () => { + const config = await createTestConfig(rootDir); + const projectPath = path.join(rootDir, "repo"); + const rootWorkspaceId = "root-111"; + const workflowTaskId = "task-workflow"; + const workflowChildTaskId = "task-workflow-child"; + const regularTaskId = "task-regular"; + + await saveWorkspaces( + config, + projectPath, + [ + projectWorkspace(projectPath, "root", rootWorkspaceId), + projectWorkspace(projectPath, "workflow-task", workflowTaskId, { + parentWorkspaceId: rootWorkspaceId, + agentType: "exec", + taskStatus: "running", + workflowTask: { runId: "wfr_target", stepId: "scope" }, + }), + projectWorkspace(projectPath, "workflow-child", workflowChildTaskId, { + parentWorkspaceId: workflowTaskId, + agentType: "explore", + taskStatus: "running", + }), + projectWorkspace(projectPath, "regular-task", regularTaskId, { + parentWorkspaceId: rootWorkspaceId, + agentType: "exec", + taskStatus: "running", + }), + ], + testTaskSettings() + ); + + const { aiService } = createAIServiceMocks(config); + const { workspaceService } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { aiService, workspaceService }); + + expect( + new Set(taskService.listDescendantAgentTasks(rootWorkspaceId).map((task) => task.taskId)) + ).toEqual(new Set([regularTaskId, workflowChildTaskId, workflowTaskId])); + expect( + taskService + .listDescendantAgentTasks(rootWorkspaceId, { + excludeWorkflowTasks: true, + }) + .map((task) => task.taskId) + ).toEqual([regularTaskId]); + expect(taskService.isWorkflowOwnedDescendantAgentTask(rootWorkspaceId, workflowTaskId)).toBe( + true + ); + expect( + taskService.isWorkflowOwnedDescendantAgentTask(rootWorkspaceId, workflowChildTaskId) + ).toBe(true); + expect(taskService.isWorkflowOwnedDescendantAgentTask(rootWorkspaceId, regularTaskId)).toBe( + false + ); + }); + test("listActiveDescendantAgentTaskIds can exclude workflow-owned descendants", async () => { const config = await createTestConfig(rootDir); const projectPath = path.join(rootDir, "repo"); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 36581dcf1a..1f58df8da7 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -2195,7 +2195,7 @@ export class TaskService { listDescendantAgentTasks( workspaceId: string, - options?: { statuses?: AgentTaskStatus[] } + options?: { statuses?: AgentTaskStatus[]; excludeWorkflowTasks?: boolean } ): DescendantAgentTaskInfo[] { assert(workspaceId.length > 0, "listDescendantAgentTasks: workspaceId must be non-empty"); @@ -2207,9 +2207,9 @@ export class TaskService { const result: DescendantAgentTaskInfo[] = []; - const stack: Array<{ taskId: string; depth: number }> = []; + const stack: Array<{ taskId: string; depth: number; workflowOwned: boolean }> = []; for (const childTaskId of index.childrenByParent.get(workspaceId) ?? []) { - stack.push({ taskId: childTaskId, depth: 1 }); + stack.push({ taskId: childTaskId, depth: 1, workflowOwned: false }); } while (stack.length > 0) { @@ -2222,8 +2222,12 @@ export class TaskService { `listDescendantAgentTasks: task ${next.taskId} is missing parentWorkspaceId` ); + const workflowOwned = next.workflowOwned || entry.workflowTask != null; const status: AgentTaskStatus = entry.taskStatus ?? "running"; - if (!statusFilter || statusFilter.has(status)) { + if ( + (!statusFilter || statusFilter.has(status)) && + !(options?.excludeWorkflowTasks === true && workflowOwned) + ) { result.push({ taskId: next.taskId, status, @@ -2239,7 +2243,7 @@ export class TaskService { } for (const childTaskId of index.childrenByParent.get(next.taskId) ?? []) { - stack.push({ taskId: childTaskId, depth: next.depth + 1 }); + stack.push({ taskId: childTaskId, depth: next.depth + 1, workflowOwned }); } } @@ -2355,6 +2359,33 @@ export class TaskService { }); } + isWorkflowOwnedDescendantAgentTask(ancestorWorkspaceId: string, taskId: string): boolean { + assert( + ancestorWorkspaceId.length > 0, + "isWorkflowOwnedDescendantAgentTask: ancestorWorkspaceId required" + ); + assert(taskId.length > 0, "isWorkflowOwnedDescendantAgentTask: taskId required"); + + const cfg = this.config.loadConfigOrDefault(); + const index = this.buildAgentTaskIndex(cfg); + let current = taskId; + let workflowOwned = false; + + for (let i = 0; i < 32; i++) { + const entry = index.byId.get(current); + workflowOwned ||= entry?.workflowTask != null; + + const parent = index.parentById.get(current); + if (!parent) return false; + if (parent === ancestorWorkspaceId) return workflowOwned; + current = parent; + } + + throw new Error( + `isWorkflowOwnedDescendantAgentTask: possible parentWorkspaceId cycle starting at ${taskId}` + ); + } + async isDescendantAgentTask(ancestorWorkspaceId: string, taskId: string): Promise { assert(ancestorWorkspaceId.length > 0, "isDescendantAgentTask: ancestorWorkspaceId required"); assert(taskId.length > 0, "isDescendantAgentTask: taskId required"); diff --git a/src/node/services/tools/task.bash.test.ts b/src/node/services/tools/task.bash.test.ts index 0d408a7d31..61918fdbb0 100644 --- a/src/node/services/tools/task.bash.test.ts +++ b/src/node/services/tools/task.bash.test.ts @@ -145,6 +145,125 @@ describe("bash + task_* (background bash tasks)", () => { }); }); + it("task_list excludes background bash tasks from workflow-owned descendants", async () => { + using tempDir = new TestTempDir("test-task-list-bash-workflow-owned"); + + const startTime = Date.parse("2025-01-01T00:00:00.000Z"); + const list = mock(() => [ + { + id: "workflow-proc", + workspaceId: "workflow-task", + status: "running" as const, + displayName: "Workflow Proc", + startTime, + }, + { + id: "regular-proc", + workspaceId: "regular-task", + status: "running" as const, + displayName: "Regular Proc", + startTime, + }, + ]); + + const backgroundProcessManager = { list } as unknown as BackgroundProcessManager; + + const taskService = { + listDescendantAgentTasks: mock(() => []), + isDescendantAgentTask: mock(() => Promise.resolve(true)), + isWorkflowOwnedDescendantAgentTask: mock( + (_: string, taskId: string) => taskId === "workflow-task" + ), + } as unknown as TaskService; + + const tool = createTaskListTool({ + ...createTestToolConfig(tempDir.path, { workspaceId: "ws-1" }), + backgroundProcessManager, + taskService, + }); + + const result: unknown = await Promise.resolve(tool.execute!({}, mockToolCallOptions)); + + expect(result).toEqual({ + tasks: [ + { + taskId: "bash:regular-proc", + status: "running", + parentWorkspaceId: "regular-task", + title: "Regular Proc", + createdAt: new Date(startTime).toISOString(), + depth: 1, + }, + ], + }); + }); + + it("task_await omits workflow-owned background bash tasks when task_ids is omitted", async () => { + using tempDir = new TestTempDir("test-task-await-bash-workflow-owned"); + + const processes = [ + { + id: "workflow-proc", + workspaceId: "workflow-task", + status: "running" as const, + displayName: "Workflow Proc", + }, + { + id: "regular-proc", + workspaceId: "regular-task", + status: "running" as const, + displayName: "Regular Proc", + }, + ]; + const list = mock(() => processes); + const getProcess = mock((id: string) => processes.find((proc) => proc.id === id)); + const getOutput = mock(() => ({ + success: true as const, + status: "running" as const, + output: "ok", + elapsed_ms: 5, + })); + + const backgroundProcessManager = { + list, + getProcess, + getOutput, + } as unknown as BackgroundProcessManager; + + const taskService = { + listActiveDescendantAgentTaskIds: mock(() => []), + isDescendantAgentTask: mock(() => Promise.resolve(true)), + isWorkflowOwnedDescendantAgentTask: mock( + (_: string, taskId: string) => taskId === "workflow-task" + ), + waitForAgentReport: mock(() => Promise.resolve({ reportMarkdown: "ignored" })), + } as unknown as TaskService; + + const tool = createTaskAwaitTool({ + ...createTestToolConfig(tempDir.path, { workspaceId: "ws-1" }), + backgroundProcessManager, + taskService, + }); + + const result: unknown = await Promise.resolve( + tool.execute!({ timeout_secs: 0 }, mockToolCallOptions) + ); + + expect(getProcess).toHaveBeenCalledTimes(1); + expect(getProcess).toHaveBeenCalledWith("regular-proc"); + expect(result).toEqual({ + results: [ + { + status: "running", + taskId: "bash:regular-proc", + output: "ok", + elapsed_ms: 5, + note: undefined, + }, + ], + }); + }); + it("task_terminate can terminate bash tasks", async () => { using tempDir = new TestTempDir("test-task-terminate-bash"); diff --git a/src/node/services/tools/task_await.ts b/src/node/services/tools/task_await.ts index a57cb3529e..3999570229 100644 --- a/src/node/services/tools/task_await.ts +++ b/src/node/services/tools/task_await.ts @@ -224,6 +224,13 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { proc.workspaceId === workspaceId || (await taskService.isDescendantAgentTask(workspaceId, proc.workspaceId)); if (!inScope) continue; + if ( + proc.workspaceId !== workspaceId && + taskService.isWorkflowOwnedDescendantAgentTask(workspaceId, proc.workspaceId) + ) { + continue; + } + bashTaskIds.push(toBashTaskId(proc.id)); } diff --git a/src/node/services/tools/task_list.test.ts b/src/node/services/tools/task_list.test.ts index 8c1a782eb5..366552f1a4 100644 --- a/src/node/services/tools/task_list.test.ts +++ b/src/node/services/tools/task_list.test.ts @@ -25,6 +25,7 @@ describe("task_list tool", () => { expect(result).toEqual({ tasks: [] }); expect(listDescendantAgentTasks).toHaveBeenCalledWith("root-workspace", { statuses: ["queued", "running", "awaiting_report"], + excludeWorkflowTasks: true, }); }); @@ -44,6 +45,7 @@ describe("task_list tool", () => { expect(result).toEqual({ tasks: [] }); expect(listDescendantAgentTasks).toHaveBeenCalledWith("root-workspace", { statuses: ["running"], + excludeWorkflowTasks: true, }); }); diff --git a/src/node/services/tools/task_list.ts b/src/node/services/tools/task_list.ts index 95f9630bea..25f469b1e7 100644 --- a/src/node/services/tools/task_list.ts +++ b/src/node/services/tools/task_list.ts @@ -19,7 +19,10 @@ export const createTaskListTool: ToolFactory = (config: ToolConfiguration) => { const statuses = args.statuses && args.statuses.length > 0 ? args.statuses : [...DEFAULT_STATUSES]; - const agentTasks = taskService.listDescendantAgentTasks(workspaceId, { statuses }); + const agentTasks = taskService.listDescendantAgentTasks(workspaceId, { + statuses, + excludeWorkflowTasks: true, + }); const tasks = [...agentTasks]; if (config.backgroundProcessManager) { @@ -36,6 +39,13 @@ export const createTaskListTool: ToolFactory = (config: ToolConfiguration) => { (await taskService.isDescendantAgentTask(workspaceId, proc.workspaceId)); if (!inScope) continue; + if ( + proc.workspaceId !== workspaceId && + taskService.isWorkflowOwnedDescendantAgentTask(workspaceId, proc.workspaceId) + ) { + continue; + } + const status = proc.status === "running" ? "running" : "reported"; if (!statuses.includes(status)) continue; From acd6d9d871b7ad4f56a85bc5866e4b3c5d5bd39d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 4 Jun 2026 15:46:10 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20block=20direct=20awai?= =?UTF-8?q?ts=20of=20workflow-owned=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject explicit task_await calls for workflow-owned agent tasks and descendant bash processes so parent agents cannot bypass workflow-run vetting. Persist workflow-ownership metadata with subagent reports so the guard still works after task cleanup/restart.\n\nAlso update task_await's tool contract and generated hook docs to document the omitted-ID workflow-owned exclusion.\n\nValidation:\n- bun test src/node/services/tools/task_await.test.ts src/node/services/tools/task.bash.test.ts src/node/services/tools/task_list.test.ts src/node/services/taskService.test.ts\n- make static-check\n\n---\n\n_Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `5.41`_\n\n --- docs/hooks/tools.mdx | 4 +- src/common/utils/tools/toolDefinitions.ts | 4 +- .../builtInSkillContent.generated.ts | 4 +- src/node/services/subagentReportArtifacts.ts | 11 +++ src/node/services/taskService.test.ts | 63 ++++++++++++++-- src/node/services/taskService.ts | 75 +++++++++++++++++-- src/node/services/tools/task.bash.test.ts | 41 ++++++++++ src/node/services/tools/task_await.test.ts | 40 ++++++++++ src/node/services/tools/task_await.ts | 21 +++++- src/node/services/tools/task_list.ts | 2 +- 10 files changed, 242 insertions(+), 23 deletions(-) diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx index b76c7eccc0..11a7c73867 100644 --- a/docs/hooks/tools.mdx +++ b/docs/hooks/tools.mdx @@ -636,8 +636,8 @@ If a value is too large for the environment, it may be omitted (not set). Mux al | `MUX_TOOL_INPUT_FILTER` | `filter` | string | Optional regex to filter bash task output lines. By default, only matching lines are returned. When filter_exclude is true, matching lines are excluded instead. Non-matching lines are discarded and cannot be retrieved later. | | `MUX_TOOL_INPUT_FILTER_EXCLUDE` | `filter_exclude` | boolean | When true, lines matching 'filter' are excluded instead of kept. Requires 'filter' to be set. | | `MUX_TOOL_INPUT_MIN_COMPLETED` | `min_completed` | number | Number of awaited tasks that must complete before this call returns. Defaults to 1, so by default task_await returns as soon as the FIRST awaited task completes, letting you act on it while the rest keep running. The result still includes every task complete at that moment plus current status (running/queued) for the rest. Tasks that have not yet completed keep running and remain re-awaitable on a later task_await call. Raise this (e.g. set it to the total number of awaited tasks) when you genuinely need more before proceeding — for example best-of-N synthesis that must compare every candidate. Clamped to the number of awaited tasks; values above that behave like 'wait for all'. | -| `MUX_TOOL_INPUT_TASK_IDS_` | `task_ids[]` | string | List of task IDs or workflow run IDs to await — use only real IDs returned by prior task, bash, workflow_run, or task_list tool results; never fabricate an ID. When omitted, waits for all active descendant tasks and workflow runs of the current workspace. | -| `MUX_TOOL_INPUT_TASK_IDS_COUNT` | `task_ids.length` | number | Number of elements in task_ids (List of task IDs or workflow run IDs to await — use only real IDs returned by prior task, bash, workflow_run, or task_list tool results; never fabricate an ID. When omitted, waits for all active descendant tasks and workflow runs of the current workspace.) | +| `MUX_TOOL_INPUT_TASK_IDS_` | `task_ids[]` | string | List of task IDs or workflow run IDs to await — use only real IDs returned by prior task, bash, workflow_run, or task_list tool results; never fabricate an ID. When omitted, waits for active descendant tasks and workflow runs of the current workspace, excluding workflow-owned sub-agents and their background bash tasks because those results are consumed through workflow runs. | +| `MUX_TOOL_INPUT_TASK_IDS_COUNT` | `task_ids.length` | number | Number of elements in task_ids (List of task IDs or workflow run IDs to await — use only real IDs returned by prior task, bash, workflow_run, or task_list tool results; never fabricate an ID. When omitted, waits for active descendant tasks and workflow runs of the current workspace, excluding workflow-owned sub-agents and their background bash tasks because those results are consumed through workflow runs.) | | `MUX_TOOL_INPUT_TIMEOUT_SECS` | `timeout_secs` | number | Maximum time to wait in seconds for each task. For bash tasks, this waits for NEW output (or process exit). If exceeded, the result returns status=queued\|running\|awaiting_report (task is still active). Defaults to 600 seconds (10 minutes) if not specified. Set to 0 for a non-blocking status check. | diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index febd846281..7015d27ac0 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -430,7 +430,7 @@ export const TaskAwaitToolArgsSchema = z .nullish() .describe( "List of task IDs or workflow run IDs to await — use only real IDs returned by prior task, bash, workflow_run, or task_list tool results; never fabricate an ID. " + - "When omitted, waits for all active descendant tasks and workflow runs of the current workspace." + "When omitted, waits for active descendant tasks and workflow runs of the current workspace, excluding workflow-owned sub-agents and their background bash tasks because those results are consumed through workflow runs." ), filter: z .string() @@ -1516,7 +1516,7 @@ export const TOOL_DEFINITIONS = { "\n\nIMPORTANT: Do not call task_await in the same parallel tool-call batch as task or bash — " + "the taskId is not available until the spawning tool returns. " + "Always wait for the task/bash tool result first, then call task_await in a subsequent step. " + - "When omitting task_ids to await all active tasks/workflows, ensure at least one background task or workflow was already spawned in a prior step. " + + "When omitting task_ids to await active tasks/workflows, ensure at least one background task or workflow was already spawned in a prior step. Omitted task_ids exclude workflow-owned sub-agents and their background bash tasks because those results are consumed through workflow runs. " + "\n\nAgent tasks and workflow runs return reports when completed. " + "Bash tasks return incremental output while running and a final reportMarkdown when they exit. " + "For bash tasks, you may optionally pass filter/filter_exclude to include/exclude output lines by regex. " + diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index fd6c000708..f326e690e6 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4436,8 +4436,8 @@ export const BUILTIN_SKILL_FILES: Record> = { "| `MUX_TOOL_INPUT_FILTER` | `filter` | string | Optional regex to filter bash task output lines. By default, only matching lines are returned. When filter_exclude is true, matching lines are excluded instead. Non-matching lines are discarded and cannot be retrieved later. |", "| `MUX_TOOL_INPUT_FILTER_EXCLUDE` | `filter_exclude` | boolean | When true, lines matching 'filter' are excluded instead of kept. Requires 'filter' to be set. |", "| `MUX_TOOL_INPUT_MIN_COMPLETED` | `min_completed` | number | Number of awaited tasks that must complete before this call returns. Defaults to 1, so by default task_await returns as soon as the FIRST awaited task completes, letting you act on it while the rest keep running. The result still includes every task complete at that moment plus current status (running/queued) for the rest. Tasks that have not yet completed keep running and remain re-awaitable on a later task_await call. Raise this (e.g. set it to the total number of awaited tasks) when you genuinely need more before proceeding — for example best-of-N synthesis that must compare every candidate. Clamped to the number of awaited tasks; values above that behave like 'wait for all'. |", - "| `MUX_TOOL_INPUT_TASK_IDS_` | `task_ids[]` | string | List of task IDs or workflow run IDs to await — use only real IDs returned by prior task, bash, workflow_run, or task_list tool results; never fabricate an ID. When omitted, waits for all active descendant tasks and workflow runs of the current workspace. |", - "| `MUX_TOOL_INPUT_TASK_IDS_COUNT` | `task_ids.length` | number | Number of elements in task_ids (List of task IDs or workflow run IDs to await — use only real IDs returned by prior task, bash, workflow_run, or task_list tool results; never fabricate an ID. When omitted, waits for all active descendant tasks and workflow runs of the current workspace.) |", + "| `MUX_TOOL_INPUT_TASK_IDS_` | `task_ids[]` | string | List of task IDs or workflow run IDs to await — use only real IDs returned by prior task, bash, workflow_run, or task_list tool results; never fabricate an ID. When omitted, waits for active descendant tasks and workflow runs of the current workspace, excluding workflow-owned sub-agents and their background bash tasks because those results are consumed through workflow runs. |", + "| `MUX_TOOL_INPUT_TASK_IDS_COUNT` | `task_ids.length` | number | Number of elements in task_ids (List of task IDs or workflow run IDs to await — use only real IDs returned by prior task, bash, workflow_run, or task_list tool results; never fabricate an ID. When omitted, waits for active descendant tasks and workflow runs of the current workspace, excluding workflow-owned sub-agents and their background bash tasks because those results are consumed through workflow runs.) |", "| `MUX_TOOL_INPUT_TIMEOUT_SECS` | `timeout_secs` | number | Maximum time to wait in seconds for each task. For bash tasks, this waits for NEW output (or process exit). If exceeded, the result returns status=queued\\|running\\|awaiting_report (task is still active). Defaults to 600 seconds (10 minutes) if not specified. Set to 0 for a non-blocking status check. |", "", "", diff --git a/src/node/services/subagentReportArtifacts.ts b/src/node/services/subagentReportArtifacts.ts index 54da7b326e..a506b4d9bb 100644 --- a/src/node/services/subagentReportArtifacts.ts +++ b/src/node/services/subagentReportArtifacts.ts @@ -26,6 +26,8 @@ export interface SubagentReportArtifactIndexEntry { title?: string; /** Full ancestor chain (parent first). Used for descendant scope checks after cleanup. */ ancestorWorkspaceIds: string[]; + /** Ancestors for which this child is owned by a workflow step. */ + workflowOwnedAncestorWorkspaceIds?: string[]; structuredOutput?: unknown; /** Estimated token count of delivered report markdown (~4 chars/token). */ reportTokenEstimate?: number; @@ -136,6 +138,7 @@ export async function readSubagentReportArtifact( thinkingLevel?: unknown; title?: unknown; ancestorWorkspaceIds?: unknown; + workflowOwnedAncestorWorkspaceIds?: unknown; structuredOutput?: unknown; reportMarkdown?: unknown; }; @@ -151,6 +154,10 @@ export async function readSubagentReportArtifact( typeof obj.model === "string" && obj.model.trim().length > 0 ? obj.model.trim() : undefined; const thinkingLevel = coerceThinkingLevel(obj.thinkingLevel); + const workflowOwnedAncestorWorkspaceIds = isStringArray(obj.workflowOwnedAncestorWorkspaceIds) + ? obj.workflowOwnedAncestorWorkspaceIds + : undefined; + if (meta) { // Trust the index file for metadata (versioned), but allow per-task file to override title. return { @@ -188,6 +195,7 @@ export async function readSubagentReportArtifact( thinkingLevel, title, ancestorWorkspaceIds, + workflowOwnedAncestorWorkspaceIds, structuredOutput: obj.structuredOutput, reportMarkdown, }; @@ -234,6 +242,7 @@ export async function upsertSubagentReportArtifact(params: { model?: string; /** Task-level thinking/reasoning level used when running the sub-agent (optional for legacy entries). */ thinkingLevel?: ThinkingLevel; + workflowOwnedAncestorWorkspaceIds?: string[]; structuredOutput?: unknown; title?: string; nowMs?: number; @@ -272,6 +281,7 @@ export async function upsertSubagentReportArtifact(params: { thinkingLevel, title: params.title, ancestorWorkspaceIds: params.ancestorWorkspaceIds, + workflowOwnedAncestorWorkspaceIds: params.workflowOwnedAncestorWorkspaceIds, structuredOutput: params.structuredOutput, reportMarkdown: params.reportMarkdown, }, @@ -296,6 +306,7 @@ export async function upsertSubagentReportArtifact(params: { model, thinkingLevel, title: params.title, + workflowOwnedAncestorWorkspaceIds: params.workflowOwnedAncestorWorkspaceIds, structuredOutput: params.structuredOutput, ancestorWorkspaceIds: params.ancestorWorkspaceIds, }; diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 25c3593de7..bf01c63cd4 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -3527,15 +3527,66 @@ describe("TaskService", () => { }) .map((task) => task.taskId) ).toEqual([regularTaskId]); - expect(taskService.isWorkflowOwnedDescendantAgentTask(rootWorkspaceId, workflowTaskId)).toBe( - true - ); expect( - taskService.isWorkflowOwnedDescendantAgentTask(rootWorkspaceId, workflowChildTaskId) + await taskService.isWorkflowOwnedDescendantAgentTask(rootWorkspaceId, workflowTaskId) ).toBe(true); - expect(taskService.isWorkflowOwnedDescendantAgentTask(rootWorkspaceId, regularTaskId)).toBe( - false + expect( + await taskService.isWorkflowOwnedDescendantAgentTask(rootWorkspaceId, workflowChildTaskId) + ).toBe(true); + expect( + await taskService.isWorkflowOwnedDescendantAgentTask(rootWorkspaceId, regularTaskId) + ).toBe(false); + }); + + test("isWorkflowOwnedDescendantAgentTask consults persisted report metadata", async () => { + const config = await createTestConfig(rootDir); + const projectPath = path.join(rootDir, "repo"); + const rootWorkspaceId = "root-111"; + const workflowTaskId = "task-workflow"; + const removedWorkflowChildTaskId = "task-workflow-child-removed"; + + await saveWorkspaces( + config, + projectPath, + [ + projectWorkspace(projectPath, "root", rootWorkspaceId), + projectWorkspace(projectPath, "workflow-task", workflowTaskId, { + parentWorkspaceId: rootWorkspaceId, + agentType: "exec", + taskStatus: "reported", + workflowTask: { runId: "wfr_target", stepId: "scope" }, + }), + ], + testTaskSettings() ); + + await upsertSubagentReportArtifact({ + workspaceId: rootWorkspaceId, + workspaceSessionDir: config.getSessionDir(rootWorkspaceId), + childTaskId: removedWorkflowChildTaskId, + parentWorkspaceId: workflowTaskId, + ancestorWorkspaceIds: [workflowTaskId, rootWorkspaceId], + workflowOwnedAncestorWorkspaceIds: [rootWorkspaceId], + reportMarkdown: "done", + nowMs: 1, + }); + + const { aiService } = createAIServiceMocks(config); + const { workspaceService } = createWorkspaceServiceMocks(); + const { taskService } = createTaskServiceHarness(config, { aiService, workspaceService }); + + expect( + await taskService.isWorkflowOwnedDescendantAgentTask( + rootWorkspaceId, + removedWorkflowChildTaskId + ) + ).toBe(true); + expect( + await taskService.isWorkflowOwnedDescendantAgentTask( + workflowTaskId, + removedWorkflowChildTaskId + ) + ).toBe(false); }); test("listActiveDescendantAgentTaskIds can exclude workflow-owned descendants", async () => { diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 1f58df8da7..2df3bdb9af 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -317,6 +317,8 @@ interface CompletedAgentReportCacheEntry { // Ancestor workspace IDs captured when the report was cached. // Used to keep descendant-scope checks working even if the task workspace is cleaned up. ancestorWorkspaceIds: string[]; + // Ancestors for which the task report must only be consumed through a workflow run. + workflowOwnedAncestorWorkspaceIds?: string[]; } interface ParentAutoResumeHint { @@ -350,6 +352,14 @@ function hasAncestorWorkspaceId( return Array.isArray(ids) && ids.includes(ancestorWorkspaceId); } +function hasWorkflowOwnedAncestorWorkspaceId( + entry: { workflowOwnedAncestorWorkspaceIds?: unknown } | null | undefined, + ancestorWorkspaceId: string +): boolean { + const ids = entry?.workflowOwnedAncestorWorkspaceIds; + return Array.isArray(ids) && ids.includes(ancestorWorkspaceId); +} + function isSuccessfulToolResult(value: unknown): boolean { return ( typeof value === "object" && @@ -1821,6 +1831,7 @@ export class TaskService { reportMarkdown: artifact.reportMarkdown, title: artifact.title, structuredOutput: artifact.structuredOutput, + workflowOwnedAncestorWorkspaceIds: artifact.workflowOwnedAncestorWorkspaceIds, ancestorWorkspaceIds: artifact.ancestorWorkspaceIds, }); this.enforceCompletedReportCacheLimit(); @@ -2359,7 +2370,10 @@ export class TaskService { }); } - isWorkflowOwnedDescendantAgentTask(ancestorWorkspaceId: string, taskId: string): boolean { + async isWorkflowOwnedDescendantAgentTask( + ancestorWorkspaceId: string, + taskId: string + ): Promise { assert( ancestorWorkspaceId.length > 0, "isWorkflowOwnedDescendantAgentTask: ancestorWorkspaceId required" @@ -2367,7 +2381,34 @@ export class TaskService { assert(taskId.length > 0, "isWorkflowOwnedDescendantAgentTask: taskId required"); const cfg = this.config.loadConfigOrDefault(); - const index = this.buildAgentTaskIndex(cfg); + const indexResult = this.getWorkflowOwnedDescendantAgentTaskUsingIndex( + this.buildAgentTaskIndex(cfg), + ancestorWorkspaceId, + taskId + ); + if (indexResult != null) { + return indexResult; + } + + const cached = this.completedReportsByTaskId.get(taskId); + if (hasWorkflowOwnedAncestorWorkspaceId(cached, ancestorWorkspaceId)) { + return true; + } + if (hasAncestorWorkspaceId(cached, ancestorWorkspaceId)) { + return false; + } + + const sessionDir = this.config.getSessionDir(ancestorWorkspaceId); + const persisted = await readSubagentReportArtifactsFile(sessionDir); + const entry = persisted.artifactsByChildTaskId[taskId]; + return hasWorkflowOwnedAncestorWorkspaceId(entry, ancestorWorkspaceId); + } + + private getWorkflowOwnedDescendantAgentTaskUsingIndex( + index: AgentTaskIndex, + ancestorWorkspaceId: string, + taskId: string + ): boolean | null { let current = taskId; let workflowOwned = false; @@ -2376,13 +2417,13 @@ export class TaskService { workflowOwned ||= entry?.workflowTask != null; const parent = index.parentById.get(current); - if (!parent) return false; + if (!parent) return null; if (parent === ancestorWorkspaceId) return workflowOwned; current = parent; } throw new Error( - `isWorkflowOwnedDescendantAgentTask: possible parentWorkspaceId cycle starting at ${taskId}` + `getWorkflowOwnedDescendantAgentTaskUsingIndex: possible parentWorkspaceId cycle starting at ${taskId}` ); } @@ -4123,11 +4164,19 @@ export class TaskService { const isWorkflowOwnedChildReport = latestChildEntry?.workspace.workflowTask != null; - const parentById = this.buildAgentTaskIndex(cfgAfterReport).parentById; + const indexAfterReport = this.buildAgentTaskIndex(cfgAfterReport); const ancestorWorkspaceIds = this.listAncestorWorkspaceIdsUsingParentById( - parentById, + indexAfterReport.parentById, childWorkspaceId ); + const workflowOwnedAncestorWorkspaceIds = ancestorWorkspaceIds.filter( + (ancestorWorkspaceId) => + this.getWorkflowOwnedDescendantAgentTaskUsingIndex( + indexAfterReport, + ancestorWorkspaceId, + childWorkspaceId + ) === true + ); // Persist the completed report in the session dirs of all ancestors so `task_await` can // retrieve it after cleanup/restart (even if the task workspace itself is deleted). @@ -4141,6 +4190,7 @@ export class TaskService { childTaskId: childWorkspaceId, parentWorkspaceId, ancestorWorkspaceIds, + workflowOwnedAncestorWorkspaceIds, reportMarkdown: reportArgs.reportMarkdown, model: latestChildEntry?.workspace.taskModelString, thinkingLevel: latestChildEntry?.workspace.taskThinkingLevel, @@ -4270,14 +4320,23 @@ export class TaskService { this.markTaskForegroundRelevant(taskId); const cfg = this.config.loadConfigOrDefault(); - const parentById = this.buildAgentTaskIndex(cfg).parentById; - const ancestorWorkspaceIds = this.listAncestorWorkspaceIdsUsingParentById(parentById, taskId); + const index = this.buildAgentTaskIndex(cfg); + const ancestorWorkspaceIds = this.listAncestorWorkspaceIdsUsingParentById( + index.parentById, + taskId + ); + const workflowOwnedAncestorWorkspaceIds = ancestorWorkspaceIds.filter( + (ancestorWorkspaceId) => + this.getWorkflowOwnedDescendantAgentTaskUsingIndex(index, ancestorWorkspaceId, taskId) === + true + ); this.completedReportsByTaskId.set(taskId, { reportMarkdown: report.reportMarkdown, title: report.title, structuredOutput: report.structuredOutput, ancestorWorkspaceIds, + workflowOwnedAncestorWorkspaceIds, }); this.enforceCompletedReportCacheLimit(); diff --git a/src/node/services/tools/task.bash.test.ts b/src/node/services/tools/task.bash.test.ts index 61918fdbb0..424d346835 100644 --- a/src/node/services/tools/task.bash.test.ts +++ b/src/node/services/tools/task.bash.test.ts @@ -264,6 +264,47 @@ describe("bash + task_* (background bash tasks)", () => { }); }); + it("task_await rejects explicit bash tasks from workflow-owned descendants", async () => { + using tempDir = new TestTempDir("test-task-await-explicit-bash-workflow-owned"); + + const getProcess = mock(() => ({ + id: "workflow-proc", + workspaceId: "workflow-task", + status: "running" as const, + displayName: "Workflow Proc", + })); + const getOutput = mock(() => { + throw new Error("workflow-owned bash output should not be read directly"); + }); + + const backgroundProcessManager = { + getProcess, + getOutput, + } as unknown as BackgroundProcessManager; + + const taskService = { + listActiveDescendantAgentTaskIds: mock(() => []), + isDescendantAgentTask: mock(() => Promise.resolve(true)), + isWorkflowOwnedDescendantAgentTask: mock(() => Promise.resolve(true)), + waitForAgentReport: mock(() => Promise.resolve({ reportMarkdown: "ignored" })), + } as unknown as TaskService; + + const tool = createTaskAwaitTool({ + ...createTestToolConfig(tempDir.path, { workspaceId: "ws-1" }), + backgroundProcessManager, + taskService, + }); + + const result: unknown = await Promise.resolve( + tool.execute!({ task_ids: ["bash:workflow-proc"] }, mockToolCallOptions) + ); + + expect(getOutput).toHaveBeenCalledTimes(0); + expect(result).toEqual({ + results: [{ status: "invalid_scope", taskId: "bash:workflow-proc" }], + }); + }); + it("task_terminate can terminate bash tasks", async () => { using tempDir = new TestTempDir("test-task-terminate-bash"); diff --git a/src/node/services/tools/task_await.test.ts b/src/node/services/tools/task_await.test.ts index b8fc1544b6..c3972ddf50 100644 --- a/src/node/services/tools/task_await.test.ts +++ b/src/node/services/tools/task_await.test.ts @@ -313,6 +313,46 @@ describe("task_await tool", () => { expect(listBackgroundProcesses).toHaveBeenCalledTimes(0); }); + it("rejects explicit workflow-owned agent task IDs", async () => { + using tempDir = new TestTempDir("test-task-await-tool-explicit-workflow-owned-agent"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + + const waitForAgentReport = mock(() => Promise.resolve({ reportMarkdown: "leaked" })); + const getAgentTaskStatuses = mock((taskIds: string[]) => { + expect(taskIds).toEqual(["workflow-task"]); + return new Map([["workflow-task", { exists: true, taskStatus: "reported" as const }]]); + }); + const isWorkflowOwnedDescendantAgentTask = mock( + (_ancestorWorkspaceId: string, taskId: string) => Promise.resolve(taskId === "workflow-task") + ); + const taskService = { + listActiveDescendantAgentTaskIds: mock(() => []), + isDescendantAgentTask: mock(() => Promise.resolve(true)), + isWorkflowOwnedDescendantAgentTask, + getAgentTaskStatuses, + waitForAgentReport, + } as unknown as TaskService; + + const tool = createTaskAwaitTool({ ...baseConfig, taskService }); + + const result = (await Promise.resolve( + tool.execute!({ task_ids: ["workflow-task"] }, mockToolCallOptions) + )) as { results: Array<{ status: string; taskId: string }> }; + + expect(result.results).toHaveLength(1); + const firstResult = result.results[0]; + if (!firstResult) { + throw new Error("Expected one task_await result"); + } + expect(firstResult.status).toBe("invalid_scope"); + expect(firstResult.taskId).toBe("workflow-task"); + expect(isWorkflowOwnedDescendantAgentTask).toHaveBeenCalledWith( + "parent-workspace", + "workflow-task" + ); + expect(waitForAgentReport).toHaveBeenCalledTimes(0); + }); + it("falls back to not_found when bash suggestion discovery fails", async () => { using tempDir = new TestTempDir("test-task-await-tool-suggestion-fallback-on-list-error"); const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); diff --git a/src/node/services/tools/task_await.ts b/src/node/services/tools/task_await.ts index 3999570229..3406d9f256 100644 --- a/src/node/services/tools/task_await.ts +++ b/src/node/services/tools/task_await.ts @@ -211,6 +211,9 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { workspaceId, { excludeWorkflowTasks: true } ); + const isWorkflowOwnedDescendantAgentTask = async (taskId: string): Promise => + (await taskService.isWorkflowOwnedDescendantAgentTask?.(workspaceId, taskId)) ?? false; + const listInScopeBackgroundBashTaskIds = async (): Promise => { if (!config.backgroundProcessManager) { return []; @@ -226,7 +229,7 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { if (!inScope) continue; if ( proc.workspaceId !== workspaceId && - taskService.isWorkflowOwnedDescendantAgentTask(workspaceId, proc.workspaceId) + (await isWorkflowOwnedDescendantAgentTask(proc.workspaceId)) ) { continue; } @@ -308,7 +311,15 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { ) ).filter((taskId): taskId is string => typeof taskId === "string"); - const descendantAgentTaskIdSet = new Set(descendantAgentTaskIds); + const awaitableAgentTaskIds: string[] = []; + for (const taskId of descendantAgentTaskIds) { + if (await isWorkflowOwnedDescendantAgentTask(taskId)) { + continue; + } + awaitableAgentTaskIds.push(taskId); + } + + const descendantAgentTaskIdSet = new Set(awaitableAgentTaskIds); const rejectedAgentTaskIds = agentTaskIds.filter( (taskId) => !descendantAgentTaskIdSet.has(taskId) ); @@ -397,6 +408,12 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { if (!inScope) { return { status: "invalid_scope" as const, taskId }; } + if ( + proc.workspaceId !== workspaceId && + (await isWorkflowOwnedDescendantAgentTask(proc.workspaceId)) + ) { + return { status: "invalid_scope" as const, taskId }; + } const outputResult = await config.backgroundProcessManager.getOutput( maybeProcessId, diff --git a/src/node/services/tools/task_list.ts b/src/node/services/tools/task_list.ts index 25f469b1e7..cdec899208 100644 --- a/src/node/services/tools/task_list.ts +++ b/src/node/services/tools/task_list.ts @@ -41,7 +41,7 @@ export const createTaskListTool: ToolFactory = (config: ToolConfiguration) => { if ( proc.workspaceId !== workspaceId && - taskService.isWorkflowOwnedDescendantAgentTask(workspaceId, proc.workspaceId) + (await taskService.isWorkflowOwnedDescendantAgentTask(workspaceId, proc.workspaceId)) ) { continue; } From 7b5c39a386701b9f03825e9f4af012220f84deb8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 4 Jun 2026 16:02:52 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A4=96=20tests:=20relax=20built-in=20?= =?UTF-8?q?workflow=20timeouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increase built-in workflow test timeouts after CI hit Bun's 5s default while these tests passed locally just under that boundary.\n\nValidation:\n- bun test src/node/services/workflows/builtInWorkflowDefinitions.test.ts\n- make static-check\n\n---\n\n_Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `6.26`_\n\n --- .../services/workflows/builtInWorkflowDefinitions.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node/services/workflows/builtInWorkflowDefinitions.test.ts b/src/node/services/workflows/builtInWorkflowDefinitions.test.ts index 2e887cd2ee..40496d0a35 100644 --- a/src/node/services/workflows/builtInWorkflowDefinitions.test.ts +++ b/src/node/services/workflows/builtInWorkflowDefinitions.test.ts @@ -394,7 +394,7 @@ describe("built-in deep-research workflow", () => { expect(structuredOutput.sources).toHaveLength(16); expect(structuredOutput.claims).toHaveLength(16); expect(structuredOutput.verification).toHaveLength(16); - }); + }, 10_000); }); describe("built-in deep-review-workflow", () => { @@ -572,5 +572,5 @@ describe("built-in deep-review-workflow", () => { }, }, }); - }); + }, 10_000); });