Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/browser/features/Tools/TaskToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
}
Expand Down
21 changes: 21 additions & 0 deletions src/constants/workspaceTags.ts
Original file line number Diff line number Diff line change
@@ -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);
48 changes: 32 additions & 16 deletions src/node/services/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2814,9 +2815,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,
Expand Down Expand Up @@ -3907,6 +3908,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 {
Expand Down Expand Up @@ -4131,12 +4152,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(
Expand Down Expand Up @@ -4507,13 +4526,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)));
});
Expand Down
62 changes: 26 additions & 36 deletions src/node/services/workflows/WorkflowTaskServiceAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> | void;
}
Expand Down
4 changes: 2 additions & 2 deletions src/node/services/workflows/workflowScriptResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
Loading