From 193da4965929c92fe0c55449f23ab0572d7035c2 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 05:23:53 +0000 Subject: [PATCH 1/6] refactor: dedupe queued-message foreground backgrounding in TaskService #3605 introduced two near-identical guard blocks that background registered foreground waits when a tool-end message is already queued (one in the workspace-turn wait path, one in the task-await path). Extract a single private helper backgroundForegroundWaitIfQueued() shared by both sites. Behavior-preserving: the helper folds in the requestingWorkspaceId truthiness guard, which is always true at the workspace-turn site (asserted non-empty) and already implied by shouldBackgroundOnQueuedMessage at the task-await site. --- src/node/services/taskService.ts | 41 ++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 63a0949441..68f1a0c33a 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -3938,6 +3938,26 @@ export class TaskService { return count; } + /** + * Background any registered foreground waits for the requesting workspace when a + * tool-end message is already queued. Shared by both wait-registration paths + * (workspace-turn and task await): the auto-backgrounding signal is edge-triggered + * on enqueue, so a message queued before the waiter registered must be re-checked + * here. No-op when backgrounding is disabled or no requesting workspace is set. + */ + private backgroundForegroundWaitIfQueued( + shouldBackgroundOnQueuedMessage: boolean, + requestingWorkspaceId: string | undefined + ): void { + if ( + shouldBackgroundOnQueuedMessage && + requestingWorkspaceId && + this.workspaceService.hasQueuedMessages(requestingWorkspaceId, "tool-end") + ) { + this.backgroundForegroundWaitsForWorkspace(requestingWorkspaceId); + } + } + private buildWorkspaceTurnWaitResult( record: WorkspaceTurnTaskHandleRecord ): WorkspaceTurnWaitResult { @@ -4162,12 +4182,10 @@ export class TaskService { timeoutMs ); - if ( - shouldBackgroundOnQueuedMessage && - this.workspaceService.hasQueuedMessages(options.requestingWorkspaceId, "tool-end") - ) { - this.backgroundForegroundWaitsForWorkspace(options.requestingWorkspaceId); - } + this.backgroundForegroundWaitIfQueued( + shouldBackgroundOnQueuedMessage, + options.requestingWorkspaceId + ); void (async () => { const record = await this.taskHandleStore.getWorkspaceTurn( @@ -4548,13 +4566,10 @@ export class TaskService { options.abortSignal.addEventListener("abort", abortListener, { once: true }); } - if ( - shouldBackgroundOnQueuedMessage && - requestingWorkspaceId && - this.workspaceService.hasQueuedMessages(requestingWorkspaceId, "tool-end") - ) { - this.backgroundForegroundWaitsForWorkspace(requestingWorkspaceId); - } + this.backgroundForegroundWaitIfQueued( + shouldBackgroundOnQueuedMessage, + requestingWorkspaceId + ); })().catch((error: unknown) => { reject(error instanceof Error ? error : new Error(String(error))); }); From d7f006c798f240a91c839ecc6d2958b58d1aa6a7 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:57:03 +0000 Subject: [PATCH 2/6] refactor: reuse SKILL_SCRIPT_PATH_PREFIX for workflow canonical paths The skill:// prefix is already named by SKILL_SCRIPT_PATH_PREFIX and used for parsing; reuse it in the two canonical-path builders instead of re-spelling the literal, so the prefix cannot drift. Behavior-preserving: the constant value is identical to the literal it replaces. --- src/node/services/workflows/workflowScriptResolver.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node/services/workflows/workflowScriptResolver.ts b/src/node/services/workflows/workflowScriptResolver.ts index 605bc96cd8..21d91ebb11 100644 --- a/src/node/services/workflows/workflowScriptResolver.ts +++ b/src/node/services/workflows/workflowScriptResolver.ts @@ -76,7 +76,7 @@ async function resolveSkillWorkflowScript( const builtIn = readBuiltInSkillFile(parsed.skillName, parsed.relativePath); return buildResolvedScript({ requestedScriptPath: input.scriptPath, - canonicalScriptPath: `skill://${parsed.skillName}/${builtIn.resolvedPath}`, + canonicalScriptPath: `${SKILL_SCRIPT_PATH_PREFIX}${parsed.skillName}/${builtIn.resolvedPath}`, source: builtIn.content, sourceKind: "skill", scope: "built-in", @@ -106,7 +106,7 @@ async function resolveSkillWorkflowScript( const source = await readFileString(skillRuntime, resolvedPath); return buildResolvedScript({ requestedScriptPath: input.scriptPath, - canonicalScriptPath: `skill://${parsed.skillName}/${parsed.relativePath}`, + canonicalScriptPath: `${SKILL_SCRIPT_PATH_PREFIX}${parsed.skillName}/${parsed.relativePath}`, source, sourceKind: "skill", scope: resolvedSkill.package.scope, From 3fbe730a81cfbec759bc17f931dbdb6b2bdd66fd Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:54:00 +0000 Subject: [PATCH 3/6] refactor: extract shared WorkflowTaskCreateArgs type Dedupe the identical inline argument object declared by WorkflowTaskServiceLike.create and createMany into a named WorkflowTaskCreateArgs interface so the agent-task creation shape has a single source of truth. PR #3611 just added onRefusal to both copies, demonstrating the drift risk. Behavior-preserving: pure type-level refactor, no runtime change. --- .../workflows/WorkflowTaskServiceAdapter.ts | 62 ++++++++----------- 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/src/node/services/workflows/WorkflowTaskServiceAdapter.ts b/src/node/services/workflows/WorkflowTaskServiceAdapter.ts index 1f14bdf344..a0cf6bf03e 100644 --- a/src/node/services/workflows/WorkflowTaskServiceAdapter.ts +++ b/src/node/services/workflows/WorkflowTaskServiceAdapter.ts @@ -38,44 +38,34 @@ interface WorkflowTaskExperiments { dynamicWorkflows?: boolean; } +// Shared shape for agent task creation so the single-step `create` and the +// batched `createMany` stay in lockstep; adding a field (e.g. onRefusal) in one +// place must not silently diverge from the other. +interface WorkflowTaskCreateArgs { + parentWorkspaceId: string; + kind: "agent"; + agentId: string; + prompt: string; + title: string; + workflowTask: { + runId: string; + stepId: string; + workflowName?: string; + outputSchema?: unknown; + }; + experiments?: WorkflowTaskExperiments; + modelString?: string; + thinkingLevel?: ParsedThinkingInput; + isolation?: "fork" | "none"; + onRefusal?: "fail" | "fallback"; +} + interface WorkflowTaskServiceLike { - create(args: { - parentWorkspaceId: string; - kind: "agent"; - agentId: string; - prompt: string; - title: string; - workflowTask: { - runId: string; - stepId: string; - workflowName?: string; - outputSchema?: unknown; - }; - experiments?: WorkflowTaskExperiments; - modelString?: string; - thinkingLevel?: ParsedThinkingInput; - isolation?: "fork" | "none"; - onRefusal?: "fail" | "fallback"; - }): Promise<{ success: true; data: TaskCreateResult } | { success: false; error: string }>; + create( + args: WorkflowTaskCreateArgs + ): Promise<{ success: true; data: TaskCreateResult } | { success: false; error: string }>; createMany?( - args: Array<{ - parentWorkspaceId: string; - kind: "agent"; - agentId: string; - prompt: string; - title: string; - workflowTask: { - runId: string; - stepId: string; - workflowName?: string; - outputSchema?: unknown; - }; - experiments?: WorkflowTaskExperiments; - modelString?: string; - thinkingLevel?: ParsedThinkingInput; - isolation?: "fork" | "none"; - onRefusal?: "fail" | "fallback"; - }>, + args: WorkflowTaskCreateArgs[], options?: { onTaskReserved?: (index: number, result: TaskCreateResult) => Promise | void; } From 9247fdbace1e86a6b9effb4d00c49f2399d094fe Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:49:21 +0000 Subject: [PATCH 4/6] refactor: centralize workspace-turn task tag keys The 'mux.taskHandleId' workspace tag is written in taskService (node) and read in TaskToolCall (browser); the literal was duplicated across the node/browser boundary. Extract the three workspace-turn task tag keys into a shared @/constants/workspaceTags module so the cross-layer contract has a single source of truth. Pure constant substitution: values are byte-identical, so no behavior changes. --- src/browser/features/Tools/TaskToolCall.tsx | 3 ++- src/constants/workspaceTags.ts | 21 +++++++++++++++++++++ src/node/services/taskService.ts | 7 ++++--- 3 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 src/constants/workspaceTags.ts diff --git a/src/browser/features/Tools/TaskToolCall.tsx b/src/browser/features/Tools/TaskToolCall.tsx index 419f89de44..2f49d86006 100644 --- a/src/browser/features/Tools/TaskToolCall.tsx +++ b/src/browser/features/Tools/TaskToolCall.tsx @@ -30,6 +30,7 @@ import { useTaskToolLiveTaskIds } from "@/browser/stores/WorkspaceStore"; import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; import { useBackgroundProcesses } from "@/browser/stores/BackgroundBashStore"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; +import { WORKSPACE_TURN_TASK_TAGS } from "@/constants/workspaceTags"; import type { TaskToolArgs, TaskToolResult, @@ -172,7 +173,7 @@ function findWorkspaceForTaskTarget( // workspace tasks tag the actual workspace with the handle so stale tool results remain clickable // after the result's explicit workspaceId falls out of view. for (const metadata of workspaceMetadata?.values() ?? []) { - if (metadata.tags?.["mux.taskHandleId"] === taskId) { + if (metadata.tags?.[WORKSPACE_TURN_TASK_TAGS.handle] === taskId) { return metadata; } } diff --git a/src/constants/workspaceTags.ts b/src/constants/workspaceTags.ts new file mode 100644 index 0000000000..8b769119c5 --- /dev/null +++ b/src/constants/workspaceTags.ts @@ -0,0 +1,21 @@ +/** + * Workspace metadata tag keys for workspace-turn ("workspace" agent) tasks. + * + * When a workspace task creates a fresh workspace, the backend stamps these + * tags onto the new workspace so the task can be correlated back to its + * originating handle/owner/turn. The `handle` tag in particular is read by the + * frontend (`TaskToolCall`) to keep stale task tool results clickable after the + * result's explicit workspaceId falls out of view, so its key must stay in sync + * across the node/browser boundary. Centralizing the keys here keeps that + * contract a single source of truth instead of duplicating the literals. + */ +export const WORKSPACE_TURN_TASK_TAGS = { + /** Workspace-turn task handle id (`wst_...`) that created the workspace. */ + handle: "mux.taskHandleId", + /** Workspace id that owns the task. */ + ownerWorkspaceId: "mux.taskOwnerWorkspaceId", + /** Turn id associated with the task. */ + turn: "mux.taskTurnId", +} as const; + +Object.freeze(WORKSPACE_TURN_TASK_TAGS); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 68f1a0c33a..4b44375c4a 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -11,6 +11,7 @@ import type { WorkspaceService } from "@/node/services/workspaceService"; import type { HistoryService } from "@/node/services/historyService"; import type { InitStateManager } from "@/node/services/initStateManager"; import { STRUCTURED_WORKFLOW_REPORT_PLACEHOLDER_MARKDOWN } from "@/common/constants/workflowReports"; +import { WORKSPACE_TURN_TASK_TAGS } from "@/constants/workspaceTags"; import { log } from "@/node/services/log"; import { discoverAgentDefinitions, @@ -2841,9 +2842,9 @@ export class TaskService { const slot = await ensureParallelSlot(); if (!slot.success) return Err(slot.error); const tags = { - "mux.taskHandleId": handleId, - "mux.taskOwnerWorkspaceId": ownerWorkspaceId, - "mux.taskTurnId": turnId, + [WORKSPACE_TURN_TASK_TAGS.handle]: handleId, + [WORKSPACE_TURN_TASK_TAGS.ownerWorkspaceId]: ownerWorkspaceId, + [WORKSPACE_TURN_TASK_TAGS.turn]: turnId, }; const createResult = await this.workspaceService.create( parentMeta.projectPath, From 2bb01f7c77934be6bc253c58743be13b2548f172 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:45:12 +0000 Subject: [PATCH 5/6] refactor: extract getLatestPhaseEvent helper in workflowProgress --- src/node/services/tools/workflowProgress.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/node/services/tools/workflowProgress.ts b/src/node/services/tools/workflowProgress.ts index 57e30fbc66..5a7e452412 100644 --- a/src/node/services/tools/workflowProgress.ts +++ b/src/node/services/tools/workflowProgress.ts @@ -26,11 +26,16 @@ function getWorkflowStepCounts(steps: ReadonlyArray<{ status: WorkflowStepStatus return counts; } +// Shared so the summary builder and the note formatter agree on what "latest phase" means. +function getLatestPhaseEvent(run: WorkflowRunRecord) { + return run.events.findLast((event) => event.type === "phase"); +} + export function buildWorkflowProgressSummary(run: WorkflowRunRecord) { assert(run.workflow.name.length > 0, "buildWorkflowProgressSummary: workflow name is required"); const progressEvents = run.events.filter(isWorkflowProgressEvent); - const latestPhase = run.events.findLast((event) => event.type === "phase"); + const latestPhase = getLatestPhaseEvent(run); const latestProgressEvent = progressEvents.at(-1); if (latestPhase == null && progressEvents.length === 0 && run.steps.length === 0) { @@ -55,7 +60,7 @@ export function buildWorkflowProgressSummary(run: WorkflowRunRecord) { export function formatWorkflowProgressNote(baseNote: string, run: WorkflowRunRecord): string { assert(baseNote.length > 0, "formatWorkflowProgressNote: base note is required"); - const latestPhase = run.events.findLast((event) => event.type === "phase"); + const latestPhase = getLatestPhaseEvent(run); if (latestPhase == null) { return baseNote; } From 8601340e7226487217589e3e348e6391bd17a924 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:46:36 +0000 Subject: [PATCH 6/6] refactor: map workflow status icons via exhaustive Record --- .../RightSidebar/Workflows/WorkflowBadges.tsx | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/browser/features/RightSidebar/Workflows/WorkflowBadges.tsx b/src/browser/features/RightSidebar/Workflows/WorkflowBadges.tsx index 59cf23369e..7f450fc878 100644 --- a/src/browser/features/RightSidebar/Workflows/WorkflowBadges.tsx +++ b/src/browser/features/RightSidebar/Workflows/WorkflowBadges.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Check, Circle, Pause, X } from "lucide-react"; +import { Check, Circle, Pause, X, type LucideIcon } from "lucide-react"; import type { WorkflowRunStatus, WorkflowScriptScope } from "@/common/types/workflow"; import { WORKFLOW_STATUS_META, WORKFLOW_TONE_VAR, type WorkflowTone } from "./workflowDisplay"; @@ -13,17 +13,20 @@ export const WorkflowLiveDot: React.FC<{ tone?: WorkflowTone; className?: string /> ); +// Status → glyph. Exhaustive over WorkflowRunStatus (via Record) so adding a run status +// is a compile error here rather than a silent fall-through to the default icon. +const WORKFLOW_STATUS_ICON: Record = { + pending: Circle, + running: Circle, + backgrounded: Pause, + interrupted: Pause, + completed: Check, + failed: X, +}; + const WorkflowStatusIcon: React.FC<{ status: WorkflowRunStatus; color: string }> = (props) => { - if (props.status === "completed") { - return ; - } - if (props.status === "failed") { - return ; - } - if (props.status === "backgrounded" || props.status === "interrupted") { - return ; - } - return ; + const Icon = WORKFLOW_STATUS_ICON[props.status]; + return ; }; /** Run status pill — colored by status tone; pulses while running. */