From 9cf8da8b6ce5596b20f07d40da306aff9874b06e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 11:37:32 +0000 Subject: [PATCH 01/17] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20workspace=20?= =?UTF-8?q?turn=20task=20handles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launch normal full-workspace turns through task(kind="workspace") with durable workspace-turn handles, owner-scoped existing-workspace reprompts, await/list/terminate support, stream correlation, foreground waiter hardening, and disposable workspace cleanup. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$93.61`_ --- docs/hooks/tools.mdx | 36 +- src/common/types/message.ts | 9 + src/common/types/workspaceTurn.ts | 22 + .../utils/tools/toolDefinitions.test.ts | 56 ++ src/common/utils/tools/toolDefinitions.ts | 68 ++ .../builtInSkillContent.generated.ts | 36 +- src/node/services/aiService.test.ts | 40 + src/node/services/aiService.ts | 5 +- src/node/services/taskHandleStore.test.ts | 60 ++ src/node/services/taskHandleStore.ts | 240 +++++ src/node/services/taskService.test.ts | 856 ++++++++++++++++- src/node/services/taskService.ts | 869 +++++++++++++++++- src/node/services/tools/task.test.ts | 57 ++ src/node/services/tools/task.ts | 103 ++- src/node/services/tools/task_await.test.ts | 135 +++ src/node/services/tools/task_await.ts | 167 +++- src/node/services/tools/task_list.test.ts | 50 + src/node/services/tools/task_list.ts | 32 + .../services/tools/task_terminate.test.ts | 33 + src/node/services/tools/task_terminate.ts | 17 + 20 files changed, 2827 insertions(+), 64 deletions(-) create mode 100644 src/common/types/workspaceTurn.ts create mode 100644 src/node/services/taskHandleStore.test.ts create mode 100644 src/node/services/taskHandleStore.ts diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx index 2257b7bf0f..6864c2be0e 100644 --- a/docs/hooks/tools.mdx +++ b/docs/hooks/tools.mdx @@ -645,21 +645,27 @@ If a value is too large for the environment, it may be omitted (not set). Mux al
-task (11) - -| Env var | JSON path | Type | Description | -| ---------------------------------- | ------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MUX_TOOL_INPUT_AGENT_ID` | `agentId` | string | — | -| `MUX_TOOL_INPUT_ISOLATION` | `isolation` | enum | Workspace isolation for the sub-agent. "fork" (the default) runs it in an isolated copy of this workspace created from committed state. "none" runs it directly in this workspace's checkout, sharing the working tree (including uncommitted changes) and skipping the fork + init overhead. Use "none" only for read-only analysis (e.g. the explore agent) or when you instruct the sub-agent to avoid editing shared files, since it can otherwise modify the same files concurrently. Omit to fork. | -| `MUX_TOOL_INPUT_MODEL` | `model` | string | Optional model override for the sub-agent, parsed with the same alias logic as the UI (an alias or a full 'provider:model' string). Omit this unless the user explicitly instructed a specific model — by default the sub-agent inherits the parent's model. Do not assume any particular model is available. | -| `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_SUBAGENT_TYPE` | `subagent_type` | string | — | -| `MUX_TOOL_INPUT_THINKING` | `thinking` | string | Optional thinking/reasoning-level override for the sub-agent. Accepts a level name (off, low, medium, high, xhigh, max) or a numeric index (resolved against the chosen model). Omit this unless the user explicitly instructed a specific thinking level — by default the sub-agent inherits the parent's thinking level. | -| `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. | -| `MUX_TOOL_INPUT_VARIANTS_COUNT` | `variants.length` | number | Number of elements in variants (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.) | +task (17) + +| Env var | JSON path | Type | Description | +| --------------------------------------- | ----------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MUX_TOOL_INPUT_AGENT_ID` | `agentId` | string | — | +| `MUX_TOOL_INPUT_ISOLATION` | `isolation` | enum | Workspace isolation for the sub-agent. "fork" (the default) runs it in an isolated copy of this workspace created from committed state. "none" runs it directly in this workspace's checkout, sharing the working tree (including uncommitted changes) and skipping the fork + init overhead. Use "none" only for read-only analysis (e.g. the explore agent) or when you instruct the sub-agent to avoid editing shared files, since it can otherwise modify the same files concurrently. Omit to fork. | +| `MUX_TOOL_INPUT_KIND` | `kind` | enum | Task kind. Omit or use "subagent" for the existing child-workspace sub-agent flow; use "workspace" to start a normal full workspace turn. | +| `MUX_TOOL_INPUT_MODEL` | `model` | string | Optional model override for the sub-agent, parsed with the same alias logic as the UI (an alias or a full 'provider:model' string). Omit this unless the user explicitly instructed a specific model — by default the sub-agent inherits the parent's model. Do not assume any particular model is available. | +| `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_SUBAGENT_TYPE` | `subagent_type` | string | — | +| `MUX_TOOL_INPUT_THINKING` | `thinking` | string | Optional thinking/reasoning-level override for the sub-agent. Accepts a level name (off, low, medium, high, xhigh, max) or a numeric index (resolved against the chosen model). Omit this unless the user explicitly instructed a specific thinking level — by default the sub-agent inherits the parent's thinking level. | +| `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. | +| `MUX_TOOL_INPUT_VARIANTS_COUNT` | `variants.length` | number | Number of elements in variants (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.) | +| `MUX_TOOL_INPUT_WORKSPACE_BRANCH_NAME` | `workspace.branchName` | string | — | +| `MUX_TOOL_INPUT_WORKSPACE_DISPOSABLE` | `workspace.disposable` | boolean | — | +| `MUX_TOOL_INPUT_WORKSPACE_MODE` | `workspace.mode` | enum | — | +| `MUX_TOOL_INPUT_WORKSPACE_TRUNK_BRANCH` | `workspace.trunkBranch` | string | — | +| `MUX_TOOL_INPUT_WORKSPACE_WORKSPACE_ID` | `workspace.workspaceId` | string | — |
diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 14615f6d45..38f4fae8c5 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -404,6 +404,15 @@ export type MuxMessageMetadata = MuxMessageMetadataBase & rawCommand: string; runId: string; } + | { + // Internal correlation marker for full-workspace turns launched through task(kind="workspace"). + // The workspace remains a normal workspace; this metadata only lets TaskService correlate the + // final assistant stream-end/error back to the durable workspace-turn handle. + type: "workspace-turn-task"; + taskHandleId: string; + ownerWorkspaceId: string; + turnId: string; + } | { // /btw — user-side marker for a side question. // diff --git a/src/common/types/workspaceTurn.ts b/src/common/types/workspaceTurn.ts new file mode 100644 index 0000000000..53c86c5578 --- /dev/null +++ b/src/common/types/workspaceTurn.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const WorkspaceTurnFinalMessageRefSchema = z + .object({ + messageId: z.string().min(1), + model: z.string().optional(), + agentId: z.string().optional(), + finishReason: z.string().optional(), + usageSummary: z + .object({ + inputTokens: z.number().optional(), + outputTokens: z.number().optional(), + totalTokens: z.number().optional(), + }) + .strict() + .optional(), + partCount: z.number().int().min(0).optional(), + textCharCount: z.number().int().min(0).optional(), + }) + .strict(); + +export type WorkspaceTurnFinalMessageRef = z.infer; diff --git a/src/common/utils/tools/toolDefinitions.test.ts b/src/common/utils/tools/toolDefinitions.test.ts index 89f55d147e..a9daee1538 100644 --- a/src/common/utils/tools/toolDefinitions.test.ts +++ b/src/common/utils/tools/toolDefinitions.test.ts @@ -114,6 +114,62 @@ describe("TOOL_DEFINITIONS", () => { ).toBe(false); }); + it("accepts workspace task args without an agent id", () => { + const parsed = TaskToolArgsSchema.safeParse({ + kind: "workspace", + prompt: "Summarize this repository", + title: "Repository summary", + run_in_background: true, + }); + + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.kind).toBe("workspace"); + expect(parsed.data.agentId).toBeUndefined(); + expect(parsed.data.subagent_type).toBeUndefined(); + } + }); + + it("rejects workspace task fanout until workspace handles support it", () => { + expect( + TaskToolArgsSchema.safeParse({ + kind: "workspace", + prompt: "Summarize this repository", + title: "Repository summary", + n: 2, + }).success + ).toBe(false); + + expect( + TaskToolArgsSchema.safeParse({ + kind: "workspace", + prompt: "Summarize ${variant}", + title: "Repository summary", + variants: ["frontend", "backend"], + }).success + ).toBe(false); + }); + + it("requires workspaceId for existing workspace task targets", () => { + expect( + TaskToolArgsSchema.safeParse({ + kind: "workspace", + prompt: "Continue in that workspace", + title: "Follow-up", + workspace: { mode: "existing" }, + }).success + ).toBe(false); + + expect( + TaskToolArgsSchema.safeParse({ + kind: "workspace", + prompt: "Continue in that workspace", + title: "Follow-up", + workspace: { mode: "existing", workspaceId: "child-workspace" }, + }).success + ).toBe(true); + }); + it("accepts bash tool calls using command (alias for script)", () => { const parsed = TOOL_DEFINITIONS.bash.schema.safeParse({ command: "ls", diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index dd2926588a..71f4d140f4 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -63,6 +63,7 @@ import { THINKING_LEVELS } from "@/common/types/thinking"; import { zodToJsonSchema } from "zod-to-json-schema"; import { extractToolFilePath } from "@/common/utils/tools/toolInputFilePath"; import { TASK_VARIANT_PLACEHOLDER, TASK_GROUP_KIND_VALUES } from "@/common/utils/tools/taskGroups"; +import { WorkspaceTurnFinalMessageRefSchema } from "@/common/types/workspaceTurn"; import { HEARTBEAT_CONTEXT_MODE_VALUES, @@ -333,20 +334,60 @@ export function buildTaskToolDescription(runtimeMode: RuntimeMode | undefined): ); } +const WorkspaceTaskKindSchema = z.enum(["subagent", "workspace"]); +const WorkspaceTaskModeSchema = z.enum(["new", "fork", "existing"]); +const WorkspaceTaskTargetSchema = z + .object({ + mode: WorkspaceTaskModeSchema.nullish(), + workspaceId: z.string().trim().min(1).nullish(), + branchName: z.string().trim().min(1).nullish(), + trunkBranch: z.string().trim().min(1).nullish(), + disposable: z.boolean().nullish(), + }) + .strict(); + /** Shared validation across both task-arg schema variants (with/without `isolation`). */ function refineTaskToolAgentArgs( args: { + kind?: "subagent" | "workspace" | null; agentId?: string | null; subagent_type?: string | null; prompt: string; n?: number | null; variants?: string[] | null; + workspace?: { mode?: "new" | "fork" | "existing" | null; workspaceId?: string | null } | null; }, ctx: z.RefinementCtx ): void { + const kind = args.kind ?? "subagent"; const hasAgentId = typeof args.agentId === "string" && args.agentId.length > 0; const hasSubagentType = typeof args.subagent_type === "string" && args.subagent_type.length > 0; + if (kind === "workspace") { + if (hasAgentId || hasSubagentType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Workspace tasks do not accept agentId or subagent_type", + path: ["agentId"], + }); + } + if (args.n != null || args.variants != null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Workspace tasks do not support n or variants yet", + path: args.n != null ? ["n"] : ["variants"], + }); + } + if ((args.workspace?.mode ?? "new") === "existing" && args.workspace?.workspaceId == null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "workspace.workspaceId is required when workspace.mode is existing", + path: ["workspace", "workspaceId"], + }); + } + return; + } + if (!hasAgentId && !hasSubagentType) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -398,6 +439,9 @@ function refineTaskToolAgentArgs( } const taskToolBaseShape = { + kind: WorkspaceTaskKindSchema.nullish().describe( + 'Task kind. Omit or use "subagent" for the existing child-workspace sub-agent flow; use "workspace" to start a normal full workspace turn.' + ), // Prefer agentId. subagent_type is a deprecated alias for backwards compatibility. agentId: TaskAgentIdSchema.nullish(), subagent_type: SubagentTypeSchema.nullish(), @@ -410,6 +454,9 @@ const taskToolBaseShape = { variants: TaskToolVariantsSchema.nullish().describe( `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 ${TASK_VARIANT_PLACEHOLDER} in the prompt.` ), + workspace: WorkspaceTaskTargetSchema.nullish().describe( + 'Workspace target for kind="workspace". Omit for a new full workspace; use mode="existing" with workspaceId only for workspaces previously created by this caller.' + ), model: TaskToolModelSchema.nullish().describe( "Optional model override for the sub-agent, parsed with the same alias logic as the UI (an alias or a full 'provider:model' string). Omit this unless the user explicitly instructed a specific model — by default the sub-agent inherits the parent's model. Do not assume any particular model is available." ), @@ -446,10 +493,13 @@ export function buildTaskToolAgentArgsSchema(options: { return options.includeIsolation ? TaskToolArgsSchema : TaskToolArgsSchemaWithoutIsolation; } +const TaskHandleKindSchema = z.enum(["agent_task", "workspace_turn"]); const TaskToolSpawnedTaskSchema = z .object({ taskId: z.string(), status: z.enum(["queued", "starting", "running", "completed", "interrupted"]), + handleKind: TaskHandleKindSchema.optional(), + workspaceId: z.string().optional(), groupKind: z.enum(TASK_GROUP_KIND_VALUES).optional(), label: z.string().optional(), }) @@ -463,6 +513,10 @@ const TaskToolCompletedReportSchema = z structuredOutput: z.unknown().optional(), agentId: z.string().optional(), agentType: z.string().optional(), + handleKind: TaskHandleKindSchema.optional(), + workspaceId: z.string().optional(), + messageId: z.string().optional(), + finalMessageRef: WorkspaceTurnFinalMessageRefSchema.optional(), groupKind: z.enum(TASK_GROUP_KIND_VALUES).optional(), label: z.string().optional(), }) @@ -473,6 +527,8 @@ export const TaskToolQueuedResultSchema = z status: z.enum(["queued", "starting", "running"]), taskId: z.string().optional(), taskIds: z.array(z.string()).min(1).optional(), + handleKind: TaskHandleKindSchema.optional(), + workspaceId: z.string().optional(), tasks: z.array(TaskToolSpawnedTaskSchema).min(1).optional(), reports: z.array(TaskToolCompletedReportSchema).min(1).optional(), note: z @@ -505,6 +561,10 @@ export const TaskToolCompletedResultSchema = z structuredOutput: z.unknown().optional(), agentId: z.string().optional(), agentType: z.string().optional(), + handleKind: TaskHandleKindSchema.optional(), + workspaceId: z.string().optional(), + messageId: z.string().optional(), + finalMessageRef: WorkspaceTurnFinalMessageRefSchema.optional(), reports: z.array(TaskToolCompletedReportSchema).min(1).optional(), }) .strict() @@ -674,6 +734,10 @@ export const TaskAwaitToolCompletedResultSchema = z status: z.literal("completed"), taskId: z.string(), reportMarkdown: z.string(), + handleKind: TaskHandleKindSchema.optional(), + workspaceId: z.string().optional(), + messageId: z.string().optional(), + finalMessageRef: WorkspaceTurnFinalMessageRefSchema.optional(), structuredOutput: z.unknown().optional(), title: z.string().optional(), output: z.string().optional(), @@ -695,6 +759,8 @@ export const TaskAwaitToolActiveResultSchema = z "interrupted", ]), taskId: z.string(), + handleKind: TaskHandleKindSchema.optional(), + workspaceId: z.string().optional(), output: z.string().optional(), elapsed_ms: z.number().optional(), note: z.string().optional(), @@ -962,6 +1028,8 @@ export const TaskListToolTaskSchema = z workspaceName: z.string().optional(), title: z.string().optional(), createdAt: z.string().optional(), + handleKind: TaskHandleKindSchema.optional(), + workspaceId: z.string().optional(), modelString: z.string().optional(), thinkingLevel: TaskListThinkingLevelSchema.optional(), depth: z.number().int().min(0), diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index a8afd31938..a659b943ce 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -4572,21 +4572,27 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "", "
", - "task (11)", - "", - "| Env var | JSON path | Type | Description |", - "| ---------------------------------- | ------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |", - "| `MUX_TOOL_INPUT_AGENT_ID` | `agentId` | string | — |", - '| `MUX_TOOL_INPUT_ISOLATION` | `isolation` | enum | Workspace isolation for the sub-agent. "fork" (the default) runs it in an isolated copy of this workspace created from committed state. "none" runs it directly in this workspace\'s checkout, sharing the working tree (including uncommitted changes) and skipping the fork + init overhead. Use "none" only for read-only analysis (e.g. the explore agent) or when you instruct the sub-agent to avoid editing shared files, since it can otherwise modify the same files concurrently. Omit to fork. |', - "| `MUX_TOOL_INPUT_MODEL` | `model` | string | Optional model override for the sub-agent, parsed with the same alias logic as the UI (an alias or a full 'provider:model' string). Omit this unless the user explicitly instructed a specific model — by default the sub-agent inherits the parent's model. Do not assume any particular model is available. |", - "| `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_SUBAGENT_TYPE` | `subagent_type` | string | — |", - "| `MUX_TOOL_INPUT_THINKING` | `thinking` | string | Optional thinking/reasoning-level override for the sub-agent. Accepts a level name (off, low, medium, high, xhigh, max) or a numeric index (resolved against the chosen model). Omit this unless the user explicitly instructed a specific thinking level — by default the sub-agent inherits the parent's thinking level. |", - "| `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. |", - "| `MUX_TOOL_INPUT_VARIANTS_COUNT` | `variants.length` | number | Number of elements in variants (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.) |", + "task (17)", + "", + "| Env var | JSON path | Type | Description |", + "| --------------------------------------- | ----------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |", + "| `MUX_TOOL_INPUT_AGENT_ID` | `agentId` | string | — |", + '| `MUX_TOOL_INPUT_ISOLATION` | `isolation` | enum | Workspace isolation for the sub-agent. "fork" (the default) runs it in an isolated copy of this workspace created from committed state. "none" runs it directly in this workspace\'s checkout, sharing the working tree (including uncommitted changes) and skipping the fork + init overhead. Use "none" only for read-only analysis (e.g. the explore agent) or when you instruct the sub-agent to avoid editing shared files, since it can otherwise modify the same files concurrently. Omit to fork. |', + '| `MUX_TOOL_INPUT_KIND` | `kind` | enum | Task kind. Omit or use "subagent" for the existing child-workspace sub-agent flow; use "workspace" to start a normal full workspace turn. |', + "| `MUX_TOOL_INPUT_MODEL` | `model` | string | Optional model override for the sub-agent, parsed with the same alias logic as the UI (an alias or a full 'provider:model' string). Omit this unless the user explicitly instructed a specific model — by default the sub-agent inherits the parent's model. Do not assume any particular model is available. |", + "| `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_SUBAGENT_TYPE` | `subagent_type` | string | — |", + "| `MUX_TOOL_INPUT_THINKING` | `thinking` | string | Optional thinking/reasoning-level override for the sub-agent. Accepts a level name (off, low, medium, high, xhigh, max) or a numeric index (resolved against the chosen model). Omit this unless the user explicitly instructed a specific thinking level — by default the sub-agent inherits the parent's thinking level. |", + "| `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. |", + "| `MUX_TOOL_INPUT_VARIANTS_COUNT` | `variants.length` | number | Number of elements in variants (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.) |", + "| `MUX_TOOL_INPUT_WORKSPACE_BRANCH_NAME` | `workspace.branchName` | string | — |", + "| `MUX_TOOL_INPUT_WORKSPACE_DISPOSABLE` | `workspace.disposable` | boolean | — |", + "| `MUX_TOOL_INPUT_WORKSPACE_MODE` | `workspace.mode` | enum | — |", + "| `MUX_TOOL_INPUT_WORKSPACE_TRUNK_BRANCH` | `workspace.trunkBranch` | string | — |", + "| `MUX_TOOL_INPUT_WORKSPACE_WORKSPACE_ID` | `workspace.workspaceId` | string | — |", "", "
", "", diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index 68b0490cdd..6bc5aa3265 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -2176,6 +2176,46 @@ describe("AIService.streamMessage compaction boundary slicing", () => { expect(initialMetadata.routeProvider).toBe("openrouter"); }); + it("passes muxMetadata into initial stream metadata", async () => { + using muxHome = new DisposableTempDir("ai-service-mux-metadata"); + const projectPath = path.join(muxHome.path, "project"); + await fs.mkdir(projectPath, { recursive: true }); + + const workspaceId = "workspace-mux-metadata"; + const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); + const harness = createHarness(muxHome.path, metadata); + + const result = await harness.service.streamMessage({ + messages: [createMuxMessage("latest-user", "user", "continue")], + workspaceId, + modelString: "openai:gpt-5.2", + thinkingLevel: "medium", + muxMetadata: { + type: "workspace-turn-task", + taskHandleId: "wst_handle", + ownerWorkspaceId: "owner-workspace", + turnId: "turn-id", + }, + }); + + expect(result.success).toBe(true); + expect(harness.startStreamCalls).toHaveLength(1); + + const startStreamCall = harness.startStreamCalls[0]; + expect(startStreamCall).toBeDefined(); + if (!startStreamCall) { + throw new Error("Expected streamManager.startStream call arguments"); + } + + const initialMetadata = initialMetadataFromStartStreamCall(startStreamCall); + expect(initialMetadata.muxMetadata).toEqual({ + type: "workspace-turn-task", + taskHandleId: "wst_handle", + ownerWorkspaceId: "owner-workspace", + turnId: "turn-id", + }); + }); + it("omits routeProvider from initial stream metadata when unresolved", async () => { using muxHome = new DisposableTempDir("ai-service-route-provider-absent"); const projectPath = path.join(muxHome.path, "project"); diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index c625547240..e14da5d109 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -20,7 +20,7 @@ import { import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import type { GoalRecordV1 } from "@/common/types/goal"; -import type { ModelMessage, MuxMessage } from "@/common/types/message"; +import type { ModelMessage, MuxMessage, MuxMessageMetadata } from "@/common/types/message"; import { createMuxMessage } from "@/common/types/message"; import type { Config } from "@/node/config"; import { StreamManager, type ModelFallbackOptions, type StreamTextOnChunk } from "./streamManager"; @@ -256,6 +256,7 @@ export interface StreamMessageOptions { workspaceGoalService?: WorkspaceGoalService; disableWorkspaceAgents?: boolean; hasQueuedMessage?: () => boolean; + muxMetadata?: MuxMessageMetadata; openaiTruncationModeOverride?: "auto" | "disabled"; } @@ -994,6 +995,7 @@ export class AIService extends EventEmitter { disableWorkspaceAgents, hasQueuedMessage, openaiTruncationModeOverride, + muxMetadata, } = opts; // Support interrupts during startup (before StreamManager emits stream-start). // We register an AbortController up-front and let stopStream() abort it. @@ -2694,6 +2696,7 @@ export class AIService extends EventEmitter { // Preserve the resolved route source so stream events and persisted messages // keep non-gateway attribution even when the model ID itself is gateway-agnostic. ...(routeProvider != null ? { routeProvider } : {}), + ...(muxMetadata !== undefined ? { muxMetadata } : {}), ...(acpPromptId != null ? { acpPromptId } : {}), ...(modelCostsIncluded(modelResult.data.model) ? { costsIncluded: true } : {}), }, diff --git a/src/node/services/taskHandleStore.test.ts b/src/node/services/taskHandleStore.test.ts new file mode 100644 index 0000000000..f8857883da --- /dev/null +++ b/src/node/services/taskHandleStore.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "bun:test"; +import * as fsPromises from "fs/promises"; +import * as os from "os"; +import * as path from "path"; + +import { Config } from "@/node/config"; +import { TaskHandleStore, WORKSPACE_TURN_TASK_ID_PREFIX } from "@/node/services/taskHandleStore"; + +async function createTempConfig(testName: string): Promise<{ config: Config; rootDir: string }> { + const rootDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), `${testName}-`)); + const config = new Config(rootDir); + await fsPromises.mkdir(config.srcDir, { recursive: true }); + return { config, rootDir }; +} + +describe("TaskHandleStore", () => { + it("persists and lists owner-scoped workspace turn handles", async () => { + const { config } = await createTempConfig("task-handle-store-persist"); + const store = new TaskHandleStore(config); + + await store.upsertWorkspaceTurn({ + kind: "workspace_turn", + handleId: `${WORKSPACE_TURN_TASK_ID_PREFIX}abc`, + ownerWorkspaceId: "owner", + workspaceId: "child", + turnId: "turn-1", + status: "running", + createdAt: "2026-06-19T00:00:00.000Z", + updatedAt: "2026-06-19T00:00:00.000Z", + createdWorkspace: true, + disposableWorkspace: false, + title: "Summary", + prompt: "Summarize", + }); + + const record = await store.getWorkspaceTurn("owner", `${WORKSPACE_TURN_TASK_ID_PREFIX}abc`); + expect(record?.workspaceId).toBe("child"); + + expect(await store.getWorkspaceTurn("other", `${WORKSPACE_TURN_TASK_ID_PREFIX}abc`)).toBeNull(); + expect(await store.isWorkspaceOwnedBy("owner", "child")).toBe(true); + expect(await store.isWorkspaceOwnedBy("other", "child")).toBe(false); + + const listed = await store.listWorkspaceTurns("owner", { statuses: ["running"] }); + expect(listed.map((item) => item.handleId)).toEqual([`${WORKSPACE_TURN_TASK_ID_PREFIX}abc`]); + }); + + it("self-heals corrupt handle records by ignoring them", async () => { + const { config } = await createTempConfig("task-handle-store-corrupt"); + const store = new TaskHandleStore(config); + const sessionDir = config.getSessionDir("owner"); + await fsPromises.mkdir(path.join(sessionDir, "task-handles"), { recursive: true }); + await fsPromises.writeFile( + path.join(sessionDir, "task-handles", `${WORKSPACE_TURN_TASK_ID_PREFIX}bad.json`), + "not json" + ); + + expect(await store.getWorkspaceTurn("owner", `${WORKSPACE_TURN_TASK_ID_PREFIX}bad`)).toBeNull(); + expect(await store.listWorkspaceTurns("owner")).toEqual([]); + }); +}); diff --git a/src/node/services/taskHandleStore.ts b/src/node/services/taskHandleStore.ts new file mode 100644 index 0000000000..7f4b868397 --- /dev/null +++ b/src/node/services/taskHandleStore.ts @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; +import * as fsPromises from "node:fs/promises"; +import * as path from "node:path"; + +import { z } from "zod"; + +import type { Config } from "@/node/config"; +import type { CompletedMessagePart, StreamEndEvent } from "@/common/types/stream"; +import type { ParsedThinkingInput, ThinkingLevel } from "@/common/types/thinking"; +import { + WorkspaceTurnFinalMessageRefSchema, + type WorkspaceTurnFinalMessageRef, +} from "@/common/types/workspaceTurn"; +import { log } from "@/node/services/log"; +import { isErrnoWithCode } from "@/node/utils/fs"; + +export type { WorkspaceTurnFinalMessageRef }; + +export const WORKSPACE_TURN_TASK_ID_PREFIX = "wst_"; +const TASK_HANDLES_DIR = "task-handles"; + +export type WorkspaceTurnTaskStatus = + | "queued" + | "starting" + | "running" + | "completed" + | "interrupted" + | "error"; + +export interface WorkspaceTurnTaskHandleRecord { + kind: "workspace_turn"; + handleId: string; + ownerWorkspaceId: string; + workspaceId: string; + turnId: string; + status: WorkspaceTurnTaskStatus; + createdAt: string; + updatedAt: string; + createdWorkspace: boolean; + disposableWorkspace: boolean; + title?: string; + prompt?: string; + modelString?: string; + thinkingLevel?: ParsedThinkingInput | ThinkingLevel; + messageId?: string; + reportMarkdown?: string; + finalMessageRef?: WorkspaceTurnFinalMessageRef; + finalMessage?: { + messageId: string; + parts?: CompletedMessagePart[]; + metadata: StreamEndEvent["metadata"]; + }; + error?: string; +} + +const WorkspaceTurnTaskHandleRecordSchema = z + .object({ + kind: z.literal("workspace_turn"), + handleId: z.string().min(1), + ownerWorkspaceId: z.string().min(1), + workspaceId: z.string().min(1), + turnId: z.string().min(1), + status: z.enum(["queued", "starting", "running", "completed", "interrupted", "error"]), + createdAt: z.string().min(1), + updatedAt: z.string().min(1), + createdWorkspace: z.boolean(), + disposableWorkspace: z.boolean(), + title: z.string().optional(), + prompt: z.string().optional(), + modelString: z.string().optional(), + thinkingLevel: z.unknown().optional(), + messageId: z.string().optional(), + reportMarkdown: z.string().optional(), + finalMessageRef: WorkspaceTurnFinalMessageRefSchema.optional(), + finalMessage: z + .object({ + messageId: z.string().min(1), + parts: z.array(z.unknown()).optional(), + metadata: z.unknown(), + }) + .passthrough() + .optional(), + error: z.string().optional(), + }) + .strict(); + +export function isWorkspaceTurnTaskId( + value: unknown +): value is `${typeof WORKSPACE_TURN_TASK_ID_PREFIX}${string}` { + return typeof value === "string" && value.startsWith(WORKSPACE_TURN_TASK_ID_PREFIX); +} + +export class TaskHandleStore { + constructor(private readonly config: Config) {} + + async upsertWorkspaceTurn(record: WorkspaceTurnTaskHandleRecord): Promise { + this.assertValidRecord(record); + const dir = this.getOwnerHandleDir(record.ownerWorkspaceId); + await fsPromises.mkdir(dir, { recursive: true }); + await fsPromises.writeFile( + this.getHandlePath(record.ownerWorkspaceId, record.handleId), + JSON.stringify(record, null, 2) + ); + } + + async updateWorkspaceTurn( + ownerWorkspaceId: string, + handleId: string, + mutator: (record: WorkspaceTurnTaskHandleRecord) => WorkspaceTurnTaskHandleRecord + ): Promise { + assert(ownerWorkspaceId.trim().length > 0, "updateWorkspaceTurn requires ownerWorkspaceId"); + assert(handleId.trim().length > 0, "updateWorkspaceTurn requires handleId"); + const current = await this.getWorkspaceTurn(ownerWorkspaceId, handleId); + if (current == null) { + return null; + } + const next = mutator(current); + await this.upsertWorkspaceTurn(next); + return next; + } + + async getWorkspaceTurn( + ownerWorkspaceId: string, + handleId: string + ): Promise { + assert(ownerWorkspaceId.trim().length > 0, "getWorkspaceTurn requires ownerWorkspaceId"); + assert(handleId.trim().length > 0, "getWorkspaceTurn requires handleId"); + const record = await this.readWorkspaceTurnFile(ownerWorkspaceId, handleId); + return record?.ownerWorkspaceId === ownerWorkspaceId ? record : null; + } + + async listWorkspaceTurns( + ownerWorkspaceId: string, + options: { statuses?: readonly WorkspaceTurnTaskStatus[] } = {} + ): Promise { + assert(ownerWorkspaceId.trim().length > 0, "listWorkspaceTurns requires ownerWorkspaceId"); + const dir = this.getOwnerHandleDir(ownerWorkspaceId); + let entries: string[]; + try { + entries = await fsPromises.readdir(dir); + } catch (error) { + if (isErrnoWithCode(error, "ENOENT")) return []; + throw error; + } + + const statuses = options.statuses != null ? new Set(options.statuses) : null; + const records = await Promise.all( + entries + .filter((entry) => entry.endsWith(".json")) + .map((entry) => + this.readWorkspaceTurnFile(ownerWorkspaceId, entry.slice(0, -".json".length)) + ) + ); + return records + .filter((record): record is WorkspaceTurnTaskHandleRecord => { + if (record == null) return false; + return statuses == null || statuses.has(record.status); + }) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + } + + async listAllWorkspaceTurns( + options: { statuses?: readonly WorkspaceTurnTaskStatus[] } = {} + ): Promise { + let entries: Array<{ isDirectory: () => boolean; name: string }>; + try { + entries = await fsPromises.readdir(this.config.sessionsDir, { withFileTypes: true }); + } catch (error) { + if (isErrnoWithCode(error, "ENOENT")) return []; + throw error; + } + + const recordsByOwner = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => this.listWorkspaceTurns(entry.name, options)) + ); + return recordsByOwner.flat().sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + } + + async isWorkspaceOwnedBy(ownerWorkspaceId: string, workspaceId: string): Promise { + assert(ownerWorkspaceId.trim().length > 0, "isWorkspaceOwnedBy requires ownerWorkspaceId"); + assert(workspaceId.trim().length > 0, "isWorkspaceOwnedBy requires workspaceId"); + const records = await this.listWorkspaceTurns(ownerWorkspaceId); + return records.some((record) => record.createdWorkspace && record.workspaceId === workspaceId); + } + + private getOwnerHandleDir(ownerWorkspaceId: string): string { + assert(ownerWorkspaceId.trim().length > 0, "ownerWorkspaceId must be non-empty"); + return path.join(this.config.getSessionDir(ownerWorkspaceId), TASK_HANDLES_DIR); + } + + private getHandlePath(ownerWorkspaceId: string, handleId: string): string { + assert(handleId.trim().length > 0, "handleId must be non-empty"); + return path.join(this.getOwnerHandleDir(ownerWorkspaceId), `${handleId}.json`); + } + + private assertValidRecord(record: WorkspaceTurnTaskHandleRecord): void { + const parsed = WorkspaceTurnTaskHandleRecordSchema.safeParse(record); + assert( + parsed.success, + `Invalid workspace turn handle record: ${parsed.success ? "" : parsed.error.message}` + ); + assert( + record.handleId.startsWith(WORKSPACE_TURN_TASK_ID_PREFIX), + "workspace turn handle IDs must use the wst_ prefix" + ); + } + + private async readWorkspaceTurnFile( + ownerWorkspaceId: string, + handleId: string + ): Promise { + try { + const raw = await fsPromises.readFile( + this.getHandlePath(ownerWorkspaceId, handleId), + "utf-8" + ); + const parsedJson = JSON.parse(raw) as unknown; + const parsed = WorkspaceTurnTaskHandleRecordSchema.safeParse(parsedJson); + if (!parsed.success) { + log.warn("Ignoring unreadable workspace turn task handle", { + ownerWorkspaceId, + handleId, + issues: parsed.error.issues, + }); + return null; + } + return parsed.data as WorkspaceTurnTaskHandleRecord; + } catch (error) { + if (isErrnoWithCode(error, "ENOENT") || error instanceof SyntaxError) { + if (error instanceof SyntaxError) { + log.warn("Ignoring corrupt workspace turn task handle", { ownerWorkspaceId, handleId }); + } + return null; + } + throw error; + } + } +} diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 4ab5e32102..cf032bd844 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -42,8 +42,10 @@ import * as forkOrchestrator from "@/node/services/utils/forkOrchestrator"; import { Ok, Err, type Result } from "@/common/types/result"; import { defaultModel } from "@/common/utils/ai/models"; import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; +import { DEFAULT_TASK_SETTINGS } from "@/common/types/tasks"; import type { ThinkingLevel } from "@/common/types/thinking"; -import type { ErrorEvent, StreamEndEvent } from "@/common/types/stream"; +import type { SendMessageError } from "@/common/types/errors"; +import type { ErrorEvent, StreamAbortEvent, StreamEndEvent } from "@/common/types/stream"; import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import { isDynamicToolPart, type DynamicToolPart } from "@/common/types/toolParts"; import { @@ -83,6 +85,18 @@ function findWorkspaceInConfig(config: Config, workspaceId: string) { .find((workspace) => workspace.id === workspaceId); } +function createWorkspaceTurnMetadata(projectPath: string): WorkspaceMetadata { + return { + id: "childworkspace", + name: "workspace-turn", + title: "Workspace turn", + projectName: "repo", + projectPath, + runtimeConfig: { type: "local" }, + createdAt: "2026-06-19T00:00:00.000Z", + }; +} + async function workspaceGoalFileExists(config: Config, workspaceId: string): Promise { try { await fsPromises.access(path.join(config.getSessionDir(workspaceId), "goal.json")); @@ -357,6 +371,7 @@ function createWorkspaceServiceMocks( isExperimentEnabled: ReturnType; emitChatEvent: ReturnType; isWorkflowInvocationCurrent: ReturnType; + create: ReturnType; }> ): { workspaceService: WorkspaceService; @@ -372,6 +387,7 @@ function createWorkspaceServiceMocks( isExperimentEnabled: ReturnType; emitChatEvent: ReturnType; isWorkflowInvocationCurrent: ReturnType; + create: ReturnType; } { const sendMessage = overrides?.sendMessage ?? mock((): Promise> => Promise.resolve(Ok(undefined))); @@ -394,8 +410,16 @@ function createWorkspaceServiceMocks( const isWorkflowInvocationCurrent = overrides?.isWorkflowInvocationCurrent ?? mock(() => Promise.resolve(true)); + const create = + overrides?.create ?? + mock( + (): Promise> => + Promise.resolve(Err("workspaceService.create not mocked")) + ); + return { workspaceService: { + create, sendMessage, resumeStream, clearQueue, @@ -409,6 +433,7 @@ function createWorkspaceServiceMocks( emitChatEvent, isWorkflowInvocationCurrent, } as unknown as WorkspaceService, + create, sendMessage, resumeStream, clearQueue, @@ -481,6 +506,835 @@ describe("TaskService", () => { await fsPromises.rm(rootDir, { recursive: true, force: true }); }); + async function startWorkspaceTurnForTest( + options: { + stableIds?: string[]; + disposable?: boolean; + sendMessage?: ReturnType; + remove?: ReturnType; + isStreaming?: ReturnType; + } = {} + ) { + const config = await createTestConfig(rootDir); + stubStableIds(config, options.stableIds ?? ["handle", "turn"]); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + + const createWorkspace = mock( + async (...args: unknown[]): Promise> => { + const tags = args[7] as Record | undefined; + await config.editConfig((cfg) => { + const project = cfg.projects.get(projectPath); + assert(project, "test project must exist"); + project.workspaces.push({ + path: path.join(projectPath, "workspace-turn"), + id: "childworkspace", + name: "workspace-turn", + title: "Workspace turn", + createdAt: "2026-06-19T00:00:00.000Z", + runtimeConfig: { type: "local" }, + tags, + }); + return cfg; + }); + return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); + } + ); + const workspaceMocks = createWorkspaceServiceMocks({ + create: createWorkspace, + ...(options.sendMessage != null ? { sendMessage: options.sendMessage } : {}), + ...(options.remove != null ? { remove: options.remove } : {}), + }); + const aiMocks = createAIServiceMocks(config, { + ...(options.isStreaming != null ? { isStreaming: options.isStreaming } : {}), + }); + const { taskService } = createTaskServiceHarness(config, { + aiService: aiMocks.aiService, + workspaceService: workspaceMocks.workspaceService, + }); + + const created = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: parentId, + prompt: "Summarize", + title: "Workspace turn", + workspace: { mode: "new", ...(options.disposable === true ? { disposable: true } : {}) }, + }); + expect(created.success).toBe(true); + if (!created.success) { + throw new Error(created.error); + } + + return { + config, + parentId, + projectPath, + taskService, + workspaceMocks, + aiMocks, + created: created.data, + }; + } + + test("createWorkspaceTurn creates a normal workspace and starts a correlated turn", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["childworkspace", "turnhandle"]); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + + const createWorkspace = mock( + async (...args: unknown[]): Promise> => { + const tags = args[7] as Record | undefined; + await config.editConfig((cfg) => { + const project = cfg.projects.get(projectPath); + assert(project, "test project must exist"); + project.workspaces.push({ + path: path.join(projectPath, "workspace-turn"), + id: "childworkspace", + name: "workspace-turn", + title: "Workspace turn", + createdAt: "2026-06-19T00:00:00.000Z", + runtimeConfig: { type: "local" }, + tags, + }); + return cfg; + }); + return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); + } + ); + const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); + const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); + const { taskService } = createTaskServiceHarness(config, { + workspaceService: workspaceMocks.workspaceService, + }); + + const result = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: parentId, + prompt: "Summarize the repo", + title: "Workspace turn", + workspace: { mode: "new" }, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toMatchObject({ + taskId: "wst_childworkspace", + workspaceId: "childworkspace", + kind: "workspace_turn", + status: "running", + }); + const childConfig = findWorkspaceInConfig(config, "childworkspace"); + expect(childConfig?.parentWorkspaceId).toBeUndefined(); + expect(childConfig?.taskStatus).toBeUndefined(); + expect(childConfig?.tags).toMatchObject({ + "mux.taskHandleId": "wst_childworkspace", + "mux.taskOwnerWorkspaceId": parentId, + }); + expect(sendMessage).toHaveBeenCalledTimes(1); + const sendMessageCall = sendMessage.mock.calls[0] as unknown[]; + expect(sendMessageCall[0]).toBe("childworkspace"); + expect(sendMessageCall[1]).toBe("Summarize the repo"); + expect(sendMessageCall[2]).toMatchObject({ agentId: "exec" }); + expect(sendMessageCall[3]).toMatchObject({ + startStreamInBackground: true, + requireIdle: true, + agentInitiated: true, + }); + }); + + test("createWorkspaceTurn marks accepted pre-stream failures as handle errors", async () => { + const sendMessage = mock( + async (...args: unknown[]): Promise> => { + const internal = args[3] as + | { onAcceptedPreStreamFailure?: (error: SendMessageError) => Promise | void } + | undefined; + await internal?.onAcceptedPreStreamFailure?.({ + type: "unknown", + raw: "Runtime startup failed", + }); + return Ok(undefined); + } + ); + const { parentId, taskService } = await startWorkspaceTurnForTest({ sendMessage }); + + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); + expect(snapshot).toMatchObject({ + status: "error", + error: "Runtime startup failed", + workspaceId: "childworkspace", + }); + }); + + test("createWorkspaceTurn reprompts only owner-created existing workspaces", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["firsthandle", "firstturn", "secondhandle", "secondturn"]); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + + const createWorkspace = mock( + async (...args: unknown[]): Promise> => { + const tags = args[7] as Record | undefined; + await config.editConfig((cfg) => { + const project = cfg.projects.get(projectPath); + assert(project, "test project must exist"); + project.workspaces.push({ + path: path.join(projectPath, "workspace-turn"), + id: "childworkspace", + name: "workspace-turn", + title: "Workspace turn", + createdAt: "2026-06-19T00:00:00.000Z", + runtimeConfig: { type: "local" }, + tags, + }); + return cfg; + }); + return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); + } + ); + const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); + const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); + const { taskService } = createTaskServiceHarness(config, { + workspaceService: workspaceMocks.workspaceService, + }); + + const first = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: parentId, + prompt: "First prompt", + title: "Workspace turn", + workspace: { mode: "new" }, + }); + expect(first.success).toBe(true); + + const taskHandleStore = ( + taskService as unknown as { + taskHandleStore: { + listAllWorkspaceTurns: (options?: { statuses?: readonly string[] }) => Promise; + }; + } + ).taskHandleStore; + const listAllWorkspaceTurns = spyOn(taskHandleStore, "listAllWorkspaceTurns"); + + const second = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: parentId, + prompt: "Second prompt", + title: "Follow-up", + workspace: { mode: "existing", workspaceId: "childworkspace" }, + }); + + expect(second.success).toBe(true); + if (!second.success) return; + expect(second.data).toMatchObject({ + taskId: "wst_secondhandle", + workspaceId: "childworkspace", + kind: "workspace_turn", + status: "running", + }); + expect(createWorkspace).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledTimes(2); + const secondSend = sendMessage.mock.calls[1] as unknown[]; + expect(secondSend[0]).toBe("childworkspace"); + expect(secondSend[1]).toBe("Second prompt"); + const secondSnapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_secondhandle"); + expect(secondSnapshot).toMatchObject({ + createdWorkspace: false, + workspaceId: "childworkspace", + status: "running", + }); + expect(listAllWorkspaceTurns).toHaveBeenCalledTimes(1); + listAllWorkspaceTurns.mockRestore(); + + await config.editConfig((cfg) => { + const project = cfg.projects.get(projectPath); + assert(project, "test project must exist"); + project.workspaces.push({ + path: path.join(projectPath, "other-parent"), + id: "other-parent", + name: "other-parent", + createdAt: "2026-06-19T00:00:00.000Z", + runtimeConfig: { type: "local" }, + }); + return cfg; + }); + const foreign = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: "other-parent", + prompt: "Should not run", + title: "Foreign", + workspace: { mode: "existing", workspaceId: "childworkspace" }, + }); + expect(foreign.success).toBe(false); + if (foreign.success) return; + expect(foreign.error).toContain("invalid_scope"); + expect(sendMessage).toHaveBeenCalledTimes(2); + }); + + test("createWorkspaceTurn counts active workspace turns across all owners", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["firsthandle", "firstturn", "secondhandle", "secondturn"]); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + const otherParentId = "other-parent"; + await config.editConfig((cfg) => { + cfg.taskSettings = { ...DEFAULT_TASK_SETTINGS, maxParallelAgentTasks: 1 }; + const project = cfg.projects.get(projectPath); + assert(project, "test project must exist"); + project.workspaces.push({ + path: path.join(projectPath, otherParentId), + id: otherParentId, + name: otherParentId, + createdAt: "2026-06-19T00:00:00.000Z", + runtimeConfig: { type: "local" }, + }); + return cfg; + }); + + const createWorkspace = mock( + async (...args: unknown[]): Promise> => { + const tags = args[7] as Record | undefined; + await config.editConfig((cfg) => { + const project = cfg.projects.get(projectPath); + assert(project, "test project must exist"); + project.workspaces.push({ + path: path.join(projectPath, "workspace-turn"), + id: "childworkspace", + name: "workspace-turn", + title: "Workspace turn", + createdAt: "2026-06-19T00:00:00.000Z", + runtimeConfig: { type: "local" }, + tags, + }); + return cfg; + }); + return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); + } + ); + const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); + const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); + const { taskService } = createTaskServiceHarness(config, { + workspaceService: workspaceMocks.workspaceService, + }); + + const first = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: parentId, + prompt: "First prompt", + title: "Workspace turn", + workspace: { mode: "new" }, + }); + expect(first.success).toBe(true); + + const second = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: otherParentId, + prompt: "Second prompt", + title: "Other workspace turn", + workspace: { mode: "new" }, + }); + expect(second.success).toBe(false); + if (second.success) return; + expect(second.error).toContain("maxParallelAgentTasks exceeded"); + expect(createWorkspace).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + + test("active workspace turn count excludes foreground-waiting workspace turns", async () => { + const { taskService } = await startWorkspaceTurnForTest(); + const internal = taskService as unknown as { + countActiveWorkspaceTurns: () => Promise; + startForegroundAwait: (workspaceId: string) => () => void; + }; + + expect(await internal.countActiveWorkspaceTurns()).toBe(1); + const stopForegroundAwait = internal.startForegroundAwait("childworkspace"); + try { + expect(await internal.countActiveWorkspaceTurns()).toBe(0); + } finally { + stopForegroundAwait(); + } + }); + + test("active workspace turn count settles stale persisted handles", async () => { + const { parentId, taskService } = await startWorkspaceTurnForTest(); + const internal = taskService as unknown as { + activeWorkspaceTurnHandleByWorkspaceId: Map< + string, + { handleId: string; ownerWorkspaceId: string } + >; + countActiveWorkspaceTurns: () => Promise; + }; + + internal.activeWorkspaceTurnHandleByWorkspaceId.clear(); + expect(await internal.countActiveWorkspaceTurns()).toBe(0); + + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); + expect(snapshot).toMatchObject({ + status: "interrupted", + error: "Workspace turn interrupted after restart", + workspaceId: "childworkspace", + }); + }); + + test("listWorkspaceTurnTasks settles stale active handles before returning", async () => { + const { parentId, taskService } = await startWorkspaceTurnForTest(); + const internal = taskService as unknown as { + activeWorkspaceTurnHandleByWorkspaceId: Map< + string, + { handleId: string; ownerWorkspaceId: string } + >; + }; + + internal.activeWorkspaceTurnHandleByWorkspaceId.clear(); + expect(await taskService.listWorkspaceTurnTasks(parentId, { statuses: ["running"] })).toEqual( + [] + ); + + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); + expect(snapshot).toMatchObject({ status: "interrupted", workspaceId: "childworkspace" }); + }); + + test("workspace-turn stream-end finalizes the handle without agent_report semantics", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["handle", "turn"]); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + + const createWorkspace = mock( + async (...args: unknown[]): Promise> => { + const tags = args[7] as Record | undefined; + await config.editConfig((cfg) => { + const project = cfg.projects.get(projectPath); + assert(project, "test project must exist"); + project.workspaces.push({ + path: path.join(projectPath, "workspace-turn"), + id: "childworkspace", + name: "workspace-turn", + title: "Workspace turn", + createdAt: "2026-06-19T00:00:00.000Z", + runtimeConfig: { type: "local" }, + tags, + }); + return cfg; + }); + return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); + } + ); + const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); + const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); + const { taskService } = createTaskServiceHarness(config, { + workspaceService: workspaceMocks.workspaceService, + }); + + const created = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: parentId, + prompt: "Summarize", + title: "Workspace turn", + workspace: { mode: "new" }, + }); + expect(created.success).toBe(true); + + const internal = taskService as unknown as { + handleStreamEnd: (event: StreamEndEvent) => Promise; + }; + await internal.handleStreamEnd({ + type: "stream-end", + workspaceId: "childworkspace", + messageId: "msg_1", + metadata: { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "stop", + muxMetadata: { + type: "workspace-turn-task", + taskHandleId: "wst_handle", + ownerWorkspaceId: parentId, + turnId: "turn", + }, + }, + parts: [{ type: "text", text: "Done" }], + }); + + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); + expect(snapshot).toMatchObject({ + status: "completed", + workspaceId: "childworkspace", + messageId: "msg_1", + reportMarkdown: "Done", + finalMessageRef: { messageId: "msg_1", agentId: "exec", textCharCount: 4 }, + }); + const childConfig = findWorkspaceInConfig(config, "childworkspace"); + expect(childConfig?.parentWorkspaceId).toBeUndefined(); + expect(childConfig?.taskStatus).toBeUndefined(); + }); + + test("parent stream-end auto-resumes for active background workspace turns", async () => { + const { parentId, taskService, workspaceMocks } = await startWorkspaceTurnForTest(); + const internal = taskService as unknown as { + handleStreamEnd: (event: StreamEndEvent) => Promise; + }; + + await internal.handleStreamEnd({ + type: "stream-end", + workspaceId: parentId, + messageId: "parent_msg_1", + metadata: { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "stop", + }, + parts: [{ type: "text", text: "Parent done" }], + }); + + expect(workspaceMocks.sendMessage).toHaveBeenCalledTimes(2); + expect(workspaceMocks.sendMessage.mock.calls[1]?.[0]).toBe(parentId); + expect(workspaceMocks.sendMessage.mock.calls[1]?.[1]).toContain("wst_handle"); + }); + + test("workspace-turn stream-end waits for active descendants before finalizing", async () => { + const { config, parentId, projectPath, taskService, workspaceMocks } = + await startWorkspaceTurnForTest(); + await config.editConfig((cfg) => { + const project = Array.from(cfg.projects.values())[0]; + assert(project, "test project must exist"); + project.workspaces.push({ + path: path.join(projectPath, "descendant-task"), + id: "descendant-task", + name: "descendant-task", + createdAt: "2026-06-19T00:00:00.000Z", + runtimeConfig: { type: "local" }, + parentWorkspaceId: "childworkspace", + taskStatus: "running", + }); + return cfg; + }); + + const internal = taskService as unknown as { + handleStreamEnd: (event: StreamEndEvent) => Promise; + }; + await internal.handleStreamEnd({ + type: "stream-end", + workspaceId: "childworkspace", + messageId: "msg_1", + metadata: { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "stop", + muxMetadata: { + type: "workspace-turn-task", + taskHandleId: "wst_handle", + ownerWorkspaceId: parentId, + turnId: "turn", + }, + }, + parts: [{ type: "text", text: "Premature final text" }], + }); + + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); + expect(snapshot).toMatchObject({ status: "running", workspaceId: "childworkspace" }); + expect(workspaceMocks.sendMessage).toHaveBeenCalledTimes(2); + expect(workspaceMocks.sendMessage.mock.calls[1]?.[0]).toBe("childworkspace"); + }); + + test("workspace-turn stream-end ignores unrelated mux metadata before falling back", async () => { + const { parentId, taskService } = await startWorkspaceTurnForTest(); + const internal = taskService as unknown as { + handleStreamEnd: (event: StreamEndEvent) => Promise; + }; + + await internal.handleStreamEnd({ + type: "stream-end", + workspaceId: "childworkspace", + messageId: "compaction_msg", + metadata: { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "stop", + muxMetadata: { type: "compaction-request", rawCommand: "/compact", parsed: {} }, + }, + parts: [{ type: "text", text: "Compaction summary" }], + }); + + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); + expect(snapshot).toMatchObject({ status: "running", workspaceId: "childworkspace" }); + }); + + test("workspace-turn stream-end falls back to the active handle when event metadata is absent", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["handle", "turn"]); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + + const createWorkspace = mock( + async (...args: unknown[]): Promise> => { + const tags = args[7] as Record | undefined; + await config.editConfig((cfg) => { + const project = cfg.projects.get(projectPath); + assert(project, "test project must exist"); + project.workspaces.push({ + path: path.join(projectPath, "workspace-turn"), + id: "childworkspace", + name: "workspace-turn", + title: "Workspace turn", + createdAt: "2026-06-19T00:00:00.000Z", + runtimeConfig: { type: "local" }, + tags, + }); + return cfg; + }); + return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); + } + ); + const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); + const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); + const { taskService } = createTaskServiceHarness(config, { + workspaceService: workspaceMocks.workspaceService, + }); + + const created = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: parentId, + prompt: "Summarize", + title: "Workspace turn", + workspace: { mode: "new" }, + }); + expect(created.success).toBe(true); + + const internal = taskService as unknown as { + handleStreamEnd: (event: StreamEndEvent) => Promise; + }; + await internal.handleStreamEnd({ + type: "stream-end", + workspaceId: "childworkspace", + messageId: "msg_1", + metadata: { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "stop", + }, + parts: [{ type: "text", text: "Done without correlation metadata" }], + }); + + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); + expect(snapshot).toMatchObject({ + status: "completed", + workspaceId: "childworkspace", + messageId: "msg_1", + reportMarkdown: "Done without correlation metadata", + }); + }); + + test("workspace-turn stream errors mark the handle failed", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["handle", "turn"]); + const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); + + const createWorkspace = mock( + async (...args: unknown[]): Promise> => { + const tags = args[7] as Record | undefined; + await config.editConfig((cfg) => { + const project = cfg.projects.get(projectPath); + assert(project, "test project must exist"); + project.workspaces.push({ + path: path.join(projectPath, "workspace-turn"), + id: "childworkspace", + name: "workspace-turn", + title: "Workspace turn", + createdAt: "2026-06-19T00:00:00.000Z", + runtimeConfig: { type: "local" }, + tags, + }); + return cfg; + }); + return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); + } + ); + const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); + const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); + const { taskService } = createTaskServiceHarness(config, { + workspaceService: workspaceMocks.workspaceService, + }); + + const created = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: parentId, + prompt: "Summarize", + title: "Workspace turn", + workspace: { mode: "new" }, + }); + expect(created.success).toBe(true); + + const internal = taskService as unknown as { + handleTaskStreamError: (event: ErrorEvent) => Promise; + }; + await internal.handleTaskStreamError({ + type: "error", + workspaceId: "childworkspace", + messageId: "msg_1", + error: "Provider failed", + errorType: "authentication", + }); + + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); + expect(snapshot).toMatchObject({ + status: "error", + workspaceId: "childworkspace", + error: "Provider failed", + }); + }); + + test("workspace-turn recoverable stream errors leave the handle running", async () => { + const { parentId, taskService } = await startWorkspaceTurnForTest(); + const internal = taskService as unknown as { + handleTaskStreamError: (event: ErrorEvent) => Promise; + }; + + await internal.handleTaskStreamError({ + type: "error", + workspaceId: "childworkspace", + messageId: "msg_1", + error: "Context too large", + errorType: "context_exceeded", + }); + + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); + expect(snapshot).toMatchObject({ + status: "running", + workspaceId: "childworkspace", + }); + }); + + test("workspace-turn stream aborts mark the handle interrupted", async () => { + const { parentId, taskService } = await startWorkspaceTurnForTest(); + const internal = taskService as unknown as { + handleStreamAbort: (event: StreamAbortEvent) => Promise; + }; + + await internal.handleStreamAbort({ + type: "stream-abort", + workspaceId: "childworkspace", + messageId: "msg_1", + abortReason: "user", + }); + + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); + expect(snapshot).toMatchObject({ + status: "interrupted", + workspaceId: "childworkspace", + }); + }); + + test("waitForWorkspaceTurn handles completion racing with waiter registration", async () => { + const { parentId, taskService } = await startWorkspaceTurnForTest(); + const internal = taskService as unknown as { + handleStreamEnd: (event: StreamEndEvent) => Promise; + taskHandleStore: { + getWorkspaceTurn: TaskService["getWorkspaceTurnSnapshot"]; + }; + }; + const originalGetWorkspaceTurn = internal.taskHandleStore.getWorkspaceTurn.bind( + internal.taskHandleStore + ); + let triggered = false; + spyOn(internal.taskHandleStore, "getWorkspaceTurn").mockImplementation( + async (ownerWorkspaceId: string, handleId: string) => { + const record = await originalGetWorkspaceTurn(ownerWorkspaceId, handleId); + if (!triggered && handleId === "wst_handle" && record?.status === "running") { + triggered = true; + await internal.handleStreamEnd({ + type: "stream-end", + workspaceId: "childworkspace", + messageId: "msg_1", + metadata: { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "stop", + muxMetadata: { + type: "workspace-turn-task", + taskHandleId: "wst_handle", + ownerWorkspaceId: parentId, + turnId: "turn", + }, + }, + parts: [{ type: "text", text: "Done" }], + }); + } + return record; + } + ); + + const report = await taskService.waitForWorkspaceTurn("wst_handle", { + requestingWorkspaceId: parentId, + timeoutMs: 100, + }); + + expect(triggered).toBe(true); + expect(report.reportMarkdown).toBe("Done"); + }); + + test("waitForWorkspaceTurn foreground waits can be sent to background", async () => { + const { parentId, taskService } = await startWorkspaceTurnForTest(); + + const waitResult = taskService + .waitForWorkspaceTurn("wst_handle", { + requestingWorkspaceId: parentId, + timeoutMs: 1_000, + backgroundOnMessageQueued: true, + }) + .then( + () => null, + (error: unknown) => error + ); + + expect(taskService.backgroundForegroundWaitsForWorkspace(parentId)).toBe(1); + expect(await waitResult).toBeInstanceOf(ForegroundWaitBackgroundedError); + expect(taskService.backgroundForegroundWaitsForWorkspace(parentId)).toBe(0); + }); + + test("disposable workspace turns are removed after completion, error, or interruption", async () => { + const completedRemove = mock((): Promise> => Promise.resolve(Ok(undefined))); + const completed = await startWorkspaceTurnForTest({ + disposable: true, + remove: completedRemove, + }); + await ( + completed.taskService as unknown as { + handleStreamEnd: (event: StreamEndEvent) => Promise; + } + ).handleStreamEnd({ + type: "stream-end", + workspaceId: "childworkspace", + messageId: "msg_completed", + metadata: { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "stop", + muxMetadata: { + type: "workspace-turn-task", + taskHandleId: "wst_handle", + ownerWorkspaceId: completed.parentId, + turnId: "turn", + }, + }, + parts: [{ type: "text", text: "Done" }], + }); + expect(completedRemove).toHaveBeenCalledWith("childworkspace", true); + + const errorRemove = mock((): Promise> => Promise.resolve(Ok(undefined))); + const failed = await startWorkspaceTurnForTest({ disposable: true, remove: errorRemove }); + await ( + failed.taskService as unknown as { + handleTaskStreamError: (event: ErrorEvent) => Promise; + } + ).handleTaskStreamError({ + type: "error", + workspaceId: "childworkspace", + messageId: "msg_error", + error: "Provider failed", + errorType: "api", + }); + expect(errorRemove).toHaveBeenCalledWith("childworkspace", true); + + const interruptedRemove = mock((): Promise> => Promise.resolve(Ok(undefined))); + const interrupted = await startWorkspaceTurnForTest({ + disposable: true, + remove: interruptedRemove, + isStreaming: mock(() => true), + }); + const interruptResult = await interrupted.taskService.interruptWorkspaceTurn( + interrupted.parentId, + "wst_handle" + ); + expect(interruptResult.success).toBe(true); + expect(interruptedRemove).toHaveBeenCalledWith("childworkspace", true); + }); + test("enforces maxTaskNestingDepth", async () => { const config = await createTestConfig(rootDir); stubStableIds(config, ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc"], "dddddddddd"); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index e8c92cf911..2599607708 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -73,7 +73,7 @@ import type { SessionUsageService } from "@/node/services/sessionUsageService"; import type { WorkspaceGoalService } from "@/node/services/workspaceGoalService"; import { getTotalCost, sumUsageHistory } from "@/common/utils/tokens/usageAggregator"; import type { ParsedThinkingInput, ThinkingLevel } from "@/common/types/thinking"; -import type { ErrorEvent, StreamEndEvent } from "@/common/types/stream"; +import type { ErrorEvent, StreamAbortEvent, StreamEndEvent } from "@/common/types/stream"; import { isActiveWorkflowRunStatus, isTerminalWorkflowRunStatus, @@ -114,6 +114,14 @@ import { hasCompletedAgentReport } from "@/common/utils/agentTaskCompletion"; import { isWorkspaceArchived } from "@/common/utils/archive"; import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; import { WorkflowRunStore } from "@/node/services/workflows/WorkflowRunStore"; +import { + TaskHandleStore, + WORKSPACE_TURN_TASK_ID_PREFIX, + isWorkspaceTurnTaskId, + type WorkspaceTurnFinalMessageRef, + type WorkspaceTurnTaskHandleRecord, + type WorkspaceTurnTaskStatus, +} from "@/node/services/taskHandleStore"; import { readAgentWorkflowRunReferences } from "@/node/services/agentWorkflowRunReferences"; import { isWorkflowRunTaskId } from "@/node/services/tools/taskId"; @@ -338,6 +346,52 @@ function getTaskCompletionInstruction(params: { return "Call agent_report exactly once now with your final report. Base it only on the work already completed in this workspace."; } +export interface WorkspaceTurnCreateArgs { + ownerWorkspaceId: string; + prompt: string; + title: string; + modelString?: string; + thinkingLevel?: ParsedThinkingInput; + parentRuntimeAiSettings?: { modelString?: string; thinkingLevel?: ThinkingLevel }; + workspace?: { + mode?: "new" | "fork" | "existing"; + workspaceId?: string; + branchName?: string; + trunkBranch?: string; + disposable?: boolean; + }; + experiments?: TaskCreateArgs["experiments"]; +} + +export interface WorkspaceTurnCreateResult { + taskId: string; + kind: "workspace_turn"; + status: "starting" | "running"; + workspaceId: string; +} + +export interface WorkspaceTurnWaitResult { + taskId: string; + workspaceId: string; + reportMarkdown: string; + title?: string; + messageId?: string; + finalMessageRef?: WorkspaceTurnFinalMessageRef; +} + +interface BackgroundableForegroundWaiter { + taskId: string; + reject: (error: Error) => void; + cleanup: () => void; + requestingWorkspaceId?: string; + backgroundOnMessageQueued: boolean; +} + +interface WorkspaceTurnWaiter extends BackgroundableForegroundWaiter { + handleId: string; + resolve: (result: WorkspaceTurnWaitResult) => void; +} + export interface TaskCreateResult { taskId: string; kind: TaskKind; @@ -481,13 +535,8 @@ interface InactiveWorkflowTaskOwner { type InterruptedTaskStatusMutation = "interrupted" | "preserved-completed-report"; -interface PendingTaskWaiter { - taskId: string; +interface PendingTaskWaiter extends BackgroundableForegroundWaiter { resolve: (report: { reportMarkdown: string; title?: string; structuredOutput?: unknown }) => void; - reject: (error: Error) => void; - cleanup: () => void; - requestingWorkspaceId?: string; - backgroundOnMessageQueued: boolean; } interface PendingTaskStartWaiter { @@ -525,6 +574,10 @@ function isStreamEndEvent(value: unknown): value is StreamEndEvent { return isTypedWorkspaceEvent(value, "stream-end"); } +function isStreamAbortEvent(value: unknown): value is StreamAbortEvent { + return isTypedWorkspaceEvent(value, "stream-abort"); +} + function isErrorEvent(value: unknown): value is ErrorEvent { return isTypedWorkspaceEvent(value, "error"); } @@ -853,8 +906,14 @@ export class TaskService { private readonly foregroundAwaitCountByWorkspaceId = new Map(); private readonly backgroundableForegroundWaitersByWorkspaceId = new Map< string, - Set + Set >(); + private readonly pendingWorkspaceTurnWaitersByHandleId = new Map(); + private readonly activeWorkspaceTurnHandleByWorkspaceId = new Map< + string, + { handleId: string; ownerWorkspaceId: string } + >(); + private readonly taskHandleStore: TaskHandleStore; private readonly userBackgroundedTaskIds = new Set(); // Cache completed reports so callers can retrieve them without re-reading disk. @@ -1234,6 +1293,7 @@ export class TaskService { private readonly sessionUsageService?: SessionUsageService, private readonly workspaceGoalService?: WorkspaceGoalService ) { + this.taskHandleStore = new TaskHandleStore(config); this.gitPatchArtifactService = new GitPatchArtifactService(config); this.aiService.on("stream-end", (payload: unknown) => { @@ -1248,6 +1308,18 @@ export class TaskService { }); }); + this.aiService.on("stream-abort", (payload: unknown) => { + if (!isStreamAbortEvent(payload)) return; + + void this.workspaceEventLocks + .withLock(payload.workspaceId, async () => { + await this.handleStreamAbort(payload); + }) + .catch((error: unknown) => { + log.error("TaskService.handleStreamAbort failed", { error }); + }); + }); + this.aiService.on("error", (payload: unknown) => { if (!isErrorEvent(payload)) return; @@ -1877,7 +1949,8 @@ export class TaskService { const cfg = this.config.loadConfigOrDefault(); const taskSettings = cfg.taskSettings ?? DEFAULT_TASK_SETTINGS; - let reservedActiveCount = this.countActiveAgentTasks(cfg); + let reservedActiveCount = + this.countActiveAgentTasks(cfg) + (await this.countActiveWorkspaceTurns()); for (const args of argsList) { const parentWorkspaceId = coerceNonEmptyString(args.parentWorkspaceId); @@ -2587,6 +2660,194 @@ export class TaskService { this.scheduleMaybeStartQueuedTasks(); } + async createWorkspaceTurn( + args: WorkspaceTurnCreateArgs + ): Promise> { + const ownerWorkspaceId = coerceNonEmptyString(args.ownerWorkspaceId); + if (!ownerWorkspaceId) { + return Err("Task.createWorkspaceTurn: ownerWorkspaceId is required"); + } + const prompt = coerceNonEmptyString(args.prompt); + if (!prompt) { + return Err("Task.createWorkspaceTurn: prompt is required"); + } + const title = coerceNonEmptyString(args.title) ?? "Workspace task"; + const mode = args.workspace?.mode ?? "new"; + if (mode !== "new" && mode !== "fork" && mode !== "existing") { + return Err("Task.createWorkspaceTurn: unsupported workspace mode"); + } + + await using _lock = await this.mutex.acquire(); + + const parentMetaResult = await this.aiService.getWorkspaceMetadata(ownerWorkspaceId); + if (!parentMetaResult.success) { + return Err(`Task.createWorkspaceTurn: owner workspace not found (${parentMetaResult.error})`); + } + const parentMeta = parentMetaResult.data; + const cfg = this.config.loadConfigOrDefault(); + const taskSettings = cfg.taskSettings ?? DEFAULT_TASK_SETTINGS; + const taskProjectConfig = cfg.projects.get(stripTrailingSlashes(parentMeta.projectPath)); + if (!taskProjectConfig?.trusted) { + return Err( + "This project must be trusted before creating workspaces. Trust the project in Settings → Security, or create a workspace from the project page." + ); + } + + const allWorkspaceTurns = await this.taskHandleStore.listAllWorkspaceTurns(); + const ownerWorkspaceTurns = allWorkspaceTurns.filter( + (record) => record.ownerWorkspaceId === ownerWorkspaceId + ); + const activeWorkspaceTurnCount = await this.countActiveWorkspaceTurns(allWorkspaceTurns); + const activeAgentCount = this.countActiveAgentTasks(cfg); + if (activeAgentCount + activeWorkspaceTurnCount >= taskSettings.maxParallelAgentTasks) { + return Err( + `Task.createWorkspaceTurn: maxParallelAgentTasks exceeded (active=${activeAgentCount + activeWorkspaceTurnCount}, max=${taskSettings.maxParallelAgentTasks})` + ); + } + + const handleId = `${WORKSPACE_TURN_TASK_ID_PREFIX}${this.config.generateStableId()}`; + const turnId = this.config.generateStableId(); + const createdAt = getIsoNow(); + let targetWorkspaceId: string; + let createdWorkspace = false; + + if (mode === "existing") { + const existingWorkspaceId = coerceNonEmptyString(args.workspace?.workspaceId); + if (!existingWorkspaceId) { + return Err("Task.createWorkspaceTurn: workspace.workspaceId is required for existing mode"); + } + const ownsExistingWorkspace = ownerWorkspaceTurns.some( + (record) => record.createdWorkspace && record.workspaceId === existingWorkspaceId + ); + if (!ownsExistingWorkspace) { + return Err("Task.createWorkspaceTurn: invalid_scope for existing workspace"); + } + if (this.aiService.isStreaming(existingWorkspaceId)) { + return Err("Task.createWorkspaceTurn: existing workspace is busy; wait until it is idle"); + } + targetWorkspaceId = existingWorkspaceId; + } else { + const tags = { + "mux.taskHandleId": handleId, + "mux.taskOwnerWorkspaceId": ownerWorkspaceId, + "mux.taskTurnId": turnId, + }; + const createResult = await this.workspaceService.create( + parentMeta.projectPath, + args.workspace?.branchName, + args.workspace?.trunkBranch ?? parentMeta.name, + title, + parentMeta.runtimeConfig, + parentMeta.subProjectPath, + false, + tags + ); + if (!createResult.success) { + return Err(`Task.createWorkspaceTurn: workspace create failed (${createResult.error})`); + } + targetWorkspaceId = createResult.data.metadata.id; + createdWorkspace = true; + } + + const model = + coerceNonEmptyString(args.modelString) ?? + coerceNonEmptyString(args.parentRuntimeAiSettings?.modelString) ?? + coerceNonEmptyString(parentMeta.aiSettingsByAgent?.exec?.model) ?? + coerceNonEmptyString(parentMeta.aiSettings?.model) ?? + defaultModel; + const thinkingLevel = + args.thinkingLevel != null + ? resolveThinkingInput(args.thinkingLevel, normalizeToCanonical(model)) + : (args.parentRuntimeAiSettings?.thinkingLevel ?? + parentMeta.aiSettingsByAgent?.exec?.thinkingLevel ?? + parentMeta.aiSettings?.thinkingLevel); + + const record: WorkspaceTurnTaskHandleRecord = { + kind: "workspace_turn", + handleId, + ownerWorkspaceId, + workspaceId: targetWorkspaceId, + turnId, + status: "running", + createdAt, + updatedAt: createdAt, + createdWorkspace, + disposableWorkspace: createdWorkspace && args.workspace?.disposable === true, + title, + prompt, + modelString: model, + ...(thinkingLevel != null ? { thinkingLevel } : {}), + }; + await this.taskHandleStore.upsertWorkspaceTurn(record); + this.activeWorkspaceTurnHandleByWorkspaceId.set(targetWorkspaceId, { + handleId, + ownerWorkspaceId, + }); + + const sendResult = await this.workspaceService.sendMessage( + targetWorkspaceId, + prompt, + { + model, + agentId: "exec", + ...(thinkingLevel != null ? { thinkingLevel } : {}), + muxMetadata: { + type: "workspace-turn-task", + taskHandleId: handleId, + ownerWorkspaceId, + turnId, + }, + experiments: args.experiments, + }, + { + startStreamInBackground: true, + requireIdle: true, + onAcceptedPreStreamFailure: async (sendError) => { + const error = formatSendMessageError(sendError).message; + const current = await this.taskHandleStore.getWorkspaceTurn(ownerWorkspaceId, handleId); + if (current == null || (current.status !== "starting" && current.status !== "running")) { + return; + } + const next: WorkspaceTurnTaskHandleRecord = { + ...current, + status: "error", + updatedAt: getIsoNow(), + error, + }; + await this.settleWorkspaceTurn({ + record: current, + next, + waiterSettlement: { status: "error", error: new Error(error) }, + }); + }, + agentInitiated: true, + } + ); + + if (!sendResult.success) { + const error = formatSendMessageError(sendResult.error).message; + const next: WorkspaceTurnTaskHandleRecord = { + ...record, + status: "error", + updatedAt: getIsoNow(), + error, + }; + await this.settleWorkspaceTurn({ + record, + next, + waiterSettlement: { status: "error", error: new Error(error) }, + }); + return Err(`Task.createWorkspaceTurn: send failed (${error})`); + } + + return Ok({ + taskId: handleId, + kind: "workspace_turn", + status: "running", + workspaceId: targetWorkspaceId, + }); + } + async create(args: TaskCreateArgs): Promise> { const parentWorkspaceId = coerceNonEmptyString(args.parentWorkspaceId); if (!parentWorkspaceId) { @@ -2689,7 +2950,7 @@ export class TaskService { } // Enforce parallelism (global). - const activeCount = this.countActiveAgentTasks(cfg); + const activeCount = this.countActiveAgentTasks(cfg) + (await this.countActiveWorkspaceTurns()); const shouldQueue = activeCount >= taskSettings.maxParallelAgentTasks; const taskId = this.config.generateStableId(); @@ -3465,7 +3726,7 @@ export class TaskService { private registerBackgroundableForegroundWaiter( workspaceId: string, - waiter: PendingTaskWaiter + waiter: BackgroundableForegroundWaiter ): void { let set = this.backgroundableForegroundWaitersByWorkspaceId.get(workspaceId); if (!set) { @@ -3477,7 +3738,7 @@ export class TaskService { private unregisterBackgroundableForegroundWaiter( workspaceId: string, - waiter: PendingTaskWaiter + waiter: BackgroundableForegroundWaiter ): void { const set = this.backgroundableForegroundWaitersByWorkspaceId.get(workspaceId); if (!set) return; @@ -3510,6 +3771,207 @@ export class TaskService { return count; } + private buildWorkspaceTurnWaitResult( + record: WorkspaceTurnTaskHandleRecord + ): WorkspaceTurnWaitResult { + assert(record.handleId.length > 0, "workspace turn record requires handleId"); + assert(record.workspaceId.length > 0, "workspace turn record requires workspaceId"); + return { + taskId: record.handleId, + workspaceId: record.workspaceId, + reportMarkdown: + record.reportMarkdown ?? "Workspace turn completed without final text output.", + title: record.title, + messageId: record.messageId, + finalMessageRef: record.finalMessageRef, + }; + } + + private settleWorkspaceTurnWaiters( + handleId: string, + settlement: + | { status: "completed"; result: WorkspaceTurnWaitResult } + | { status: "error"; error: Error } + ): void { + assert(handleId.length > 0, "settleWorkspaceTurnWaiters requires handleId"); + const waiters = this.pendingWorkspaceTurnWaitersByHandleId.get(handleId) ?? []; + this.pendingWorkspaceTurnWaitersByHandleId.delete(handleId); + for (const waiter of waiters) { + if (settlement.status === "completed") { + waiter.resolve(settlement.result); + } else { + waiter.reject(settlement.error); + } + } + } + + private async cleanupDisposableWorkspaceTurn( + record: WorkspaceTurnTaskHandleRecord + ): Promise { + if (!record.disposableWorkspace) return; + try { + const removeResult = await this.workspaceService.remove(record.workspaceId, true); + if (!removeResult.success) { + log.error("Workspace turn cleanup: failed to remove disposable workspace", { + handleId: record.handleId, + workspaceId: record.workspaceId, + error: removeResult.error, + }); + } + } catch (error: unknown) { + log.error("Workspace turn cleanup: workspaceService.remove threw", { + handleId: record.handleId, + workspaceId: record.workspaceId, + error: getErrorMessage(error), + }); + } + } + + private async settleWorkspaceTurn(params: { + record: WorkspaceTurnTaskHandleRecord; + next: WorkspaceTurnTaskHandleRecord; + waiterSettlement: + | { status: "completed"; result: WorkspaceTurnWaitResult } + | { status: "error"; error: Error }; + }): Promise { + assert( + params.next.handleId === params.record.handleId, + "settleWorkspaceTurn requires stable handleId" + ); + assert( + params.next.workspaceId === params.record.workspaceId, + "settleWorkspaceTurn requires stable workspaceId" + ); + + await this.taskHandleStore.upsertWorkspaceTurn(params.next); + this.activeWorkspaceTurnHandleByWorkspaceId.delete(params.record.workspaceId); + this.settleWorkspaceTurnWaiters(params.record.handleId, params.waiterSettlement); + this.markTaskForegroundRelevant(params.record.handleId); + await this.cleanupDisposableWorkspaceTurn(params.next); + this.scheduleMaybeStartQueuedTasks(); + } + + async waitForWorkspaceTurn( + handleId: string, + options: { + timeoutMs?: number; + abortSignal?: AbortSignal; + requestingWorkspaceId: string; + backgroundOnMessageQueued?: boolean; + } + ): Promise { + assert(handleId.length > 0, "waitForWorkspaceTurn: handleId must be non-empty"); + assert( + options.requestingWorkspaceId.length > 0, + "waitForWorkspaceTurn: requestingWorkspaceId must be non-empty" + ); + const timeoutMs = options.timeoutMs ?? 120_000; + assert(Number.isFinite(timeoutMs) && timeoutMs > 0, "waitForWorkspaceTurn: timeoutMs invalid"); + + this.markTaskForegroundRelevant(handleId); + + return await new Promise((resolve, reject) => { + let settled = false; + let timer: ReturnType | null = null; + let abortListener: (() => void) | null = null; + let stopBlockingRequester: (() => void) | null = this.startForegroundAwait( + options.requestingWorkspaceId + ); + const shouldBackgroundOnQueuedMessage = options.backgroundOnMessageQueued ?? true; + + const cleanup = () => { + if (settled) return; + settled = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + if (abortListener) { + options.abortSignal?.removeEventListener("abort", abortListener); + abortListener = null; + } + if (waiterEntry.backgroundOnMessageQueued && waiterEntry.requestingWorkspaceId) { + this.unregisterBackgroundableForegroundWaiter( + waiterEntry.requestingWorkspaceId, + waiterEntry + ); + } + const waiters = this.pendingWorkspaceTurnWaitersByHandleId.get(handleId) ?? []; + const nextWaiters = waiters.filter((waiter) => waiter !== waiterEntry); + if (nextWaiters.length === 0) { + this.pendingWorkspaceTurnWaitersByHandleId.delete(handleId); + } else { + this.pendingWorkspaceTurnWaitersByHandleId.set(handleId, nextWaiters); + } + if (stopBlockingRequester) { + try { + stopBlockingRequester(); + } finally { + stopBlockingRequester = null; + } + } + }; + const waiterEntry: WorkspaceTurnWaiter = { + taskId: handleId, + handleId, + requestingWorkspaceId: options.requestingWorkspaceId, + backgroundOnMessageQueued: shouldBackgroundOnQueuedMessage, + resolve: (result) => { + cleanup(); + resolve(result); + }, + reject: (error) => { + cleanup(); + reject(error); + }, + cleanup, + }; + + const waiters = this.pendingWorkspaceTurnWaitersByHandleId.get(handleId) ?? []; + waiters.push(waiterEntry); + this.pendingWorkspaceTurnWaitersByHandleId.set(handleId, waiters); + if (shouldBackgroundOnQueuedMessage) { + this.registerBackgroundableForegroundWaiter(options.requestingWorkspaceId, waiterEntry); + } + + if (options.abortSignal?.aborted) { + waiterEntry.reject(new Error("Interrupted")); + return; + } + abortListener = () => waiterEntry.reject(new Error("Interrupted")); + options.abortSignal?.addEventListener("abort", abortListener, { once: true }); + timer = setTimeout( + () => waiterEntry.reject(new Error("Timed out waiting for workspace turn")), + timeoutMs + ); + + void (async () => { + const record = await this.taskHandleStore.getWorkspaceTurn( + options.requestingWorkspaceId, + handleId + ); + if (settled) return; + if (record == null) { + waiterEntry.reject(new Error("Workspace turn not found or out of scope")); + return; + } + if (record.status === "completed") { + waiterEntry.resolve(this.buildWorkspaceTurnWaitResult(record)); + return; + } + if (record.status === "error") { + waiterEntry.reject(new Error(record.error ?? "Workspace turn failed")); + return; + } + if (record.status === "interrupted") { + waiterEntry.reject(new Error("Workspace turn interrupted")); + } + })().catch((error: unknown) => { + waiterEntry.reject(error instanceof Error ? error : new Error(String(error))); + }); + }); + } + async waitForAgentReport( taskId: string, options?: { @@ -3969,6 +4431,72 @@ export class TaskService { return result; } + async getWorkspaceTurnSnapshot( + ownerWorkspaceId: string, + handleId: string + ): Promise { + if (!isWorkspaceTurnTaskId(handleId)) { + return null; + } + return await this.taskHandleStore.getWorkspaceTurn(ownerWorkspaceId, handleId); + } + + async listWorkspaceTurnTasks( + ownerWorkspaceId: string, + options: { statuses?: readonly WorkspaceTurnTaskStatus[] } = {} + ): Promise { + const records = await this.taskHandleStore.listWorkspaceTurns(ownerWorkspaceId, options); + const statuses = options.statuses != null ? new Set(options.statuses) : null; + const result: WorkspaceTurnTaskHandleRecord[] = []; + for (const record of records) { + if ( + (record.status === "starting" || record.status === "running") && + !this.isLiveWorkspaceTurn(record) + ) { + await this.settleStaleWorkspaceTurn(record); + const latest = await this.taskHandleStore.getWorkspaceTurn( + record.ownerWorkspaceId, + record.handleId + ); + if (latest != null && (statuses == null || statuses.has(latest.status))) { + result.push(latest); + } + continue; + } + result.push(record); + } + return result; + } + + async interruptWorkspaceTurn( + ownerWorkspaceId: string, + handleId: string + ): Promise> { + const record = await this.taskHandleStore.getWorkspaceTurn(ownerWorkspaceId, handleId); + if (record == null) { + return Err("Workspace turn not found or out of scope"); + } + if (record.status === "completed" || record.status === "error") { + return Err(`Workspace turn is already ${record.status} and cannot be interrupted.`); + } + try { + await this.aiService.stopStream(record.workspaceId, { abandonPartial: false }); + } catch (error: unknown) { + log.debug("interruptWorkspaceTurn: stopStream threw", { handleId, error }); + } + const next: WorkspaceTurnTaskHandleRecord = { + ...record, + status: "interrupted", + updatedAt: getIsoNow(), + }; + await this.settleWorkspaceTurn({ + record, + next, + waiterSettlement: { status: "error", error: new Error("Workspace turn interrupted") }, + }); + return Ok({ workspaceId: record.workspaceId }); + } + listDescendantAgentTasks( workspaceId: string, options?: { statuses?: AgentTaskStatus[]; excludeWorkflowTasks?: boolean } @@ -4338,6 +4866,84 @@ export class TaskService { return this.findWorkflowTaskOwnerInAncestry(index, taskId) != null; } + private isActiveWorkspaceTurn(record: WorkspaceTurnTaskHandleRecord): boolean { + if (record.status === "running" && this.isForegroundAwaiting(record.workspaceId)) { + return false; + } + return record.status === "starting" || record.status === "running"; + } + + private isLiveWorkspaceTurn(record: WorkspaceTurnTaskHandleRecord): boolean { + const active = this.activeWorkspaceTurnHandleByWorkspaceId.get(record.workspaceId); + return ( + (active?.handleId === record.handleId && + active.ownerWorkspaceId === record.ownerWorkspaceId) || + this.aiService.isStreaming(record.workspaceId) + ); + } + + private async settleStaleWorkspaceTurn(record: WorkspaceTurnTaskHandleRecord): Promise { + if (record.status !== "starting" && record.status !== "running") { + return; + } + const next: WorkspaceTurnTaskHandleRecord = { + ...record, + status: "interrupted", + updatedAt: getIsoNow(), + error: "Workspace turn interrupted after restart", + }; + await this.settleWorkspaceTurn({ + record, + next, + waiterSettlement: { + status: "error", + error: new Error("Workspace turn interrupted after restart"), + }, + }); + } + + private async countActiveWorkspaceTurns( + records?: readonly WorkspaceTurnTaskHandleRecord[] + ): Promise { + const candidateWorkspaceTurns = + records ?? + (await this.taskHandleStore.listAllWorkspaceTurns({ + statuses: ["starting", "running"], + })); + let count = 0; + for (const record of candidateWorkspaceTurns) { + if (!this.isActiveWorkspaceTurn(record)) { + continue; + } + if (!this.isLiveWorkspaceTurn(record)) { + await this.settleStaleWorkspaceTurn(record); + continue; + } + count += 1; + } + return count; + } + + private async listActiveWorkspaceTurnTaskIdsForOwner( + ownerWorkspaceId: string + ): Promise { + const records = await this.taskHandleStore.listWorkspaceTurns(ownerWorkspaceId, { + statuses: ["starting", "running"], + }); + const taskIds: string[] = []; + for (const record of records) { + if (record.status !== "starting" && record.status !== "running") { + continue; + } + if (!this.isLiveWorkspaceTurn(record)) { + await this.settleStaleWorkspaceTurn(record); + continue; + } + taskIds.push(record.handleId); + } + return taskIds; + } + private countActiveAgentTasks(config: ReturnType): number { let activeCount = 0; for (const task of this.listAgentTaskWorkspaces(config)) { @@ -4526,7 +5132,8 @@ export class TaskService { const availableSlots = Math.max( 0, - taskSettings.maxParallelAgentTasks - this.countActiveAgentTasks(config) + taskSettings.maxParallelAgentTasks - + (this.countActiveAgentTasks(config) + (await this.countActiveWorkspaceTurns())) ); taskQueueDebug("TaskService.maybeStartQueuedTasks reservation summary", { maxParallelAgentTasks: taskSettings.maxParallelAgentTasks, @@ -5046,6 +5653,125 @@ export class TaskService { return true; } + private getWorkspaceTurnMetadata( + event: StreamEndEvent + ): { taskHandleId: string; ownerWorkspaceId: string; turnId: string } | null { + const muxMetadata = event.metadata.muxMetadata; + if (typeof muxMetadata !== "object" || muxMetadata == null || Array.isArray(muxMetadata)) { + return null; + } + const data = muxMetadata as unknown as Record; + if (data.type !== "workspace-turn-task") { + return null; + } + const taskHandleId = coerceNonEmptyString(data.taskHandleId); + const ownerWorkspaceId = coerceNonEmptyString(data.ownerWorkspaceId); + const turnId = coerceNonEmptyString(data.turnId); + if (!taskHandleId || !ownerWorkspaceId || !turnId) { + return null; + } + return { taskHandleId, ownerWorkspaceId, turnId }; + } + + private buildWorkspaceTurnReportMarkdown(event: StreamEndEvent): string { + const text = event.parts + .filter( + (part): part is Extract<(typeof event.parts)[number], { type: "text" }> => + part.type === "text" + ) + .map((part) => part.text) + .join("\n") + .trim(); + return text.length > 0 ? text : "Workspace turn completed without final text output."; + } + + private buildWorkspaceTurnFinalMessageRef(event: StreamEndEvent): WorkspaceTurnFinalMessageRef { + const textCharCount = event.parts + .filter( + (part): part is Extract<(typeof event.parts)[number], { type: "text" }> => + part.type === "text" + ) + .reduce((sum, part) => sum + part.text.length, 0); + const usage = event.metadata.usage; + return { + messageId: event.messageId, + model: event.metadata.model, + agentId: event.metadata.agentId, + finishReason: event.metadata.finishReason, + ...(usage != null + ? { + usageSummary: { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalTokens: usage.totalTokens, + }, + } + : {}), + partCount: event.parts.length, + textCharCount, + }; + } + + private async finalizeWorkspaceTurnFromStreamEnd(event: StreamEndEvent): Promise { + const metadataFromEvent = this.getWorkspaceTurnMetadata(event); + if (metadataFromEvent == null && event.metadata.muxMetadata != null) { + return false; + } + const active = + metadataFromEvent == null + ? this.activeWorkspaceTurnHandleByWorkspaceId.get(event.workspaceId) + : null; + if (metadataFromEvent == null && active == null) { + return false; + } + const ownerWorkspaceId = metadataFromEvent?.ownerWorkspaceId ?? active?.ownerWorkspaceId; + const taskHandleId = metadataFromEvent?.taskHandleId ?? active?.handleId; + assert(ownerWorkspaceId != null, "workspace turn stream-end requires ownerWorkspaceId"); + assert(taskHandleId != null, "workspace turn stream-end requires taskHandleId"); + const record = await this.taskHandleStore.getWorkspaceTurn(ownerWorkspaceId, taskHandleId); + if (record == null) { + if (active != null) { + this.activeWorkspaceTurnHandleByWorkspaceId.delete(event.workspaceId); + } + log.warn("Ignoring missing workspace turn stream-end handle", { + workspaceId: event.workspaceId, + taskHandleId: metadataFromEvent?.taskHandleId ?? active?.handleId, + }); + return true; + } + const metadata = metadataFromEvent ?? { + taskHandleId: record.handleId, + ownerWorkspaceId: record.ownerWorkspaceId, + turnId: record.turnId, + }; + if (record.workspaceId !== event.workspaceId || record.turnId !== metadata.turnId) { + log.warn("Ignoring out-of-scope workspace turn stream-end", { + workspaceId: event.workspaceId, + taskHandleId: metadata.taskHandleId, + }); + return true; + } + + const next: WorkspaceTurnTaskHandleRecord = { + ...record, + status: "completed", + updatedAt: getIsoNow(), + messageId: event.messageId, + reportMarkdown: this.buildWorkspaceTurnReportMarkdown(event), + finalMessageRef: this.buildWorkspaceTurnFinalMessageRef(event), + finalMessage: { + messageId: event.messageId, + metadata: event.metadata, + }, + }; + await this.settleWorkspaceTurn({ + record, + next, + waiterSettlement: { status: "completed", result: this.buildWorkspaceTurnWaitResult(next) }, + }); + return true; + } + private async handleStreamEnd(event: StreamEndEvent): Promise { const workspaceId = event.workspaceId; @@ -5070,13 +5796,17 @@ export class TaskService { workspaceId, referencedWorkflowRunIds ); + let activeWorkspaceTurnIds = await this.listActiveWorkspaceTurnTaskIdsForOwner(workspaceId); if (!hasActiveDescendants) { // Foreground best-of children can finish while the parent task tool call is still pending, // which temporarily blocks their leaf cleanup and may defer synthetic fallback delivery. // Recheck both once the parent stream reaches a descendant-free stream-end. await this.deliverDeferredBestOfReportsForParent(workspaceId); await this.requestReportedChildCleanupRechecks(workspaceId); - if (activeWorkflowRunIds.length === 0) { + if (activeWorkflowRunIds.length === 0 && activeWorkspaceTurnIds.length === 0) { + if (await this.finalizeWorkspaceTurnFromStreamEnd(event)) { + return; + } this.consecutiveAutoResumes.delete(workspaceId); return; } @@ -5095,9 +5825,12 @@ export class TaskService { // bypass that journal/final-result path by asking the model to task_await those child tasks // directly. Instead, await the owning workflow run when one is still active. // Foreground waits can also be backgrounded at runtime when users queue another message. - let activeTaskIds = this.listActiveDescendantAgentTaskIds(workspaceId, { - excludeWorkflowTasks: true, - }); + let activeTaskIds = [ + ...this.listActiveDescendantAgentTaskIds(workspaceId, { + excludeWorkflowTasks: true, + }), + ...activeWorkspaceTurnIds, + ]; const queueBackgroundedTaskIds = new Set( activeTaskIds.filter((id) => this.isTaskQueueBackgrounded(id)) ); @@ -5111,6 +5844,9 @@ export class TaskService { let blockingTaskIds = getBlockingTaskIds(activeTaskIds); if (blockingTaskIds.length === 0 && activeWorkflowRunIds.length === 0) { + if (await this.finalizeWorkspaceTurnFromStreamEnd(event)) { + return; + } this.consecutiveAutoResumes.delete(workspaceId); consumeQueueBackgroundedExemptions(); log.debug("Skipping parent auto-resume: all active descendants were queue-backgrounded", { @@ -5136,15 +5872,22 @@ export class TaskService { event.metadata ); - activeTaskIds = this.listActiveDescendantAgentTaskIds(workspaceId, { - excludeWorkflowTasks: true, - }); + activeWorkspaceTurnIds = await this.listActiveWorkspaceTurnTaskIdsForOwner(workspaceId); + activeTaskIds = [ + ...this.listActiveDescendantAgentTaskIds(workspaceId, { + excludeWorkflowTasks: true, + }), + ...activeWorkspaceTurnIds, + ]; blockingTaskIds = getBlockingTaskIds(activeTaskIds); activeWorkflowRunIds = await this.listActiveBackgroundWorkflowRunIds( workspaceId, activeWorkflowRunIds ); if (blockingTaskIds.length === 0 && activeWorkflowRunIds.length === 0) { + if (await this.finalizeWorkspaceTurnFromStreamEnd(event)) { + return; + } this.consecutiveAutoResumes.delete(workspaceId); consumeQueueBackgroundedExemptions(); return; @@ -5191,15 +5934,22 @@ export class TaskService { { skipAutoResumeReset: true, synthetic: true, agentInitiated: true, requireIdle: true } ); if (!sendResult.success && isWorkspaceBusyIdleOnlySend(sendResult.error)) { - activeTaskIds = this.listActiveDescendantAgentTaskIds(workspaceId, { - excludeWorkflowTasks: true, - }); + activeWorkspaceTurnIds = await this.listActiveWorkspaceTurnTaskIdsForOwner(workspaceId); + activeTaskIds = [ + ...this.listActiveDescendantAgentTaskIds(workspaceId, { + excludeWorkflowTasks: true, + }), + ...activeWorkspaceTurnIds, + ]; blockingTaskIds = getBlockingTaskIds(activeTaskIds); activeWorkflowRunIds = await this.listActiveBackgroundWorkflowRunIds( workspaceId, activeWorkflowRunIds ); if (blockingTaskIds.length === 0 && activeWorkflowRunIds.length === 0) { + if (await this.finalizeWorkspaceTurnFromStreamEnd(event)) { + return; + } this.consecutiveAutoResumes.delete(workspaceId); consumeQueueBackgroundedExemptions(); return; @@ -5251,6 +6001,10 @@ export class TaskService { return; } + if (await this.finalizeWorkspaceTurnFromStreamEnd(event)) { + return; + } + const status = entry.workspace.taskStatus; const reportArgs = this.findAgentReportArgsInParts(event.parts); @@ -5345,7 +6099,76 @@ export class TaskService { await this.promptTaskForRequiredCompletionTool(workspaceId, { reason: "stream_end" }); } + private async finalizeWorkspaceTurnFromStreamAbort(event: StreamAbortEvent): Promise { + const active = this.activeWorkspaceTurnHandleByWorkspaceId.get(event.workspaceId); + if (active == null) { + return false; + } + const record = await this.taskHandleStore.getWorkspaceTurn( + active.ownerWorkspaceId, + active.handleId + ); + if (record == null) { + this.activeWorkspaceTurnHandleByWorkspaceId.delete(event.workspaceId); + return true; + } + if (!this.isActiveWorkspaceTurn(record)) { + this.activeWorkspaceTurnHandleByWorkspaceId.delete(event.workspaceId); + return true; + } + const next: WorkspaceTurnTaskHandleRecord = { + ...record, + status: "interrupted", + updatedAt: getIsoNow(), + }; + await this.settleWorkspaceTurn({ + record, + next, + waiterSettlement: { status: "error", error: new Error("Workspace turn interrupted") }, + }); + return true; + } + + private async handleStreamAbort(event: StreamAbortEvent): Promise { + await this.finalizeWorkspaceTurnFromStreamAbort(event); + } + + private async finalizeWorkspaceTurnFromStreamError(event: ErrorEvent): Promise { + const active = this.activeWorkspaceTurnHandleByWorkspaceId.get(event.workspaceId); + if (active == null) { + return false; + } + const record = await this.taskHandleStore.getWorkspaceTurn( + active.ownerWorkspaceId, + active.handleId + ); + if (record == null) { + this.activeWorkspaceTurnHandleByWorkspaceId.delete(event.workspaceId); + return true; + } + const settlesWorkspaceTurn = + event.errorType != null && RUNNING_TASK_TERMINAL_STREAM_ERRORS.has(event.errorType); + if (!settlesWorkspaceTurn) { + return true; + } + const next: WorkspaceTurnTaskHandleRecord = { + ...record, + status: "error", + updatedAt: getIsoNow(), + error: event.error, + }; + await this.settleWorkspaceTurn({ + record, + next, + waiterSettlement: { status: "error", error: new Error(event.error) }, + }); + return true; + } + private async handleTaskStreamError(event: ErrorEvent): Promise { + if (await this.finalizeWorkspaceTurnFromStreamError(event)) { + return; + } const workspaceId = event.workspaceId; const cfg = this.config.loadConfigOrDefault(); const entry = findWorkspaceEntry(cfg, workspaceId); diff --git a/src/node/services/tools/task.test.ts b/src/node/services/tools/task.test.ts index 115665520e..8fa1a1d82e 100644 --- a/src/node/services/tools/task.test.ts +++ b/src/node/services/tools/task.test.ts @@ -93,6 +93,63 @@ describe("task tool", () => { expect(parseWithIsolation(tool).success).toBe(true); }); + it("starts a background workspace turn without requiring a sub-agent id", async () => { + using tempDir = new TestTempDir("test-task-tool-workspace-turn"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + + const createWorkspaceTurn = mock(() => + Ok({ + taskId: "wst_child-turn", + kind: "workspace_turn" as const, + status: "running" as const, + workspaceId: "child-workspace", + }) + ); + const create = mock(() => Err("sub-agent path should not be used")); + const waitForWorkspaceTurn = mock(() => Promise.resolve({ reportMarkdown: "ignored" })); + const taskService = { + create, + createWorkspaceTurn, + waitForWorkspaceTurn, + } as unknown as TaskService; + + const tool = createTaskTool({ + ...baseConfig, + muxEnv: { MUX_MODEL_STRING: "openai:gpt-4o-mini", MUX_THINKING_LEVEL: "high" }, + taskService, + }); + + const result: unknown = await Promise.resolve( + tool.execute!( + { + kind: "workspace", + prompt: "summarize the repository", + title: "Repository summary", + run_in_background: true, + }, + mockToolCallOptions + ) + ); + + expect(create).not.toHaveBeenCalled(); + expect(waitForWorkspaceTurn).not.toHaveBeenCalled(); + expect(createWorkspaceTurn).toHaveBeenCalledTimes(1); + const createWorkspaceTurnCall = createWorkspaceTurn.mock.calls[0] as unknown[]; + expect(createWorkspaceTurnCall[0]).toMatchObject({ + ownerWorkspaceId: "parent-workspace", + prompt: "summarize the repository", + title: "Repository summary", + parentRuntimeAiSettings: { modelString: "openai:gpt-4o-mini", thinkingLevel: "high" }, + workspace: { mode: "new" }, + }); + expect(result).toMatchObject({ + status: "running", + taskId: "wst_child-turn", + workspaceId: "child-workspace", + handleKind: "workspace_turn", + }); + }); + it("forwards isolation to taskService.create", async () => { using tempDir = new TestTempDir("test-task-tool-isolation-passthrough"); 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 1358c5e233..6441cd2448 100644 --- a/src/node/services/tools/task.ts +++ b/src/node/services/tools/task.ts @@ -356,6 +356,7 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { } const { + kind, agentId, subagent_type, prompt, @@ -366,20 +367,107 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { model, thinking, isolation, + workspace, } = validatedArgs; + + // Explicit per-launch model/thinking overrides. Omitted by default so delegated work + // inherits the parent's live settings unless the caller requests an override. + const aiOverrides = parseTaskAiOverrides({ model, thinking }); + + const workspaceId = requireWorkspaceId(config, "task"); + const taskService = requireTaskService(config, "task"); + + const parentRuntimeAiSettings = buildParentRuntimeAiSettings(config); + + if (kind === "workspace") { + const created = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: workspaceId, + prompt, + title, + experiments: config.experiments, + ...(aiOverrides.modelString != null ? { modelString: aiOverrides.modelString } : {}), + ...(aiOverrides.thinkingLevel != null + ? { thinkingLevel: aiOverrides.thinkingLevel } + : {}), + ...(parentRuntimeAiSettings != null ? { parentRuntimeAiSettings } : {}), + workspace: { + mode: workspace?.mode ?? "new", + ...(workspace?.workspaceId != null ? { workspaceId: workspace.workspaceId } : {}), + ...(workspace?.branchName != null ? { branchName: workspace.branchName } : {}), + ...(workspace?.trunkBranch != null ? { trunkBranch: workspace.trunkBranch } : {}), + ...(workspace?.disposable != null ? { disposable: workspace.disposable } : {}), + }, + }); + if (!created.success) { + throw new Error(created.error); + } + + const pendingResult = { + status: created.data.status, + taskId: created.data.taskId, + workspaceId: created.data.workspaceId, + handleKind: "workspace_turn" as const, + note: buildBackgroundStartNote(1), + }; + if (run_in_background) { + return parseToolResult(TaskToolResultSchema, pendingResult, "task"); + } + + try { + const report = await taskService.waitForWorkspaceTurn(created.data.taskId, { + abortSignal, + requestingWorkspaceId: workspaceId, + backgroundOnMessageQueued: true, + }); + return parseToolResult( + TaskToolResultSchema, + { + status: "completed" as const, + taskId: created.data.taskId, + workspaceId: report.workspaceId ?? created.data.workspaceId, + handleKind: "workspace_turn" as const, + reportMarkdown: report.reportMarkdown, + title: report.title, + messageId: report.messageId, + finalMessageRef: report.finalMessageRef, + }, + "task" + ); + } catch (error: unknown) { + if (abortSignal?.aborted) { + throw new Error("Interrupted"); + } + if (error instanceof ForegroundWaitBackgroundedError) { + return parseToolResult( + TaskToolResultSchema, + { + ...pendingResult, + note: buildForegroundContinuationNote(1, "backgrounded"), + }, + "task" + ); + } + const errorMessage = getErrorMessage(error); + if (errorMessage === "Timed out waiting for workspace turn") { + return parseToolResult( + TaskToolResultSchema, + { + ...pendingResult, + note: buildForegroundContinuationNote(1, "timed_out"), + }, + "task" + ); + } + throw error; + } + } + const requestedAgentId = typeof agentId === "string" && agentId.trim().length > 0 ? agentId : subagent_type; if (!requestedAgentId) { throw new Error("task tool input validation failed: expected agent task args"); } - // Explicit per-launch model/thinking overrides. Omitted by default so the - // sub-agent keeps inheriting the parent's live settings (see precedence in - // taskService.resolveTaskAISettings). - const aiOverrides = parseTaskAiOverrides({ model, thinking }); - - const workspaceId = requireWorkspaceId(config, "task"); - const taskService = requireTaskService(config, "task"); const taskGroupLaunches = buildTaskGroupLaunches({ prompt, n, variants }); const taskGroupCount = taskGroupLaunches.length; const taskGroupId = @@ -396,7 +484,6 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { // Parent runtime model and thinking are forwarded as a low-priority fallback so // unconfigured delegated runs still inherit the parent's live model. Do not // restore the previous top-priority forwarding through explicit task args. - const parentRuntimeAiSettings = buildParentRuntimeAiSettings(config); const createdTasks: SpawnedTaskInfo[] = []; for (const launch of taskGroupLaunches) { if (abortSignal?.aborted) { diff --git a/src/node/services/tools/task_await.test.ts b/src/node/services/tools/task_await.test.ts index b80ad6ba8d..0be04bc426 100644 --- a/src/node/services/tools/task_await.test.ts +++ b/src/node/services/tools/task_await.test.ts @@ -40,6 +40,141 @@ function createWorkflowRun( } describe("task_await tool", () => { + it("returns completed workspace-turn results without raw part duplication", async () => { + using tempDir = new TestTempDir("test-task-await-workspace-turn"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + + const taskService = { + listActiveDescendantAgentTaskIds: mock(() => []), + listWorkspaceTurnTasks: mock(() => []), + isDescendantAgentTask: mock(() => Promise.resolve(false)), + getAgentTaskStatuses: mock(() => new Map()), + getWorkspaceTurnSnapshot: mock(() => + Promise.resolve({ + kind: "workspace_turn", + handleId: "wst_done", + ownerWorkspaceId: "parent-workspace", + workspaceId: "child-workspace", + turnId: "turn-1", + status: "completed", + createdAt: "2026-06-19T00:00:00.000Z", + updatedAt: "2026-06-19T00:00:10.000Z", + createdWorkspace: true, + disposableWorkspace: false, + title: "Summary", + reportMarkdown: "Done", + messageId: "msg_1", + finalMessageRef: { messageId: "msg_1", partCount: 1, textCharCount: 4 }, + finalMessage: { + messageId: "msg_1", + parts: [{ type: "text", text: "Done" }], + metadata: {}, + }, + }) + ), + } as unknown as TaskService; + + const tool = createTaskAwaitTool({ ...baseConfig, taskService }); + const result = (await Promise.resolve( + tool.execute!({ task_ids: ["wst_done"], timeout_secs: 0 }, mockToolCallOptions) + )) as { results: Array> }; + + expect(result.results).toEqual([ + { + status: "completed", + taskId: "wst_done", + handleKind: "workspace_turn", + workspaceId: "child-workspace", + reportMarkdown: "Done", + title: "Summary", + messageId: "msg_1", + finalMessageRef: { messageId: "msg_1", partCount: 1, textCharCount: 4 }, + note: COMPLETED_REPORT_REFETCH_NOTE, + }, + ]); + expect(result.results[0]?.finalMessage).toBeUndefined(); + }); + + it("returns live workspace-turn status when min_completed detaches an unfinished await", async () => { + using tempDir = new TestTempDir("test-task-await-workspace-turn-detached"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + const completedSnapshot = { + kind: "workspace_turn", + handleId: "wst_done", + ownerWorkspaceId: "parent-workspace", + workspaceId: "child-done", + turnId: "turn-done", + status: "completed", + createdAt: "2026-06-19T00:00:00.000Z", + updatedAt: "2026-06-19T00:00:10.000Z", + createdWorkspace: true, + disposableWorkspace: false, + reportMarkdown: "Done", + messageId: "msg_done", + } as const; + const runningSnapshot = { + kind: "workspace_turn", + handleId: "wst_running", + ownerWorkspaceId: "parent-workspace", + workspaceId: "child-running", + turnId: "turn-running", + status: "running", + createdAt: "2026-06-19T00:00:00.000Z", + updatedAt: "2026-06-19T00:00:00.000Z", + createdWorkspace: true, + disposableWorkspace: false, + } as const; + + const taskService = { + listActiveDescendantAgentTaskIds: mock(() => []), + listWorkspaceTurnTasks: mock(() => Promise.resolve([runningSnapshot])), + isDescendantAgentTask: mock(() => Promise.resolve(false)), + getAgentTaskStatuses: mock(() => new Map()), + getWorkspaceTurnSnapshot: mock((_ownerWorkspaceId: string, taskId: string) => + Promise.resolve(taskId === "wst_done" ? completedSnapshot : runningSnapshot) + ), + waitForWorkspaceTurn: mock( + (_taskId: string, options: { abortSignal?: AbortSignal }) => + new Promise((_resolve, reject) => { + if (options.abortSignal?.aborted) { + reject(new Error("Interrupted")); + return; + } + options.abortSignal?.addEventListener("abort", () => reject(new Error("Interrupted")), { + once: true, + }); + }) + ), + } as unknown as TaskService; + + const tool = createTaskAwaitTool({ ...baseConfig, taskService }); + const result = (await Promise.resolve( + tool.execute!( + { task_ids: ["wst_done", "wst_running"], min_completed: 1, timeout_secs: 60 }, + mockToolCallOptions + ) + )) as { results: Array> }; + + expect(result.results).toEqual([ + { + status: "completed", + taskId: "wst_done", + handleKind: "workspace_turn", + workspaceId: "child-done", + reportMarkdown: "Done", + messageId: "msg_done", + note: COMPLETED_REPORT_REFETCH_NOTE, + }, + { + status: "running", + taskId: "wst_running", + handleKind: "workspace_turn", + workspaceId: "child-running", + note: "Workspace turn await detached; task continues in background.", + }, + ]); + }); + it("includes gitFormatPatch artifacts written during waitForAgentReport", async () => { using tempDir = new TestTempDir("test-task-await-tool-artifacts"); 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 532e2e077f..a6e89bef91 100644 --- a/src/node/services/tools/task_await.ts +++ b/src/node/services/tools/task_await.ts @@ -25,6 +25,10 @@ import { requireWorkspaceId, } from "./toolUtils"; import { getErrorMessage } from "@/common/utils/errors"; +import { + isWorkspaceTurnTaskId, + type WorkspaceTurnTaskStatus, +} from "@/node/services/taskHandleStore"; import { ForegroundWaitBackgroundedError, type AgentTaskStatus, @@ -51,6 +55,10 @@ function isAgentTaskActiveStatus(status: AgentTaskStatus | null): status is Agen ); } +function isWorkspaceTurnActiveStatus(status: WorkspaceTurnTaskStatus): boolean { + return status === "queued" || status === "starting" || status === "running"; +} + function coerceTimeoutMs(timeoutSecs: unknown): number | undefined { if (typeof timeoutSecs !== "number" || !Number.isFinite(timeoutSecs)) return undefined; if (timeoutSecs < 0) return undefined; @@ -288,8 +296,18 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { } return dedupeStrings(workflowRunIds); }; + const listInScopeWorkspaceTurnTaskIds = async (): Promise => { + if (taskService.listWorkspaceTurnTasks == null) { + return []; + } + const turns = await taskService.listWorkspaceTurnTasks(workspaceId, { + statuses: ["queued", "starting", "running"], + }); + return turns.map((turn) => turn.handleId); + }; const listInScopeAwaitableTaskIds = async (): Promise => { const awaitableTaskIds = [...activeDescendantAgentTaskIds]; + awaitableTaskIds.push(...(await listInScopeWorkspaceTurnTaskIds())); awaitableTaskIds.push(...(await listInScopeBackgroundBashTaskIds())); awaitableTaskIds.push(...(await listInScopeWorkflowRunIds())); return dedupeStrings(awaitableTaskIds); @@ -309,7 +327,10 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { : await listInScopeAwaitableTaskIds(); const agentTaskIds = uniqueTaskIds.filter( - (taskId) => !taskId.startsWith("bash:") && !isWorkflowRunTaskId(taskId) + (taskId) => + !taskId.startsWith("bash:") && + !isWorkflowRunTaskId(taskId) && + !isWorkspaceTurnTaskId(taskId) ); const bulkFilter = ( taskService as unknown as { @@ -488,6 +509,149 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { }; } + if (isWorkspaceTurnTaskId(taskId)) { + const snapshot = await taskService.getWorkspaceTurnSnapshot(workspaceId, taskId); + if (snapshot == null) { + const activeTaskIds = requestedIds + ? await listInScopeWorkspaceTurnTaskIds().catch(() => undefined) + : undefined; + return { + status: requestedIds ? ("invalid_scope" as const) : ("not_found" as const), + taskId, + activeTaskIds, + }; + } + if (timeoutMs === 0 || !isWorkspaceTurnActiveStatus(snapshot.status)) { + if (snapshot.status === "completed") { + return { + status: "completed" as const, + taskId, + handleKind: "workspace_turn" as const, + workspaceId: snapshot.workspaceId, + reportMarkdown: + snapshot.reportMarkdown ?? "Workspace turn completed without final text output.", + title: snapshot.title, + messageId: snapshot.messageId, + finalMessageRef: snapshot.finalMessageRef, + note: COMPLETED_REPORT_REFETCH_NOTE, + }; + } + if (snapshot.status === "interrupted") { + return { + status: "interrupted" as const, + taskId, + handleKind: "workspace_turn" as const, + workspaceId: snapshot.workspaceId, + note: "Workspace turn was interrupted. The full workspace is preserved.", + }; + } + if (snapshot.status === "error") { + return { + status: "error" as const, + taskId, + error: snapshot.error ?? "Workspace turn failed", + }; + } + } + if (timeoutMs === 0) { + return { + status: snapshot.status as "queued" | "starting" | "running", + taskId, + handleKind: "workspace_turn" as const, + workspaceId: snapshot.workspaceId, + note: "Workspace turn is still running.", + }; + } + try { + const report = await taskService.waitForWorkspaceTurn(taskId, { + timeoutMs, + abortSignal: taskSignal, + requestingWorkspaceId: workspaceId, + backgroundOnMessageQueued: true, + }); + return { + status: "completed" as const, + taskId, + handleKind: "workspace_turn" as const, + workspaceId: report.workspaceId, + reportMarkdown: report.reportMarkdown, + title: report.title, + messageId: report.messageId, + finalMessageRef: report.finalMessageRef, + note: COMPLETED_REPORT_REFETCH_NOTE, + }; + } catch (error: unknown) { + const message = getErrorMessage(error); + if (error instanceof ForegroundWaitBackgroundedError) { + const latest = await taskService.getWorkspaceTurnSnapshot(workspaceId, taskId); + const status = + latest != null && isWorkspaceTurnActiveStatus(latest.status) + ? (latest.status as "queued" | "starting" | "running") + : ("running" as const); + return { + status, + taskId, + handleKind: "workspace_turn" as const, + ...(latest?.workspaceId != null ? { workspaceId: latest.workspaceId } : {}), + note: "Workspace turn sent to background because a new message was queued. Use task_await to monitor progress.", + }; + } + if (abortSignal?.aborted) { + return { status: "error" as const, taskId, error: "Interrupted" }; + } + if (taskSignal.aborted) { + const latest = await taskService.getWorkspaceTurnSnapshot(workspaceId, taskId); + if (latest == null) return { status: "not_found" as const, taskId }; + if (latest.status === "completed") { + return { + status: "completed" as const, + taskId, + handleKind: "workspace_turn" as const, + workspaceId: latest.workspaceId, + reportMarkdown: + latest.reportMarkdown ?? "Workspace turn completed without final text output.", + title: latest.title, + messageId: latest.messageId, + finalMessageRef: latest.finalMessageRef, + note: COMPLETED_REPORT_REFETCH_NOTE, + }; + } + if (latest.status === "error") { + return { + status: "error" as const, + taskId, + error: latest.error ?? "Workspace turn failed", + }; + } + return { + status: isWorkspaceTurnActiveStatus(latest.status) + ? (latest.status as "queued" | "starting" | "running") + : "interrupted", + taskId, + handleKind: "workspace_turn" as const, + workspaceId: latest.workspaceId, + note: "Workspace turn await detached; task continues in background.", + }; + } + if (/timed out/i.test(message)) { + const latest = await taskService.getWorkspaceTurnSnapshot(workspaceId, taskId); + if (latest == null) return { status: "not_found" as const, taskId }; + return { + status: isWorkspaceTurnActiveStatus(latest.status) + ? (latest.status as "queued" | "starting" | "running") + : "running", + taskId, + handleKind: "workspace_turn" as const, + workspaceId: latest.workspaceId, + }; + } + if (/out of scope/i.test(message) || /not found/i.test(message)) { + return { status: "invalid_scope" as const, taskId }; + } + return { status: "error" as const, taskId, error: message }; + } + } + if (isWorkflowRunTaskId(taskId)) { return await awaitWorkflowRun(taskId, taskSignal); } @@ -499,6 +663,7 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { if (requestedIds) { const suggestedTaskIds = dedupeStrings([ ...activeDescendantAgentTaskIds, + ...(await listInScopeWorkspaceTurnTaskIds().catch(() => [])), ...(await getSuggestionBashTaskIds()), ...(await getSuggestionWorkflowRunIds()), ]); diff --git a/src/node/services/tools/task_list.test.ts b/src/node/services/tools/task_list.test.ts index 3de78e8371..6818d58ea6 100644 --- a/src/node/services/tools/task_list.test.ts +++ b/src/node/services/tools/task_list.test.ts @@ -91,6 +91,56 @@ describe("task_list tool", () => { }); }); + it("lists workspace-turn handles with workspace metadata", async () => { + using tempDir = new TestTempDir("test-task-list-workspace-turns"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "root-workspace" }); + + const listDescendantAgentTasks = mock(() => []); + const listWorkspaceTurnTasks = mock(() => [ + { + kind: "workspace_turn" as const, + handleId: "wst_turn", + ownerWorkspaceId: "root-workspace", + workspaceId: "child-workspace", + turnId: "turn-1", + status: "running" as const, + createdAt: "2026-06-19T00:00:00.000Z", + updatedAt: "2026-06-19T00:00:01.000Z", + createdWorkspace: true, + disposableWorkspace: false, + title: "Summary", + }, + ]); + const taskService = { + listDescendantAgentTasks, + listWorkspaceTurnTasks, + } as unknown as TaskService; + + const tool = createTaskListTool({ ...baseConfig, taskService }); + + const result: unknown = await Promise.resolve( + tool.execute!({ statuses: ["running"] }, mockToolCallOptions) + ); + + expect(listWorkspaceTurnTasks).toHaveBeenCalledWith("root-workspace", { + statuses: ["running"], + }); + expect(result).toEqual({ + tasks: [ + { + taskId: "wst_turn", + status: "running", + parentWorkspaceId: "root-workspace", + handleKind: "workspace_turn", + workspaceId: "child-workspace", + title: "Summary", + createdAt: "2026-06-19T00:00:00.000Z", + depth: 1, + }, + ], + }); + }); + const buildWorkflowRun = (id: string, status: string) => ({ id, workspaceId: "root-workspace", diff --git a/src/node/services/tools/task_list.ts b/src/node/services/tools/task_list.ts index 359edd8715..8208fe8094 100644 --- a/src/node/services/tools/task_list.ts +++ b/src/node/services/tools/task_list.ts @@ -82,6 +82,38 @@ export const createTaskListTool: ToolFactory = (config: ToolConfiguration) => { } } + const workspaceTurnStatuses = statuses.filter( + ( + status + ): status is "queued" | "starting" | "running" | "interrupted" | "completed" | "failed" => + status === "queued" || + status === "starting" || + status === "running" || + status === "interrupted" || + status === "completed" || + status === "failed" + ); + if (workspaceTurnStatuses.length > 0 && taskService.listWorkspaceTurnTasks != null) { + const storeStatuses = workspaceTurnStatuses.map((status) => + status === "failed" ? "error" : status + ); + const workspaceTurns = await taskService.listWorkspaceTurnTasks(workspaceId, { + statuses: storeStatuses, + }); + for (const turn of workspaceTurns) { + tasks.push({ + taskId: turn.handleId, + status: turn.status === "error" ? "failed" : turn.status, + parentWorkspaceId: workspaceId, + handleKind: "workspace_turn", + workspaceId: turn.workspaceId, + title: turn.title, + createdAt: turn.createdAt, + depth: 1, + }); + } + } + if (config.backgroundProcessManager) { const depthByWorkspaceId = new Map(); depthByWorkspaceId.set(workspaceId, 0); diff --git a/src/node/services/tools/task_terminate.test.ts b/src/node/services/tools/task_terminate.test.ts index f5939b95a0..0c0e34047a 100644 --- a/src/node/services/tools/task_terminate.test.ts +++ b/src/node/services/tools/task_terminate.test.ts @@ -87,6 +87,39 @@ describe("task_terminate tool", () => { }); }); + it("interrupts a workspace turn without deleting the workspace", async () => { + using tempDir = new TestTempDir("test-task-terminate-workspace-turn"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "root-workspace" }); + + const interruptWorkspaceTurn = mock( + (): Promise> => + Promise.resolve(Ok({ workspaceId: "child-workspace" })) + ); + const taskService = { + interruptWorkspaceTurn, + terminateDescendantAgentTask: mock(() => { + throw new Error("workspace turn IDs must not reach agent task termination"); + }), + } as unknown as TaskService; + + const tool = createTaskTerminateTool({ ...baseConfig, taskService }); + + const result: unknown = await Promise.resolve( + tool.execute!({ task_ids: ["wst_turn"] }, mockToolCallOptions) + ); + + expect(interruptWorkspaceTurn).toHaveBeenCalledWith("root-workspace", "wst_turn"); + expect(result).toEqual({ + results: [ + { + status: "interrupted", + taskId: "wst_turn", + note: "Workspace turn interrupted. The full workspace is preserved for inspection and future prompts.", + }, + ], + }); + }); + const buildWorkflowRun = (status: string) => ({ id: "wfr_run_1", workspaceId: "root-workspace", diff --git a/src/node/services/tools/task_terminate.ts b/src/node/services/tools/task_terminate.ts index b5c43588a9..ba7c2b2e4f 100644 --- a/src/node/services/tools/task_terminate.ts +++ b/src/node/services/tools/task_terminate.ts @@ -8,6 +8,7 @@ import { TOOL_DEFINITIONS, } from "@/common/utils/tools/toolDefinitions"; +import { isWorkspaceTurnTaskId } from "@/node/services/taskHandleStore"; import { fromBashTaskId, isWorkflowRunTaskId } from "./taskId"; import { dedupeStrings, @@ -91,6 +92,22 @@ export const createTaskTerminateTool: ToolFactory = (config: ToolConfiguration) return await interruptWorkflowRun(config, workspaceId, taskId); } + if (isWorkspaceTurnTaskId(taskId)) { + const interruptResult = await taskService.interruptWorkspaceTurn(workspaceId, taskId); + if (!interruptResult.success) { + const msg = interruptResult.error; + if (/not found/i.test(msg) || /scope/i.test(msg)) { + return { status: "invalid_scope" as const, taskId }; + } + return { status: "error" as const, taskId, error: msg }; + } + return { + status: "interrupted" as const, + taskId, + note: "Workspace turn interrupted. The full workspace is preserved for inspection and future prompts.", + }; + } + const maybeProcessId = fromBashTaskId(taskId); if (taskId.startsWith("bash:") && !maybeProcessId) { return { status: "error" as const, taskId, error: "Invalid bash taskId." }; From 500ce92985b362e6928fe72a2b8410e5faa8c5f4 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 13:26:35 +0000 Subject: [PATCH 02/17] =?UTF-8?q?=F0=9F=A4=96=20test:=20fix=20workspace=20?= =?UTF-8?q?turn=20task=20unit=20assertions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agentSession.editMessageId.test.ts | 20 +++ src/node/services/agentSession.ts | 6 +- src/node/services/taskService.test.ts | 116 ++++++++++++++++-- src/node/services/taskService.ts | 39 +++++- src/node/services/tools/task_await.test.ts | 37 ++++++ src/node/services/tools/task_await.ts | 2 +- 6 files changed, 208 insertions(+), 12 deletions(-) diff --git a/src/node/services/agentSession.editMessageId.test.ts b/src/node/services/agentSession.editMessageId.test.ts index ba98384240..acf9fe587a 100644 --- a/src/node/services/agentSession.editMessageId.test.ts +++ b/src/node/services/agentSession.editMessageId.test.ts @@ -104,6 +104,26 @@ describe("AgentSession.sendMessage (editMessageId)", () => { expect(streamMessage.mock.calls).toHaveLength(1); }); + it("passes muxMetadata through to the stream request", async () => { + const { session, streamMessage } = await createSessionHarness("ws-mux-metadata"); + const muxMetadata = { + type: "workspace-turn-task" as const, + taskHandleId: "wst_handle", + ownerWorkspaceId: "owner-workspace", + turnId: "turn-id", + }; + + const result = await session.sendMessage("hello", { + model: TEST_MODEL, + agentId: "exec", + muxMetadata, + }); + + expect(result.success).toBe(true); + expect(streamMessage).toHaveBeenCalledTimes(1); + expect(streamMessage.mock.calls[0]?.[0]).toMatchObject({ muxMetadata }); + }); + it("does not truncate history when edit validation fails", async () => { const workspaceId = "ws-edit-validation"; const { session, historyService, streamMessage } = await createSessionHarness(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 430ac7c804..600064b1ab 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -3543,11 +3543,12 @@ export class AgentSession { // Bind recordFileState to this session for the propose_plan tool const recordFileState = this.fileChangeTracker.record.bind(this.fileChangeTracker); + const typedMuxMetadata = options?.muxMetadata as MuxMessageMetadata | undefined; const acpPromptId = - normalizeAcpPromptId(options?.acpPromptId) ?? extractAcpPromptId(options?.muxMetadata); + normalizeAcpPromptId(options?.acpPromptId) ?? extractAcpPromptId(typedMuxMetadata); const delegatedToolNames = normalizeDelegatedToolNames(options?.delegatedToolNames) ?? - extractAcpDelegatedTools(options?.muxMetadata); + extractAcpDelegatedTools(typedMuxMetadata); const streamResult = await this.aiService.streamMessage({ messages: historyResult.data, @@ -3564,6 +3565,7 @@ export class AgentSession { agentId: options?.agentId, acpPromptId, delegatedToolNames, + muxMetadata: typedMuxMetadata, recordFileState, changedFileAttachments: changedFileAttachments.length > 0 ? changedFileAttachments : undefined, diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index cf032bd844..066702f104 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -639,6 +639,57 @@ describe("TaskService", () => { }); }); + test("createWorkspaceTurn rejects multi-project owners instead of dropping secondary repos", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["handle", "turn"]); + const projectPath = await createTestProject(rootDir, "repo", { initGit: false }); + const secondaryProjectPath = await createTestProject(rootDir, "repo-secondary", { + initGit: false, + }); + const parentId = "1111111111"; + await saveWorkspaces( + config, + projectPath, + [ + { + path: projectPath, + id: parentId, + name: "parent", + createdAt: new Date().toISOString(), + runtimeConfig: { type: "local" }, + projects: [ + { projectPath, projectName: "repo" }, + { projectPath: secondaryProjectPath, projectName: "repo-secondary" }, + ], + }, + ], + { + taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, + extraProjects: [[secondaryProjectPath, { trusted: true, workspaces: [] }]], + } + ); + const createWorkspace = mock( + (): Promise> => + Promise.resolve(Err("should not create workspace")) + ); + const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace }); + const { taskService } = createTaskServiceHarness(config, { + workspaceService: workspaceMocks.workspaceService, + }); + + const result = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: parentId, + prompt: "Summarize all projects", + title: "Workspace turn", + workspace: { mode: "new" }, + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toContain("multi-project workspace turns are not supported"); + expect(createWorkspace).not.toHaveBeenCalled(); + }); + test("createWorkspaceTurn marks accepted pre-stream failures as handle errors", async () => { const sendMessage = mock( async (...args: unknown[]): Promise> => { @@ -866,6 +917,24 @@ describe("TaskService", () => { }); }); + test("getWorkspaceTurnSnapshot settles stale active handles before returning", async () => { + const { parentId, taskService } = await startWorkspaceTurnForTest(); + const internal = taskService as unknown as { + activeWorkspaceTurnHandleByWorkspaceId: Map< + string, + { handleId: string; ownerWorkspaceId: string } + >; + }; + + internal.activeWorkspaceTurnHandleByWorkspaceId.clear(); + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); + expect(snapshot).toMatchObject({ + status: "interrupted", + error: "Workspace turn interrupted after restart", + workspaceId: "childworkspace", + }); + }); + test("listWorkspaceTurnTasks settles stale active handles before returning", async () => { const { parentId, taskService } = await startWorkspaceTurnForTest(); const internal = taskService as unknown as { @@ -957,6 +1026,40 @@ describe("TaskService", () => { expect(childConfig?.taskStatus).toBeUndefined(); }); + test("workspace-turn stream-end with non-stop finish marks the handle error", async () => { + const { parentId, taskService } = await startWorkspaceTurnForTest(); + const internal = taskService as unknown as { + handleStreamEnd: (event: StreamEndEvent) => Promise; + }; + + await internal.handleStreamEnd({ + type: "stream-end", + workspaceId: "childworkspace", + messageId: "msg_truncated", + metadata: { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "length", + muxMetadata: { + type: "workspace-turn-task", + taskHandleId: "wst_handle", + ownerWorkspaceId: parentId, + turnId: "turn", + }, + }, + parts: [{ type: "text", text: "Partial" }], + }); + + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); + expect(snapshot).toMatchObject({ + status: "error", + workspaceId: "childworkspace", + messageId: "msg_truncated", + error: "Workspace turn ended before completion (finishReason: length)", + }); + expect(snapshot?.reportMarkdown).toBeUndefined(); + }); + test("parent stream-end auto-resumes for active background workspace turns", async () => { const { parentId, taskService, workspaceMocks } = await startWorkspaceTurnForTest(); const internal = taskService as unknown as { @@ -1317,7 +1420,7 @@ describe("TaskService", () => { workspaceId: "childworkspace", messageId: "msg_error", error: "Provider failed", - errorType: "api", + errorType: "authentication", }); expect(errorRemove).toHaveBeenCalledWith("childworkspace", true); @@ -5699,17 +5802,14 @@ describe("TaskService", () => { const { workspaceService, remove } = createWorkspaceServiceMocks(); const { taskService } = createTaskServiceHarness(config, { aiService, workspaceService }); - const waiter = taskService.waitForAgentReport(taskId, { timeoutMs: 10_000 }); + const waiter = taskService + .waitForAgentReport(taskId, { timeoutMs: 10_000 }) + .catch((error: unknown) => error); const terminateResult = await taskService.terminateDescendantAgentTask(rootWorkspaceId, taskId); expect(terminateResult.success).toBe(true); - let caught: unknown = null; - try { - await waiter; - } catch (error: unknown) { - caught = error; - } + const caught = await waiter; expect(caught).toBeInstanceOf(Error); if (caught instanceof Error) { expect(caught.message).toMatch(/terminated/i); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 2599607708..642e6259d9 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -2687,6 +2687,11 @@ export class TaskService { const cfg = this.config.loadConfigOrDefault(); const taskSettings = cfg.taskSettings ?? DEFAULT_TASK_SETTINGS; const taskProjectConfig = cfg.projects.get(stripTrailingSlashes(parentMeta.projectPath)); + if ((parentMeta.projects?.length ?? 0) > 1) { + // WorkspaceService.create only materializes one project checkout; fail loudly instead of + // silently dropping secondary repos from a multi-project caller's task context. + return Err("Task.createWorkspaceTurn: multi-project workspace turns are not supported yet"); + } if (!taskProjectConfig?.trusted) { return Err( "This project must be trusted before creating workspaces. Trust the project in Settings → Security, or create a workspace from the project page." @@ -4438,7 +4443,16 @@ export class TaskService { if (!isWorkspaceTurnTaskId(handleId)) { return null; } - return await this.taskHandleStore.getWorkspaceTurn(ownerWorkspaceId, handleId); + const record = await this.taskHandleStore.getWorkspaceTurn(ownerWorkspaceId, handleId); + if ( + record != null && + (record.status === "starting" || record.status === "running") && + !this.isLiveWorkspaceTurn(record) + ) { + await this.settleStaleWorkspaceTurn(record); + return await this.taskHandleStore.getWorkspaceTurn(ownerWorkspaceId, handleId); + } + return record; } async listWorkspaceTurnTasks( @@ -5752,6 +5766,29 @@ export class TaskService { return true; } + // Truncated/non-stop provider finishes are partial output, not a completed delegated turn. + if (event.metadata.finishReason != null && event.metadata.finishReason !== "stop") { + const error = `Workspace turn ended before completion (finishReason: ${event.metadata.finishReason})`; + const next: WorkspaceTurnTaskHandleRecord = { + ...record, + status: "error", + updatedAt: getIsoNow(), + messageId: event.messageId, + error, + finalMessageRef: this.buildWorkspaceTurnFinalMessageRef(event), + finalMessage: { + messageId: event.messageId, + metadata: event.metadata, + }, + }; + await this.settleWorkspaceTurn({ + record, + next, + waiterSettlement: { status: "error", error: new Error(error) }, + }); + return true; + } + const next: WorkspaceTurnTaskHandleRecord = { ...record, status: "completed", diff --git a/src/node/services/tools/task_await.test.ts b/src/node/services/tools/task_await.test.ts index 0be04bc426..f5fc79c917 100644 --- a/src/node/services/tools/task_await.test.ts +++ b/src/node/services/tools/task_await.test.ts @@ -175,6 +175,43 @@ describe("task_await tool", () => { ]); }); + it("uses the documented default timeout for workspace-turn awaits when timeout is null", async () => { + using tempDir = new TestTempDir("test-task-await-workspace-turn-default-timeout"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + const runningSnapshot = { + kind: "workspace_turn", + handleId: "wst_running", + ownerWorkspaceId: "parent-workspace", + workspaceId: "child-running", + turnId: "turn-running", + status: "running", + createdAt: "2026-06-19T00:00:00.000Z", + updatedAt: "2026-06-19T00:00:00.000Z", + createdWorkspace: true, + disposableWorkspace: false, + } as const; + let observedTimeoutMs: number | undefined; + + const taskService = { + listActiveDescendantAgentTaskIds: mock(() => []), + listWorkspaceTurnTasks: mock(() => Promise.resolve([runningSnapshot])), + isDescendantAgentTask: mock(() => Promise.resolve(false)), + getAgentTaskStatuses: mock(() => new Map()), + getWorkspaceTurnSnapshot: mock(() => Promise.resolve(runningSnapshot)), + waitForWorkspaceTurn: mock((_taskId: string, options: { timeoutMs?: number }) => { + observedTimeoutMs = options.timeoutMs; + return Promise.resolve({ workspaceId: "child-running", reportMarkdown: "Done" }); + }), + } as unknown as TaskService; + + const tool = createTaskAwaitTool({ ...baseConfig, taskService }); + await Promise.resolve( + tool.execute!({ task_ids: ["wst_running"], timeout_secs: null }, mockToolCallOptions) + ); + + expect(observedTimeoutMs).toBe(600_000); + }); + it("includes gitFormatPatch artifacts written during waitForAgentReport", async () => { using tempDir = new TestTempDir("test-task-await-tool-artifacts"); 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 a6e89bef91..965911619e 100644 --- a/src/node/services/tools/task_await.ts +++ b/src/node/services/tools/task_await.ts @@ -564,7 +564,7 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { } try { const report = await taskService.waitForWorkspaceTurn(taskId, { - timeoutMs, + timeoutMs: timeoutMs ?? DEFAULT_TASK_AWAIT_TIMEOUT_MS, abortSignal: taskSignal, requestingWorkspaceId: workspaceId, backgroundOnMessageQueued: true, From ac9a8edc28ec1f61134ee8c0e2545abef5f28053 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 14:28:16 +0000 Subject: [PATCH 03/17] Fix stale workspace turn recovery --- src/node/services/taskService.test.ts | 73 +++++++++++- src/node/services/taskService.ts | 159 +++++++++++++++++++------- 2 files changed, 190 insertions(+), 42 deletions(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 066702f104..810d079b82 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -547,7 +547,7 @@ describe("TaskService", () => { const aiMocks = createAIServiceMocks(config, { ...(options.isStreaming != null ? { isStreaming: options.isStreaming } : {}), }); - const { taskService } = createTaskServiceHarness(config, { + const { historyService, taskService } = createTaskServiceHarness(config, { aiService: aiMocks.aiService, workspaceService: workspaceMocks.workspaceService, }); @@ -570,6 +570,7 @@ describe("TaskService", () => { taskService, workspaceMocks, aiMocks, + historyService, created: created.data, }; } @@ -935,6 +936,76 @@ describe("TaskService", () => { }); }); + test("getWorkspaceTurnSnapshot recovers stale completed handles from matching history", async () => { + const { parentId, taskService, historyService, created } = await startWorkspaceTurnForTest(); + const appendResult = await historyService.appendToHistory( + created.workspaceId, + createMuxMessage("msg_completed", "assistant", "Recovered final text", { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "stop", + muxMetadata: { + type: "workspace-turn-task", + taskHandleId: created.taskId, + ownerWorkspaceId: parentId, + turnId: "turn", + }, + }) + ); + expect(appendResult.success).toBe(true); + const internal = taskService as unknown as { + activeWorkspaceTurnHandleByWorkspaceId: Map< + string, + { handleId: string; ownerWorkspaceId: string } + >; + }; + + internal.activeWorkspaceTurnHandleByWorkspaceId.clear(); + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, created.taskId); + expect(snapshot).toMatchObject({ + status: "completed", + workspaceId: created.workspaceId, + messageId: "msg_completed", + reportMarkdown: "Recovered final text", + finalMessageRef: { messageId: "msg_completed", finishReason: "stop", textCharCount: 20 }, + }); + }); + + test("getWorkspaceTurnSnapshot recovers stale truncated handles from matching history as errors", async () => { + const { parentId, taskService, historyService, created } = await startWorkspaceTurnForTest(); + const appendResult = await historyService.appendToHistory( + created.workspaceId, + createMuxMessage("msg_truncated_history", "assistant", "Partial text", { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "length", + muxMetadata: { + type: "workspace-turn-task", + taskHandleId: created.taskId, + ownerWorkspaceId: parentId, + turnId: "turn", + }, + }) + ); + expect(appendResult.success).toBe(true); + const internal = taskService as unknown as { + activeWorkspaceTurnHandleByWorkspaceId: Map< + string, + { handleId: string; ownerWorkspaceId: string } + >; + }; + + internal.activeWorkspaceTurnHandleByWorkspaceId.clear(); + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, created.taskId); + expect(snapshot).toMatchObject({ + status: "error", + workspaceId: created.workspaceId, + messageId: "msg_truncated_history", + error: "Workspace turn ended before completion (finishReason: length)", + }); + expect(snapshot?.reportMarkdown).toBeUndefined(); + }); + test("listWorkspaceTurnTasks settles stale active handles before returning", async () => { const { parentId, taskService } = await startWorkspaceTurnForTest(); const internal = taskService as unknown as { diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 642e6259d9..ac500b6965 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -3849,7 +3849,13 @@ export class TaskService { ); await this.taskHandleStore.upsertWorkspaceTurn(params.next); - this.activeWorkspaceTurnHandleByWorkspaceId.delete(params.record.workspaceId); + const active = this.activeWorkspaceTurnHandleByWorkspaceId.get(params.record.workspaceId); + if ( + active?.handleId === params.record.handleId && + active.ownerWorkspaceId === params.record.ownerWorkspaceId + ) { + this.activeWorkspaceTurnHandleByWorkspaceId.delete(params.record.workspaceId); + } this.settleWorkspaceTurnWaiters(params.record.handleId, params.waiterSettlement); this.markTaskForegroundRelevant(params.record.handleId); await this.cleanupDisposableWorkspaceTurn(params.next); @@ -4900,6 +4906,19 @@ export class TaskService { if (record.status !== "starting" && record.status !== "running") { return; } + const recovered = await this.recoverTerminalWorkspaceTurnFromHistory(record); + if (recovered != null) { + await this.settleWorkspaceTurn({ + record, + next: recovered, + waiterSettlement: + recovered.status === "completed" + ? { status: "completed", result: this.buildWorkspaceTurnWaitResult(recovered) } + : { status: "error", error: new Error(recovered.error ?? "Workspace turn failed") }, + }); + return; + } + const next: WorkspaceTurnTaskHandleRecord = { ...record, status: "interrupted", @@ -5667,14 +5686,13 @@ export class TaskService { return true; } - private getWorkspaceTurnMetadata( - event: StreamEndEvent + private getWorkspaceTurnMetadataFromValue( + muxMetadata: unknown ): { taskHandleId: string; ownerWorkspaceId: string; turnId: string } | null { - const muxMetadata = event.metadata.muxMetadata; if (typeof muxMetadata !== "object" || muxMetadata == null || Array.isArray(muxMetadata)) { return null; } - const data = muxMetadata as unknown as Record; + const data = muxMetadata as Record; if (data.type !== "workspace-turn-task") { return null; } @@ -5687,6 +5705,12 @@ export class TaskService { return { taskHandleId, ownerWorkspaceId, turnId }; } + private getWorkspaceTurnMetadata( + event: StreamEndEvent + ): { taskHandleId: string; ownerWorkspaceId: string; turnId: string } | null { + return this.getWorkspaceTurnMetadataFromValue(event.metadata.muxMetadata); + } + private buildWorkspaceTurnReportMarkdown(event: StreamEndEvent): string { const text = event.parts .filter( @@ -5726,6 +5750,90 @@ export class TaskService { }; } + private buildWorkspaceTurnStreamEndEventFromHistory( + record: WorkspaceTurnTaskHandleRecord, + message: MuxMessage + ): StreamEndEvent | null { + if (message.role !== "assistant" || message.metadata?.partial === true) { + return null; + } + const metadata = this.getWorkspaceTurnMetadataFromValue(message.metadata?.muxMetadata); + if ( + metadata == null || + metadata.taskHandleId !== record.handleId || + metadata.ownerWorkspaceId !== record.ownerWorkspaceId || + metadata.turnId !== record.turnId + ) { + return null; + } + return { + type: "stream-end", + workspaceId: record.workspaceId, + messageId: message.id, + metadata: { + ...message.metadata, + model: coerceNonEmptyString(message.metadata?.model) ?? record.modelString ?? defaultModel, + }, + parts: message.parts as StreamEndEvent["parts"], + }; + } + + private buildTerminalWorkspaceTurnRecordFromEvent( + record: WorkspaceTurnTaskHandleRecord, + event: StreamEndEvent + ): WorkspaceTurnTaskHandleRecord { + // Truncated/non-stop provider finishes are partial output, not a completed delegated turn. + if (event.metadata.finishReason != null && event.metadata.finishReason !== "stop") { + return { + ...record, + status: "error", + updatedAt: getIsoNow(), + messageId: event.messageId, + error: `Workspace turn ended before completion (finishReason: ${event.metadata.finishReason})`, + finalMessageRef: this.buildWorkspaceTurnFinalMessageRef(event), + finalMessage: { + messageId: event.messageId, + metadata: event.metadata, + }, + }; + } + return { + ...record, + status: "completed", + updatedAt: getIsoNow(), + messageId: event.messageId, + reportMarkdown: this.buildWorkspaceTurnReportMarkdown(event), + finalMessageRef: this.buildWorkspaceTurnFinalMessageRef(event), + finalMessage: { + messageId: event.messageId, + metadata: event.metadata, + }, + }; + } + + private async recoverTerminalWorkspaceTurnFromHistory( + record: WorkspaceTurnTaskHandleRecord + ): Promise { + const historyResult = await this.historyService.getHistoryFromLatestBoundary( + record.workspaceId + ); + if (!historyResult.success) { + log.warn("Workspace turn stale recovery could not read history", { + handleId: record.handleId, + workspaceId: record.workspaceId, + error: historyResult.error, + }); + return null; + } + for (const message of historyResult.data.toReversed()) { + const event = this.buildWorkspaceTurnStreamEndEventFromHistory(record, message); + if (event != null) { + return this.buildTerminalWorkspaceTurnRecordFromEvent(record, event); + } + } + return null; + } + private async finalizeWorkspaceTurnFromStreamEnd(event: StreamEndEvent): Promise { const metadataFromEvent = this.getWorkspaceTurnMetadata(event); if (metadataFromEvent == null && event.metadata.muxMetadata != null) { @@ -5766,45 +5874,14 @@ export class TaskService { return true; } - // Truncated/non-stop provider finishes are partial output, not a completed delegated turn. - if (event.metadata.finishReason != null && event.metadata.finishReason !== "stop") { - const error = `Workspace turn ended before completion (finishReason: ${event.metadata.finishReason})`; - const next: WorkspaceTurnTaskHandleRecord = { - ...record, - status: "error", - updatedAt: getIsoNow(), - messageId: event.messageId, - error, - finalMessageRef: this.buildWorkspaceTurnFinalMessageRef(event), - finalMessage: { - messageId: event.messageId, - metadata: event.metadata, - }, - }; - await this.settleWorkspaceTurn({ - record, - next, - waiterSettlement: { status: "error", error: new Error(error) }, - }); - return true; - } - - const next: WorkspaceTurnTaskHandleRecord = { - ...record, - status: "completed", - updatedAt: getIsoNow(), - messageId: event.messageId, - reportMarkdown: this.buildWorkspaceTurnReportMarkdown(event), - finalMessageRef: this.buildWorkspaceTurnFinalMessageRef(event), - finalMessage: { - messageId: event.messageId, - metadata: event.metadata, - }, - }; + const next = this.buildTerminalWorkspaceTurnRecordFromEvent(record, event); await this.settleWorkspaceTurn({ record, next, - waiterSettlement: { status: "completed", result: this.buildWorkspaceTurnWaitResult(next) }, + waiterSettlement: + next.status === "completed" + ? { status: "completed", result: this.buildWorkspaceTurnWaitResult(next) } + : { status: "error", error: new Error(next.error ?? "Workspace turn failed") }, }); return true; } From 1dfe1f03db1de3ea0c7547b5a565517b8cf4e348 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 14:47:29 +0000 Subject: [PATCH 04/17] Address workspace turn review races --- src/common/utils/tools/toolDefinitions.ts | 7 +++ src/node/services/taskService.test.ts | 26 +++++++++++ src/node/services/taskService.ts | 4 ++ src/node/services/tools/task.test.ts | 19 ++++++++ src/node/services/tools/task_await.test.ts | 53 ++++++++++++++++++++++ src/node/services/tools/task_await.ts | 34 ++++++++++++-- 6 files changed, 140 insertions(+), 3 deletions(-) diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index 71f4d140f4..a6e5f2bf1b 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -378,6 +378,13 @@ function refineTaskToolAgentArgs( path: args.n != null ? ["n"] : ["variants"], }); } + if ((args.workspace?.mode ?? "new") === "fork") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'workspace.mode="fork" is not supported for workspace tasks yet', + path: ["workspace", "mode"], + }); + } if ((args.workspace?.mode ?? "new") === "existing" && args.workspace?.workspaceId == null) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 810d079b82..e7c76faaa0 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -691,6 +691,32 @@ describe("TaskService", () => { expect(createWorkspace).not.toHaveBeenCalled(); }); + test("createWorkspaceTurn rejects fork mode until workspace turns support forking", async () => { + const config = await createTestConfig(rootDir); + stubStableIds(config, ["handle", "turn"]); + const { parentId } = await saveLocalParentWorkspace(config, rootDir); + const createWorkspace = mock( + (): Promise> => + Promise.resolve(Err("should not create workspace")) + ); + const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace }); + const { taskService } = createTaskServiceHarness(config, { + workspaceService: workspaceMocks.workspaceService, + }); + + const result = await taskService.createWorkspaceTurn({ + ownerWorkspaceId: parentId, + prompt: "Summarize fork", + title: "Workspace turn", + workspace: { mode: "fork" }, + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toContain('workspace.mode="fork" is not supported'); + expect(createWorkspace).not.toHaveBeenCalled(); + }); + test("createWorkspaceTurn marks accepted pre-stream failures as handle errors", async () => { const sendMessage = mock( async (...args: unknown[]): Promise> => { diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index ac500b6965..22d9b528a8 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -2716,6 +2716,10 @@ export class TaskService { let targetWorkspaceId: string; let createdWorkspace = false; + if (mode === "fork") { + return Err('Task.createWorkspaceTurn: workspace.mode="fork" is not supported yet'); + } + if (mode === "existing") { const existingWorkspaceId = coerceNonEmptyString(args.workspace?.workspaceId); if (!existingWorkspaceId) { diff --git a/src/node/services/tools/task.test.ts b/src/node/services/tools/task.test.ts index 8fa1a1d82e..b3558fd273 100644 --- a/src/node/services/tools/task.test.ts +++ b/src/node/services/tools/task.test.ts @@ -93,6 +93,25 @@ describe("task tool", () => { expect(parseWithIsolation(tool).success).toBe(true); }); + it("rejects unsupported workspace fork mode in the schema", () => { + using tempDir = new TestTempDir("test-task-tool-workspace-fork-schema"); + const tool = createTaskTool({ + ...createTestToolConfig(tempDir.path), + muxEnv: { MUX_RUNTIME: "worktree" }, + }); + + const parsed = ( + tool.inputSchema as { safeParse: (v: unknown) => { success: boolean } } + ).safeParse({ + kind: "workspace", + prompt: "summarize the fork", + title: "Workspace fork", + workspace: { mode: "fork" }, + }); + + expect(parsed.success).toBe(false); + }); + it("starts a background workspace turn without requiring a sub-agent id", async () => { using tempDir = new TestTempDir("test-task-tool-workspace-turn"); const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); diff --git a/src/node/services/tools/task_await.test.ts b/src/node/services/tools/task_await.test.ts index f5fc79c917..1b52b25178 100644 --- a/src/node/services/tools/task_await.test.ts +++ b/src/node/services/tools/task_await.test.ts @@ -212,6 +212,59 @@ describe("task_await tool", () => { expect(observedTimeoutMs).toBe(600_000); }); + it("returns terminal workspace-turn result when timeout races with completion", async () => { + using tempDir = new TestTempDir("test-task-await-workspace-turn-timeout-terminal"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + const runningSnapshot = { + kind: "workspace_turn", + handleId: "wst_race", + ownerWorkspaceId: "parent-workspace", + workspaceId: "child-race", + turnId: "turn-race", + status: "running", + createdAt: "2026-06-19T00:00:00.000Z", + updatedAt: "2026-06-19T00:00:00.000Z", + createdWorkspace: true, + disposableWorkspace: false, + } as const; + const completedSnapshot = { + ...runningSnapshot, + status: "completed", + updatedAt: "2026-06-19T00:00:01.000Z", + reportMarkdown: "Finished during timeout", + messageId: "msg_race", + finalMessageRef: { messageId: "msg_race", textCharCount: 23 }, + } as const; + const snapshots = [runningSnapshot, completedSnapshot]; + + const taskService = { + listActiveDescendantAgentTaskIds: mock(() => []), + listWorkspaceTurnTasks: mock(() => Promise.resolve([runningSnapshot])), + isDescendantAgentTask: mock(() => Promise.resolve(false)), + getAgentTaskStatuses: mock(() => new Map()), + getWorkspaceTurnSnapshot: mock(() => Promise.resolve(snapshots.shift() ?? completedSnapshot)), + waitForWorkspaceTurn: mock(() => Promise.reject(new Error("timed out"))), + } as unknown as TaskService; + + const tool = createTaskAwaitTool({ ...baseConfig, taskService }); + const result = (await Promise.resolve( + tool.execute!({ task_ids: ["wst_race"], timeout_secs: 1 }, mockToolCallOptions) + )) as { results: Array> }; + + expect(result.results).toEqual([ + { + status: "completed", + taskId: "wst_race", + handleKind: "workspace_turn", + workspaceId: "child-race", + reportMarkdown: "Finished during timeout", + messageId: "msg_race", + finalMessageRef: { messageId: "msg_race", textCharCount: 23 }, + note: COMPLETED_REPORT_REFETCH_NOTE, + }, + ]); + }); + it("includes gitFormatPatch artifacts written during waitForAgentReport", async () => { using tempDir = new TestTempDir("test-task-await-tool-artifacts"); 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 965911619e..81932237b2 100644 --- a/src/node/services/tools/task_await.ts +++ b/src/node/services/tools/task_await.ts @@ -636,10 +636,38 @@ export const createTaskAwaitTool: ToolFactory = (config: ToolConfiguration) => { if (/timed out/i.test(message)) { const latest = await taskService.getWorkspaceTurnSnapshot(workspaceId, taskId); if (latest == null) return { status: "not_found" as const, taskId }; + if (latest.status === "completed") { + return { + status: "completed" as const, + taskId, + handleKind: "workspace_turn" as const, + workspaceId: latest.workspaceId, + reportMarkdown: + latest.reportMarkdown ?? "Workspace turn completed without final text output.", + title: latest.title, + messageId: latest.messageId, + finalMessageRef: latest.finalMessageRef, + note: COMPLETED_REPORT_REFETCH_NOTE, + }; + } + if (latest.status === "error") { + return { + status: "error" as const, + taskId, + error: latest.error ?? "Workspace turn failed", + }; + } + if (latest.status === "interrupted") { + return { + status: "interrupted" as const, + taskId, + handleKind: "workspace_turn" as const, + workspaceId: latest.workspaceId, + note: "Workspace turn was interrupted. The full workspace is preserved.", + }; + } return { - status: isWorkspaceTurnActiveStatus(latest.status) - ? (latest.status as "queued" | "starting" | "running") - : "running", + status: latest.status, taskId, handleKind: "workspace_turn" as const, workspaceId: latest.workspaceId, From 147bbbdc88b8d3a19c962a7bf0f9802483e2001d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 15:01:09 +0000 Subject: [PATCH 05/17] Harden workspace turn metadata recovery --- src/node/services/taskHandleStore.test.ts | 26 ++++++++++++ src/node/services/taskHandleStore.ts | 39 +++++++++++++---- src/node/services/taskService.test.ts | 49 +++++++++++++++++++++ src/node/services/taskService.ts | 52 ++++++++++++++++++++--- 4 files changed, 151 insertions(+), 15 deletions(-) diff --git a/src/node/services/taskHandleStore.test.ts b/src/node/services/taskHandleStore.test.ts index f8857883da..705a7efda3 100644 --- a/src/node/services/taskHandleStore.test.ts +++ b/src/node/services/taskHandleStore.test.ts @@ -44,6 +44,32 @@ describe("TaskHandleStore", () => { expect(listed.map((item) => item.handleId)).toEqual([`${WORKSPACE_TURN_TASK_ID_PREFIX}abc`]); }); + it("rejects unsafe handle IDs before composing paths", async () => { + const { config } = await createTempConfig("task-handle-store-unsafe-id"); + const store = new TaskHandleStore(config); + const sessionDir = config.getSessionDir("owner"); + await fsPromises.mkdir(sessionDir, { recursive: true }); + await fsPromises.writeFile( + path.join(sessionDir, "chat.json"), + JSON.stringify({ + kind: "workspace_turn", + handleId: `${WORKSPACE_TURN_TASK_ID_PREFIX}x/../../chat`, + ownerWorkspaceId: "owner", + workspaceId: "escaped", + turnId: "turn-1", + status: "completed", + createdAt: "2026-06-19T00:00:00.000Z", + updatedAt: "2026-06-19T00:00:00.000Z", + createdWorkspace: true, + disposableWorkspace: false, + }) + ); + + expect( + await store.getWorkspaceTurn("owner", `${WORKSPACE_TURN_TASK_ID_PREFIX}x/../../chat`) + ).toBeNull(); + }); + it("self-heals corrupt handle records by ignoring them", async () => { const { config } = await createTempConfig("task-handle-store-corrupt"); const store = new TaskHandleStore(config); diff --git a/src/node/services/taskHandleStore.ts b/src/node/services/taskHandleStore.ts index 7f4b868397..b69ad6d10e 100644 --- a/src/node/services/taskHandleStore.ts +++ b/src/node/services/taskHandleStore.ts @@ -84,10 +84,19 @@ const WorkspaceTurnTaskHandleRecordSchema = z }) .strict(); +const WORKSPACE_TURN_TASK_ID_PATTERN = /^wst_[a-z0-9][a-z0-9_-]*$/; + export function isWorkspaceTurnTaskId( value: unknown ): value is `${typeof WORKSPACE_TURN_TASK_ID_PREFIX}${string}` { - return typeof value === "string" && value.startsWith(WORKSPACE_TURN_TASK_ID_PREFIX); + return typeof value === "string" && WORKSPACE_TURN_TASK_ID_PATTERN.test(value); +} + +function assertValidWorkspaceTurnTaskId(handleId: string): void { + assert( + isWorkspaceTurnTaskId(handleId), + "workspace turn handle IDs must use the wst_ prefix and safe filename characters" + ); } export class TaskHandleStore { @@ -125,6 +134,9 @@ export class TaskHandleStore { ): Promise { assert(ownerWorkspaceId.trim().length > 0, "getWorkspaceTurn requires ownerWorkspaceId"); assert(handleId.trim().length > 0, "getWorkspaceTurn requires handleId"); + if (!isWorkspaceTurnTaskId(handleId)) { + return null; + } const record = await this.readWorkspaceTurnFile(ownerWorkspaceId, handleId); return record?.ownerWorkspaceId === ownerWorkspaceId ? record : null; } @@ -147,9 +159,9 @@ export class TaskHandleStore { const records = await Promise.all( entries .filter((entry) => entry.endsWith(".json")) - .map((entry) => - this.readWorkspaceTurnFile(ownerWorkspaceId, entry.slice(0, -".json".length)) - ) + .map((entry) => entry.slice(0, -".json".length)) + .filter(isWorkspaceTurnTaskId) + .map((handleId) => this.readWorkspaceTurnFile(ownerWorkspaceId, handleId)) ); return records .filter((record): record is WorkspaceTurnTaskHandleRecord => { @@ -192,6 +204,7 @@ export class TaskHandleStore { private getHandlePath(ownerWorkspaceId: string, handleId: string): string { assert(handleId.trim().length > 0, "handleId must be non-empty"); + assertValidWorkspaceTurnTaskId(handleId); return path.join(this.getOwnerHandleDir(ownerWorkspaceId), `${handleId}.json`); } @@ -201,10 +214,7 @@ export class TaskHandleStore { parsed.success, `Invalid workspace turn handle record: ${parsed.success ? "" : parsed.error.message}` ); - assert( - record.handleId.startsWith(WORKSPACE_TURN_TASK_ID_PREFIX), - "workspace turn handle IDs must use the wst_ prefix" - ); + assertValidWorkspaceTurnTaskId(record.handleId); } private async readWorkspaceTurnFile( @@ -226,6 +236,19 @@ export class TaskHandleStore { }); return null; } + if ( + parsed.data.handleId !== handleId || + parsed.data.ownerWorkspaceId !== ownerWorkspaceId || + !isWorkspaceTurnTaskId(parsed.data.handleId) + ) { + log.warn("Ignoring mismatched workspace turn task handle", { + ownerWorkspaceId, + handleId, + recordOwnerWorkspaceId: parsed.data.ownerWorkspaceId, + recordHandleId: parsed.data.handleId, + }); + return null; + } return parsed.data as WorkspaceTurnTaskHandleRecord; } catch (error) { if (isErrnoWithCode(error, "ENOENT") || error instanceof SyntaxError) { diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index e7c76faaa0..19a8801067 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -1225,6 +1225,55 @@ describe("TaskService", () => { expect(workspaceMocks.sendMessage.mock.calls[1]?.[0]).toBe("childworkspace"); }); + test("workspace-turn auto-resume preserves handle metadata", async () => { + const { config, parentId, projectPath, taskService, workspaceMocks } = + await startWorkspaceTurnForTest(); + await config.editConfig((cfg) => { + const project = Array.from(cfg.projects.values())[0]; + assert(project, "test project must exist"); + project.workspaces.push({ + path: path.join(projectPath, "descendant-task"), + id: "descendant-task", + name: "descendant-task", + createdAt: "2026-06-19T00:00:00.000Z", + runtimeConfig: { type: "local" }, + parentWorkspaceId: "childworkspace", + taskStatus: "running", + }); + return cfg; + }); + + await ( + taskService as unknown as { handleStreamEnd: (event: StreamEndEvent) => Promise } + ).handleStreamEnd({ + type: "stream-end", + workspaceId: "childworkspace", + messageId: "msg_1", + metadata: { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "stop", + muxMetadata: { + type: "workspace-turn-task", + taskHandleId: "wst_handle", + ownerWorkspaceId: parentId, + turnId: "turn", + }, + }, + parts: [{ type: "text", text: "Premature final text" }], + }); + + expect(workspaceMocks.sendMessage).toHaveBeenCalledTimes(2); + expect(workspaceMocks.sendMessage.mock.calls[1]?.[2]).toMatchObject({ + muxMetadata: { + type: "workspace-turn-task", + taskHandleId: "wst_handle", + ownerWorkspaceId: parentId, + turnId: "turn", + }, + }); + }); + test("workspace-turn stream-end ignores unrelated mux metadata before falling back", async () => { const { parentId, taskService } = await startWorkspaceTurnForTest(); const internal = taskService as unknown as { diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 22d9b528a8..6fd6705ea1 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -49,7 +49,7 @@ import { type TaskSettings, } from "@/common/types/tasks"; -import { createMuxMessage, type MuxMessage } from "@/common/types/message"; +import { createMuxMessage, type MuxMessage, type MuxMessageMetadata } from "@/common/types/message"; import { createCompactionSummaryMessageId, createTaskFailureMessageId, @@ -379,6 +379,8 @@ export interface WorkspaceTurnWaitResult { finalMessageRef?: WorkspaceTurnFinalMessageRef; } +type WorkspaceTurnMuxMetadata = Extract; + interface BackgroundableForegroundWaiter { taskId: string; reject: (error: Error) => void; @@ -2800,12 +2802,7 @@ export class TaskService { model, agentId: "exec", ...(thinkingLevel != null ? { thinkingLevel } : {}), - muxMetadata: { - type: "workspace-turn-task", - taskHandleId: handleId, - ownerWorkspaceId, - turnId, - }, + muxMetadata: this.buildWorkspaceTurnMuxMetadata(record), experiments: args.experiments, }, { @@ -5690,6 +5687,17 @@ export class TaskService { return true; } + private buildWorkspaceTurnMuxMetadata( + record: Pick + ): WorkspaceTurnMuxMetadata { + return { + type: "workspace-turn-task", + taskHandleId: record.handleId, + ownerWorkspaceId: record.ownerWorkspaceId, + turnId: record.turnId, + }; + } + private getWorkspaceTurnMetadataFromValue( muxMetadata: unknown ): { taskHandleId: string; ownerWorkspaceId: string; turnId: string } | null { @@ -5838,6 +5846,33 @@ export class TaskService { return null; } + private async resolveWorkspaceTurnMuxMetadataForStreamEnd( + event: StreamEndEvent + ): Promise { + const metadata = this.getWorkspaceTurnMetadata(event); + if (metadata != null) { + return { + type: "workspace-turn-task", + ...metadata, + }; + } + if (event.metadata.muxMetadata != null) { + return undefined; + } + const active = this.activeWorkspaceTurnHandleByWorkspaceId.get(event.workspaceId); + if (active == null) { + return undefined; + } + const record = await this.taskHandleStore.getWorkspaceTurn( + active.ownerWorkspaceId, + active.handleId + ); + if (record == null || record.workspaceId !== event.workspaceId) { + return undefined; + } + return this.buildWorkspaceTurnMuxMetadata(record); + } + private async finalizeWorkspaceTurnFromStreamEnd(event: StreamEndEvent): Promise { const metadataFromEvent = this.getWorkspaceTurnMetadata(event); if (metadataFromEvent == null && event.metadata.muxMetadata != null) { @@ -6039,10 +6074,13 @@ export class TaskService { taskIds: blockingTaskIds, workflowRunIds: activeWorkflowRunIds, }); + const workspaceTurnMuxMetadata = + await this.resolveWorkspaceTurnMuxMetadataForStreamEnd(event); const sendOptions = { model: resumeOptions.model, agentId: resumeOptions.agentId, thinkingLevel: resumeOptions.thinkingLevel, + ...(workspaceTurnMuxMetadata != null ? { muxMetadata: workspaceTurnMuxMetadata } : {}), }; let sendResult = await this.workspaceService.sendMessage( workspaceId, From 5420cdcc8e4abd257f4740bedc91ff5d7284d5e5 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 15:16:47 +0000 Subject: [PATCH 06/17] Require correlated workspace turn settlement --- src/node/services/taskService.test.ts | 9 +-- src/node/services/taskService.ts | 97 ++++++++++++++++----------- 2 files changed, 63 insertions(+), 43 deletions(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 19a8801067..ee3f9d78bf 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -1274,7 +1274,7 @@ describe("TaskService", () => { }); }); - test("workspace-turn stream-end ignores unrelated mux metadata before falling back", async () => { + test("workspace-turn stream-end ignores unrelated mux metadata", async () => { const { parentId, taskService } = await startWorkspaceTurnForTest(); const internal = taskService as unknown as { handleStreamEnd: (event: StreamEndEvent) => Promise; @@ -1297,7 +1297,7 @@ describe("TaskService", () => { expect(snapshot).toMatchObject({ status: "running", workspaceId: "childworkspace" }); }); - test("workspace-turn stream-end falls back to the active handle when event metadata is absent", async () => { + test("workspace-turn stream-end without correlation metadata interrupts the active handle", async () => { const config = await createTestConfig(rootDir); stubStableIds(config, ["handle", "turn"]); const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); @@ -1353,11 +1353,12 @@ describe("TaskService", () => { const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); expect(snapshot).toMatchObject({ - status: "completed", + status: "interrupted", workspaceId: "childworkspace", messageId: "msg_1", - reportMarkdown: "Done without correlation metadata", + error: "Workspace turn superseded by an uncorrelated workspace stream-end", }); + expect(snapshot?.reportMarkdown).toBeUndefined(); }); test("workspace-turn stream errors mark the handle failed", async () => { diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 6fd6705ea1..0a371c1008 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -5846,65 +5846,85 @@ export class TaskService { return null; } - private async resolveWorkspaceTurnMuxMetadataForStreamEnd( + private resolveWorkspaceTurnMuxMetadataForStreamEnd( event: StreamEndEvent - ): Promise { + ): WorkspaceTurnMuxMetadata | undefined { const metadata = this.getWorkspaceTurnMetadata(event); - if (metadata != null) { - return { - type: "workspace-turn-task", - ...metadata, - }; - } - if (event.metadata.muxMetadata != null) { + if (metadata == null) { return undefined; } + return { + type: "workspace-turn-task", + ...metadata, + }; + } + + private async interruptWorkspaceTurnFromUncorrelatedStreamEnd( + event: StreamEndEvent + ): Promise { const active = this.activeWorkspaceTurnHandleByWorkspaceId.get(event.workspaceId); if (active == null) { - return undefined; + return false; } const record = await this.taskHandleStore.getWorkspaceTurn( active.ownerWorkspaceId, active.handleId ); - if (record == null || record.workspaceId !== event.workspaceId) { - return undefined; + if (record == null) { + this.activeWorkspaceTurnHandleByWorkspaceId.delete(event.workspaceId); + log.warn("Ignoring missing uncorrelated workspace turn stream-end handle", { + workspaceId: event.workspaceId, + taskHandleId: active.handleId, + }); + return true; + } + if (record.workspaceId !== event.workspaceId) { + log.warn("Ignoring out-of-scope uncorrelated workspace turn stream-end", { + workspaceId: event.workspaceId, + taskHandleId: record.handleId, + }); + return false; + } + if (record.status !== "starting" && record.status !== "running") { + this.activeWorkspaceTurnHandleByWorkspaceId.delete(event.workspaceId); + return true; } - return this.buildWorkspaceTurnMuxMetadata(record); + + const error = "Workspace turn superseded by an uncorrelated workspace stream-end"; + const next: WorkspaceTurnTaskHandleRecord = { + ...record, + status: "interrupted", + updatedAt: getIsoNow(), + messageId: event.messageId, + error, + }; + await this.settleWorkspaceTurn({ + record, + next, + waiterSettlement: { status: "error", error: new Error(error) }, + }); + return true; } private async finalizeWorkspaceTurnFromStreamEnd(event: StreamEndEvent): Promise { - const metadataFromEvent = this.getWorkspaceTurnMetadata(event); - if (metadataFromEvent == null && event.metadata.muxMetadata != null) { - return false; - } - const active = - metadataFromEvent == null - ? this.activeWorkspaceTurnHandleByWorkspaceId.get(event.workspaceId) - : null; - if (metadataFromEvent == null && active == null) { - return false; + const metadata = this.getWorkspaceTurnMetadata(event); + if (metadata == null) { + if (event.metadata.muxMetadata != null) { + return false; + } + return await this.interruptWorkspaceTurnFromUncorrelatedStreamEnd(event); } - const ownerWorkspaceId = metadataFromEvent?.ownerWorkspaceId ?? active?.ownerWorkspaceId; - const taskHandleId = metadataFromEvent?.taskHandleId ?? active?.handleId; - assert(ownerWorkspaceId != null, "workspace turn stream-end requires ownerWorkspaceId"); - assert(taskHandleId != null, "workspace turn stream-end requires taskHandleId"); - const record = await this.taskHandleStore.getWorkspaceTurn(ownerWorkspaceId, taskHandleId); + const record = await this.taskHandleStore.getWorkspaceTurn( + metadata.ownerWorkspaceId, + metadata.taskHandleId + ); if (record == null) { - if (active != null) { - this.activeWorkspaceTurnHandleByWorkspaceId.delete(event.workspaceId); - } log.warn("Ignoring missing workspace turn stream-end handle", { workspaceId: event.workspaceId, - taskHandleId: metadataFromEvent?.taskHandleId ?? active?.handleId, + taskHandleId: metadata.taskHandleId, }); return true; } - const metadata = metadataFromEvent ?? { - taskHandleId: record.handleId, - ownerWorkspaceId: record.ownerWorkspaceId, - turnId: record.turnId, - }; if (record.workspaceId !== event.workspaceId || record.turnId !== metadata.turnId) { log.warn("Ignoring out-of-scope workspace turn stream-end", { workspaceId: event.workspaceId, @@ -6074,8 +6094,7 @@ export class TaskService { taskIds: blockingTaskIds, workflowRunIds: activeWorkflowRunIds, }); - const workspaceTurnMuxMetadata = - await this.resolveWorkspaceTurnMuxMetadataForStreamEnd(event); + const workspaceTurnMuxMetadata = this.resolveWorkspaceTurnMuxMetadataForStreamEnd(event); const sendOptions = { model: resumeOptions.model, agentId: resumeOptions.agentId, From 46f5d4d241ad4e66dd3593cf7a252eb84223e4f1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 15:30:24 +0000 Subject: [PATCH 07/17] Block workspace turns in plan mode --- src/node/services/tools/task.test.ts | 44 ++++++++++++++++++++++++++++ src/node/services/tools/task.ts | 4 +++ 2 files changed, 48 insertions(+) diff --git a/src/node/services/tools/task.test.ts b/src/node/services/tools/task.test.ts index b3558fd273..b86d387b87 100644 --- a/src/node/services/tools/task.test.ts +++ b/src/node/services/tools/task.test.ts @@ -1032,6 +1032,50 @@ describe("task tool", () => { } }); + it("should reject workspace turns while in plan agent", async () => { + using tempDir = new TestTempDir("test-task-tool-plan-workspace"); + const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" }); + + const createWorkspaceTurn = mock(() => + Ok({ + taskId: "wst_child-turn", + kind: "workspace_turn" as const, + status: "running" as const, + workspaceId: "child-workspace", + }) + ); + const taskService = { createWorkspaceTurn } as unknown as TaskService; + + const tool = createTaskTool({ + ...baseConfig, + planFileOnly: true, + taskService, + }); + + let caught: unknown = null; + try { + await Promise.resolve( + tool.execute!( + { + kind: "workspace", + prompt: "implement it", + title: "Workspace turn", + run_in_background: true, + }, + mockToolCallOptions + ) + ); + } catch (error: unknown) { + caught = error; + } + + expect(caught).toBeInstanceOf(Error); + if (caught instanceof Error) { + expect(caught.message).toMatch(/plan agent/i); + } + expect(createWorkspaceTurn).not.toHaveBeenCalled(); + }); + it('should reject spawning "exec" tasks while in plan agent', async () => { using tempDir = new TestTempDir("test-task-tool"); 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 6441cd2448..5785daef1c 100644 --- a/src/node/services/tools/task.ts +++ b/src/node/services/tools/task.ts @@ -379,6 +379,10 @@ export const createTaskTool: ToolFactory = (config: ToolConfiguration) => { const parentRuntimeAiSettings = buildParentRuntimeAiSettings(config); + if (config.planFileOnly && kind === "workspace") { + throw new Error('In the plan agent you may only spawn agentId: "explore" tasks.'); + } + if (kind === "workspace") { const created = await taskService.createWorkspaceTurn({ ownerWorkspaceId: workspaceId, From aa6beb51218751350ab7fada68c2d138064a77c8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 15:47:25 +0000 Subject: [PATCH 08/17] Preserve workspace turn retry settlements --- .../agentSession.startupAutoRetry.test.ts | 41 +++++++++ src/node/services/agentSession.ts | 4 +- src/node/services/taskService.test.ts | 88 +++++++++++++++++++ src/node/services/taskService.ts | 71 ++++++++++++--- 4 files changed, 191 insertions(+), 13 deletions(-) diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index 14ad3f786e..4dbbb7d6cd 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -124,6 +124,47 @@ describe("AgentSession startup auto-retry recovery", () => { session.dispose(); }); + test("startup auto-retry reuses workspace-turn metadata from the retry user message", async () => { + const workspaceId = "startup-retry-workspace-turn-metadata"; + const { session, historyService, aiService, cleanup } = await createSessionBundle(workspaceId); + cleanups.push(cleanup); + const muxMetadata = { + type: "workspace-turn-task" as const, + taskHandleId: "wst_handle", + ownerWorkspaceId: "owner-workspace", + turnId: "turn-id", + }; + const appendResult = await historyService.appendToHistory( + workspaceId, + createMuxMessage("user-1", "user", "Complete the workspace turn", { + timestamp: Date.now(), + retrySendOptions: { model: "openai:gpt-4o", agentId: "exec" }, + muxMetadata, + }) + ); + expect(appendResult.success).toBe(true); + const streamMessageMock = mock((_payload: Parameters[0]) => + Promise.resolve(Ok(undefined)) + ); + aiService.streamMessage = streamMessageMock as unknown as AIService["streamMessage"]; + const privateSession = session as unknown as { + retryActiveStream: () => Promise; + startupAutoRetryCheckPromise: Promise | null; + lastAutoRetryResumeRequest?: AutoRetryResumeRequest; + }; + + session.ensureStartupAutoRetryCheck(); + await privateSession.startupAutoRetryCheckPromise; + expect(privateSession.lastAutoRetryResumeRequest?.options.muxMetadata).toBeUndefined(); + + await privateSession.retryActiveStream(); + + expect(streamMessageMock).toHaveBeenCalledTimes(1); + expect(streamMessageMock.mock.calls[0]?.[0]).toMatchObject({ muxMetadata }); + + session.dispose(); + }); + test("re-runs startup auto-retry check after busy startup state clears", async () => { const workspaceId = "startup-retry-busy-rerun"; const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 600064b1ab..f9a6a69ff3 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -3543,7 +3543,9 @@ export class AgentSession { // Bind recordFileState to this session for the propose_plan tool const recordFileState = this.fileChangeTracker.record.bind(this.fileChangeTracker); - const typedMuxMetadata = options?.muxMetadata as MuxMessageMetadata | undefined; + const typedMuxMetadata = + (options?.muxMetadata as MuxMessageMetadata | undefined) ?? + lastUserMessage?.metadata?.muxMetadata; const acpPromptId = normalizeAcpPromptId(options?.acpPromptId) ?? extractAcpPromptId(typedMuxMetadata); const delegatedToolNames = diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index ee3f9d78bf..772f9ac4cf 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -1508,6 +1508,94 @@ describe("TaskService", () => { expect(report.reportMarkdown).toBe("Done"); }); + test("workspace-turn terminal settlements do not overwrite each other", async () => { + const completed = await startWorkspaceTurnForTest(); + const staleRunningRecord = await completed.taskService.getWorkspaceTurnSnapshot( + completed.parentId, + "wst_handle" + ); + assert(staleRunningRecord, "expected running workspace-turn record"); + const completedInternal = completed.taskService as unknown as { + handleStreamEnd: (event: StreamEndEvent) => Promise; + settleWorkspaceTurn: (params: unknown) => Promise; + }; + await completedInternal.handleStreamEnd({ + type: "stream-end", + workspaceId: "childworkspace", + messageId: "msg_done", + metadata: { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "stop", + muxMetadata: { + type: "workspace-turn-task", + taskHandleId: "wst_handle", + ownerWorkspaceId: completed.parentId, + turnId: "turn", + }, + }, + parts: [{ type: "text", text: "Done" }], + }); + await completedInternal.settleWorkspaceTurn({ + record: staleRunningRecord, + next: { + ...staleRunningRecord, + status: "interrupted", + updatedAt: "2026-06-19T00:00:01.000Z", + }, + waiterSettlement: { status: "error", error: new Error("late interrupt") }, + }); + expect( + await completed.taskService.getWorkspaceTurnSnapshot(completed.parentId, "wst_handle") + ).toMatchObject({ + status: "completed", + messageId: "msg_done", + reportMarkdown: "Done", + }); + + const interrupted = await startWorkspaceTurnForTest({ + stableIds: ["secondhandle", "secondturn"], + }); + const staleInterruptedRecord = await interrupted.taskService.getWorkspaceTurnSnapshot( + interrupted.parentId, + "wst_secondhandle" + ); + assert(staleInterruptedRecord, "expected second running workspace-turn record"); + const interruptResult = await interrupted.taskService.interruptWorkspaceTurn( + interrupted.parentId, + "wst_secondhandle" + ); + expect(interruptResult.success).toBe(true); + await ( + interrupted.taskService as unknown as { + settleWorkspaceTurn: (params: unknown) => Promise; + } + ).settleWorkspaceTurn({ + record: staleInterruptedRecord, + next: { + ...staleInterruptedRecord, + status: "completed", + updatedAt: "2026-06-19T00:00:01.000Z", + messageId: "msg_late_done", + reportMarkdown: "Late done", + }, + waiterSettlement: { + status: "completed", + result: { + taskId: "wst_secondhandle", + workspaceId: "childworkspace", + reportMarkdown: "Late done", + }, + }, + }); + const interruptedSnapshot = await interrupted.taskService.getWorkspaceTurnSnapshot( + interrupted.parentId, + "wst_secondhandle" + ); + expect(interruptedSnapshot).toMatchObject({ status: "interrupted" }); + expect(interruptedSnapshot?.reportMarkdown).toBeUndefined(); + }); + test("waitForWorkspaceTurn foreground waits can be sent to background", async () => { const { parentId, taskService } = await startWorkspaceTurnForTest(); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 0a371c1008..fead6e2d7d 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -894,6 +894,9 @@ export class TaskService { // concurrently from multiple child stream-end handlers for the same parent, and it must remain // safe even when the parent stream-end already holds workspaceEventLocks for the parent itself. private readonly deferredBestOfLocks = new MutexMap(); + // Serialize terminal writes per workspace-turn handle so late completions/interruptions cannot + // overwrite an already-settled handle. + private readonly workspaceTurnSettlementLocks = new MutexMap(); private readonly mutex = new AsyncMutex(); private maybeStartQueuedTasksInFlight: Promise | undefined; private maybeStartQueuedTasksRerunRequested = false; @@ -3833,6 +3836,10 @@ export class TaskService { } } + private isTerminalWorkspaceTurnStatus(status: WorkspaceTurnTaskStatus): boolean { + return status === "completed" || status === "interrupted" || status === "error"; + } + private async settleWorkspaceTurn(params: { record: WorkspaceTurnTaskHandleRecord; next: WorkspaceTurnTaskHandleRecord; @@ -3849,18 +3856,58 @@ export class TaskService { "settleWorkspaceTurn requires stable workspaceId" ); - await this.taskHandleStore.upsertWorkspaceTurn(params.next); - const active = this.activeWorkspaceTurnHandleByWorkspaceId.get(params.record.workspaceId); - if ( - active?.handleId === params.record.handleId && - active.ownerWorkspaceId === params.record.ownerWorkspaceId - ) { - this.activeWorkspaceTurnHandleByWorkspaceId.delete(params.record.workspaceId); - } - this.settleWorkspaceTurnWaiters(params.record.handleId, params.waiterSettlement); - this.markTaskForegroundRelevant(params.record.handleId); - await this.cleanupDisposableWorkspaceTurn(params.next); - this.scheduleMaybeStartQueuedTasks(); + await this.workspaceTurnSettlementLocks.withLock(params.record.handleId, async () => { + const current = await this.taskHandleStore.getWorkspaceTurn( + params.record.ownerWorkspaceId, + params.record.handleId + ); + if (current == null) { + return; + } + assert( + current.workspaceId === params.record.workspaceId, + "settleWorkspaceTurn requires current record to match workspaceId" + ); + + if (this.isTerminalWorkspaceTurnStatus(current.status)) { + const active = this.activeWorkspaceTurnHandleByWorkspaceId.get(params.record.workspaceId); + if ( + active?.handleId === params.record.handleId && + active.ownerWorkspaceId === params.record.ownerWorkspaceId + ) { + this.activeWorkspaceTurnHandleByWorkspaceId.delete(params.record.workspaceId); + } + this.settleWorkspaceTurnWaiters( + current.handleId, + current.status === "completed" + ? { status: "completed", result: this.buildWorkspaceTurnWaitResult(current) } + : { + status: "error", + error: new Error( + current.error ?? + (current.status === "interrupted" + ? "Workspace turn interrupted" + : "Workspace turn failed") + ), + } + ); + this.markTaskForegroundRelevant(current.handleId); + return; + } + + await this.taskHandleStore.upsertWorkspaceTurn(params.next); + const active = this.activeWorkspaceTurnHandleByWorkspaceId.get(params.record.workspaceId); + if ( + active?.handleId === params.record.handleId && + active.ownerWorkspaceId === params.record.ownerWorkspaceId + ) { + this.activeWorkspaceTurnHandleByWorkspaceId.delete(params.record.workspaceId); + } + this.settleWorkspaceTurnWaiters(params.record.handleId, params.waiterSettlement); + this.markTaskForegroundRelevant(params.record.handleId); + await this.cleanupDisposableWorkspaceTurn(params.next); + this.scheduleMaybeStartQueuedTasks(); + }); } async waitForWorkspaceTurn( From 28e8b183d098c419db4a8ceb3e2a01b20b64ce13 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 15:58:39 +0000 Subject: [PATCH 09/17] Skip deferred workspace turn recovery --- src/node/services/taskHandleStore.ts | 2 + src/node/services/taskService.test.ts | 67 +++++++++++++++++++++++++++ src/node/services/taskService.ts | 38 +++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/src/node/services/taskHandleStore.ts b/src/node/services/taskHandleStore.ts index b69ad6d10e..b9228ee48c 100644 --- a/src/node/services/taskHandleStore.ts +++ b/src/node/services/taskHandleStore.ts @@ -50,6 +50,7 @@ export interface WorkspaceTurnTaskHandleRecord { parts?: CompletedMessagePart[]; metadata: StreamEndEvent["metadata"]; }; + deferredMessageIds?: string[]; error?: string; } @@ -80,6 +81,7 @@ const WorkspaceTurnTaskHandleRecordSchema = z }) .passthrough() .optional(), + deferredMessageIds: z.array(z.string().min(1)).optional(), error: z.string().optional(), }) .strict(); diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 772f9ac4cf..196c1ea2c0 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -1225,6 +1225,73 @@ describe("TaskService", () => { expect(workspaceMocks.sendMessage.mock.calls[1]?.[0]).toBe("childworkspace"); }); + test("workspace-turn stale recovery skips deferred pre-handoff stream-end history", async () => { + const { config, parentId, projectPath, taskService, historyService } = + await startWorkspaceTurnForTest(); + await config.editConfig((cfg) => { + const project = Array.from(cfg.projects.values())[0]; + assert(project, "test project must exist"); + project.workspaces.push({ + path: path.join(projectPath, "descendant-task"), + id: "descendant-task", + name: "descendant-task", + createdAt: "2026-06-19T00:00:00.000Z", + runtimeConfig: { type: "local" }, + parentWorkspaceId: "childworkspace", + taskStatus: "running", + }); + return cfg; + }); + const muxMetadata = { + type: "workspace-turn-task" as const, + taskHandleId: "wst_handle", + ownerWorkspaceId: parentId, + turnId: "turn", + }; + const appendResult = await historyService.appendToHistory( + "childworkspace", + createMuxMessage("msg_prehandoff", "assistant", "Premature final text", { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "stop", + muxMetadata, + }) + ); + expect(appendResult.success).toBe(true); + const internal = taskService as unknown as { + activeWorkspaceTurnHandleByWorkspaceId: Map< + string, + { handleId: string; ownerWorkspaceId: string } + >; + handleStreamEnd: (event: StreamEndEvent) => Promise; + }; + + await internal.handleStreamEnd({ + type: "stream-end", + workspaceId: "childworkspace", + messageId: "msg_prehandoff", + metadata: { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "stop", + muxMetadata, + }, + parts: [{ type: "text", text: "Premature final text" }], + }); + + expect(await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle")).toMatchObject({ + status: "running", + deferredMessageIds: ["msg_prehandoff"], + }); + internal.activeWorkspaceTurnHandleByWorkspaceId.clear(); + const recovered = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); + expect(recovered).toMatchObject({ + status: "interrupted", + error: "Workspace turn interrupted after restart", + }); + expect(recovered?.reportMarkdown).toBeUndefined(); + }); + test("workspace-turn auto-resume preserves handle metadata", async () => { const { config, parentId, projectPath, taskService, workspaceMocks } = await startWorkspaceTurnForTest(); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index fead6e2d7d..0ca0a74218 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -5870,6 +5870,14 @@ export class TaskService { }; } + private isDeferredWorkspaceTurnMessage( + record: WorkspaceTurnTaskHandleRecord, + messageId: string + ): boolean { + assert(messageId.length > 0, "isDeferredWorkspaceTurnMessage requires messageId"); + return record.deferredMessageIds?.includes(messageId) === true; + } + private async recoverTerminalWorkspaceTurnFromHistory( record: WorkspaceTurnTaskHandleRecord ): Promise { @@ -5885,6 +5893,9 @@ export class TaskService { return null; } for (const message of historyResult.data.toReversed()) { + if (this.isDeferredWorkspaceTurnMessage(record, message.id)) { + continue; + } const event = this.buildWorkspaceTurnStreamEndEventFromHistory(record, message); if (event != null) { return this.buildTerminalWorkspaceTurnRecordFromEvent(record, event); @@ -5893,6 +5904,31 @@ export class TaskService { return null; } + private async markWorkspaceTurnStreamEndDeferred(event: StreamEndEvent): Promise { + const metadata = this.getWorkspaceTurnMetadata(event); + if (metadata == null) { + return; + } + const record = await this.taskHandleStore.getWorkspaceTurn( + metadata.ownerWorkspaceId, + metadata.taskHandleId + ); + if ( + record == null || + record.workspaceId !== event.workspaceId || + record.turnId !== metadata.turnId || + !this.isActiveWorkspaceTurn(record) || + this.isDeferredWorkspaceTurnMessage(record, event.messageId) + ) { + return; + } + await this.taskHandleStore.upsertWorkspaceTurn({ + ...record, + updatedAt: getIsoNow(), + deferredMessageIds: [...(record.deferredMessageIds ?? []), event.messageId], + }); + } + private resolveWorkspaceTurnMuxMetadataForStreamEnd( event: StreamEndEvent ): WorkspaceTurnMuxMetadata | undefined { @@ -6032,6 +6068,8 @@ export class TaskService { } } + await this.markWorkspaceTurnStreamEndDeferred(event); + if (this.aiService.isStreaming(workspaceId)) { return; } From 0aefdf33ddb7a247800c2a5ad9c0e54b712f5ae2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 16:06:31 +0000 Subject: [PATCH 10/17] Avoid deferred workspace turn finalization --- src/node/services/taskService.test.ts | 33 +++++++++++++++++++++++++++ src/node/services/taskService.ts | 3 +++ 2 files changed, 36 insertions(+) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 196c1ea2c0..5254f88837 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -1225,6 +1225,39 @@ describe("TaskService", () => { expect(workspaceMocks.sendMessage.mock.calls[1]?.[0]).toBe("childworkspace"); }); + test("workspace-turn deferred stream-end does not finalize the handle", async () => { + const { parentId, taskService } = await startWorkspaceTurnForTest(); + const event: StreamEndEvent = { + type: "stream-end", + workspaceId: "childworkspace", + messageId: "msg_deferred", + metadata: { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "stop", + muxMetadata: { + type: "workspace-turn-task", + taskHandleId: "wst_handle", + ownerWorkspaceId: parentId, + turnId: "turn", + }, + }, + parts: [{ type: "text", text: "Pre-handoff text" }], + }; + const internal = taskService as unknown as { + markWorkspaceTurnStreamEndDeferred: (event: StreamEndEvent) => Promise; + finalizeWorkspaceTurnFromStreamEnd: (event: StreamEndEvent) => Promise; + }; + + await internal.markWorkspaceTurnStreamEndDeferred(event); + expect(await internal.finalizeWorkspaceTurnFromStreamEnd(event)).toBe(true); + + expect(await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle")).toMatchObject({ + status: "running", + deferredMessageIds: ["msg_deferred"], + }); + }); + test("workspace-turn stale recovery skips deferred pre-handoff stream-end history", async () => { const { config, parentId, projectPath, taskService, historyService } = await startWorkspaceTurnForTest(); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 0ca0a74218..02e6a1d80e 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -6015,6 +6015,9 @@ export class TaskService { }); return true; } + if (this.isDeferredWorkspaceTurnMessage(record, event.messageId)) { + return true; + } const next = this.buildTerminalWorkspaceTurnRecordFromEvent(record, event); await this.settleWorkspaceTurn({ From b2c9458adfe02efe7b18cb3feb9146f6f9817067 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 16:17:52 +0000 Subject: [PATCH 11/17] Guard deferred workspace turn markers --- src/node/services/taskService.test.ts | 31 +++++++++++++++++++++++ src/node/services/taskService.ts | 36 ++++++++++++++------------- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 5254f88837..5c43736f98 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -1258,6 +1258,37 @@ describe("TaskService", () => { }); }); + test("workspace-turn deferred marker does not rewrite terminal handles", async () => { + const { parentId, taskService } = await startWorkspaceTurnForTest(); + const interruptResult = await taskService.interruptWorkspaceTurn(parentId, "wst_handle"); + expect(interruptResult.success).toBe(true); + await ( + taskService as unknown as { + markWorkspaceTurnStreamEndDeferred: (event: StreamEndEvent) => Promise; + } + ).markWorkspaceTurnStreamEndDeferred({ + type: "stream-end", + workspaceId: "childworkspace", + messageId: "msg_deferred_after_interrupt", + metadata: { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "stop", + muxMetadata: { + type: "workspace-turn-task", + taskHandleId: "wst_handle", + ownerWorkspaceId: parentId, + turnId: "turn", + }, + }, + parts: [{ type: "text", text: "Pre-handoff text" }], + }); + + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); + expect(snapshot).toMatchObject({ status: "interrupted" }); + expect(snapshot?.deferredMessageIds).toBeUndefined(); + }); + test("workspace-turn stale recovery skips deferred pre-handoff stream-end history", async () => { const { config, parentId, projectPath, taskService, historyService } = await startWorkspaceTurnForTest(); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 02e6a1d80e..cc8ee1f1d8 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -5909,23 +5909,25 @@ export class TaskService { if (metadata == null) { return; } - const record = await this.taskHandleStore.getWorkspaceTurn( - metadata.ownerWorkspaceId, - metadata.taskHandleId - ); - if ( - record == null || - record.workspaceId !== event.workspaceId || - record.turnId !== metadata.turnId || - !this.isActiveWorkspaceTurn(record) || - this.isDeferredWorkspaceTurnMessage(record, event.messageId) - ) { - return; - } - await this.taskHandleStore.upsertWorkspaceTurn({ - ...record, - updatedAt: getIsoNow(), - deferredMessageIds: [...(record.deferredMessageIds ?? []), event.messageId], + await this.workspaceTurnSettlementLocks.withLock(metadata.taskHandleId, async () => { + const record = await this.taskHandleStore.getWorkspaceTurn( + metadata.ownerWorkspaceId, + metadata.taskHandleId + ); + if ( + record == null || + record.workspaceId !== event.workspaceId || + record.turnId !== metadata.turnId || + !this.isActiveWorkspaceTurn(record) || + this.isDeferredWorkspaceTurnMessage(record, event.messageId) + ) { + return; + } + await this.taskHandleStore.upsertWorkspaceTurn({ + ...record, + updatedAt: getIsoNow(), + deferredMessageIds: [...(record.deferredMessageIds ?? []), event.messageId], + }); }); } From 896b61b12abc72c44dc7b300a927b5118fd56b3f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 16:31:32 +0000 Subject: [PATCH 12/17] Refine workspace turn abort errors --- src/node/services/taskService.test.ts | 63 +++++++++++++++++++++++++++ src/node/services/taskService.ts | 13 ++++-- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 5c43736f98..0643ef28b7 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -1550,6 +1550,27 @@ describe("TaskService", () => { }); }); + test("workspace-turn terminal stream errors mark the handle failed", async () => { + const { parentId, taskService } = await startWorkspaceTurnForTest(); + const internal = taskService as unknown as { + handleTaskStreamError: (event: ErrorEvent) => Promise; + }; + + await internal.handleTaskStreamError({ + type: "error", + workspaceId: "childworkspace", + messageId: "msg_unknown_error", + error: "Provider returned no usable result", + errorType: "unknown", + }); + + expect(await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle")).toMatchObject({ + status: "error", + workspaceId: "childworkspace", + error: "Provider returned no usable result", + }); + }); + test("workspace-turn recoverable stream errors leave the handle running", async () => { const { parentId, taskService } = await startWorkspaceTurnForTest(); const internal = taskService as unknown as { @@ -1571,6 +1592,48 @@ describe("TaskService", () => { }); }); + test("workspace-turn system stream aborts keep the handle running for resume", async () => { + const { parentId, taskService } = await startWorkspaceTurnForTest(); + const internal = taskService as unknown as { + handleStreamAbort: (event: StreamAbortEvent) => Promise; + handleStreamEnd: (event: StreamEndEvent) => Promise; + }; + + await internal.handleStreamAbort({ + type: "stream-abort", + workspaceId: "childworkspace", + messageId: "msg_system_abort", + abortReason: "system", + }); + expect(await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle")).toMatchObject({ + status: "running", + workspaceId: "childworkspace", + }); + + await internal.handleStreamEnd({ + type: "stream-end", + workspaceId: "childworkspace", + messageId: "msg_resumed", + metadata: { + model: "anthropic:claude-opus-4-6", + agentId: "exec", + finishReason: "stop", + muxMetadata: { + type: "workspace-turn-task", + taskHandleId: "wst_handle", + ownerWorkspaceId: parentId, + turnId: "turn", + }, + }, + parts: [{ type: "text", text: "Resumed done" }], + }); + expect(await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle")).toMatchObject({ + status: "completed", + messageId: "msg_resumed", + reportMarkdown: "Resumed done", + }); + }); + test("workspace-turn stream aborts mark the handle interrupted", async () => { const { parentId, taskService } = await startWorkspaceTurnForTest(); const internal = taskService as unknown as { diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index cc8ee1f1d8..193badda22 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -499,6 +499,12 @@ const MAX_CONSECUTIVE_PARENT_AUTO_RESUMES = 3; */ const MAX_TASK_RECOVERY_ATTEMPTS = 5; +const WORKSPACE_TURN_RECOVERABLE_STREAM_ERRORS: ReadonlySet = new Set([ + "aborted", + "context_exceeded", + "runtime_start_failed", +]); + /** * Provider-terminal stream errors that settle a child task even while it is * still `running` (before it owes its completion tool). Subset of @@ -6381,6 +6387,9 @@ export class TaskService { this.activeWorkspaceTurnHandleByWorkspaceId.delete(event.workspaceId); return true; } + if (event.abortReason !== "user") { + return true; + } const next: WorkspaceTurnTaskHandleRecord = { ...record, status: "interrupted", @@ -6411,9 +6420,7 @@ export class TaskService { this.activeWorkspaceTurnHandleByWorkspaceId.delete(event.workspaceId); return true; } - const settlesWorkspaceTurn = - event.errorType != null && RUNNING_TASK_TERMINAL_STREAM_ERRORS.has(event.errorType); - if (!settlesWorkspaceTurn) { + if (event.errorType != null && WORKSPACE_TURN_RECOVERABLE_STREAM_ERRORS.has(event.errorType)) { return true; } const next: WorkspaceTurnTaskHandleRecord = { From 9d72dc2113445e7c0e6048686edfb64e110bcb23 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 16:41:00 +0000 Subject: [PATCH 13/17] Settle restarted workspace turn errors --- src/node/services/taskService.test.ts | 5 ++++ src/node/services/taskService.ts | 33 +++++++++++++++++++-------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 0643ef28b7..6413de20dc 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -1553,9 +1553,14 @@ describe("TaskService", () => { test("workspace-turn terminal stream errors mark the handle failed", async () => { const { parentId, taskService } = await startWorkspaceTurnForTest(); const internal = taskService as unknown as { + activeWorkspaceTurnHandleByWorkspaceId: Map< + string, + { handleId: string; ownerWorkspaceId: string } + >; handleTaskStreamError: (event: ErrorEvent) => Promise; }; + internal.activeWorkspaceTurnHandleByWorkspaceId.clear(); await internal.handleTaskStreamError({ type: "error", workspaceId: "childworkspace", diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 193badda22..de3087e1eb 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -6407,18 +6407,31 @@ export class TaskService { await this.finalizeWorkspaceTurnFromStreamAbort(event); } - private async finalizeWorkspaceTurnFromStreamError(event: ErrorEvent): Promise { - const active = this.activeWorkspaceTurnHandleByWorkspaceId.get(event.workspaceId); - if (active == null) { - return false; + private async getActiveWorkspaceTurnRecordForWorkspace( + workspaceId: string + ): Promise { + const active = this.activeWorkspaceTurnHandleByWorkspaceId.get(workspaceId); + if (active != null) { + const record = await this.taskHandleStore.getWorkspaceTurn( + active.ownerWorkspaceId, + active.handleId + ); + if (record != null) { + return record; + } + this.activeWorkspaceTurnHandleByWorkspaceId.delete(workspaceId); } - const record = await this.taskHandleStore.getWorkspaceTurn( - active.ownerWorkspaceId, - active.handleId - ); + + const records = await this.taskHandleStore.listAllWorkspaceTurns({ + statuses: ["starting", "running"], + }); + return records.toReversed().find((record) => record.workspaceId === workspaceId) ?? null; + } + + private async finalizeWorkspaceTurnFromStreamError(event: ErrorEvent): Promise { + const record = await this.getActiveWorkspaceTurnRecordForWorkspace(event.workspaceId); if (record == null) { - this.activeWorkspaceTurnHandleByWorkspaceId.delete(event.workspaceId); - return true; + return false; } if (event.errorType != null && WORKSPACE_TURN_RECOVERABLE_STREAM_ERRORS.has(event.errorType)) { return true; From 945f617180b72bb0aef2084fcba6261c7434fcfd Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 16:52:34 +0000 Subject: [PATCH 14/17] Keep startup retry workspace turns live --- src/node/services/taskService.test.ts | 28 +++++++++++++++++++++++++++ src/node/services/taskService.ts | 3 ++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 6413de20dc..16f5aeea03 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -513,6 +513,7 @@ describe("TaskService", () => { sendMessage?: ReturnType; remove?: ReturnType; isStreaming?: ReturnType; + hasPendingQueuedOrPreparingTurn?: ReturnType; } = {} ) { const config = await createTestConfig(rootDir); @@ -543,6 +544,9 @@ describe("TaskService", () => { create: createWorkspace, ...(options.sendMessage != null ? { sendMessage: options.sendMessage } : {}), ...(options.remove != null ? { remove: options.remove } : {}), + ...(options.hasPendingQueuedOrPreparingTurn != null + ? { hasPendingQueuedOrPreparingTurn: options.hasPendingQueuedOrPreparingTurn } + : {}), }); const aiMocks = createAIServiceMocks(config, { ...(options.isStreaming != null ? { isStreaming: options.isStreaming } : {}), @@ -944,6 +948,30 @@ describe("TaskService", () => { }); }); + test("active workspace turn count keeps startup-retrying handles live", async () => { + const hasPendingQueuedOrPreparingTurn = mock( + (workspaceId: string) => workspaceId === "childworkspace" + ); + const { parentId, taskService } = await startWorkspaceTurnForTest({ + hasPendingQueuedOrPreparingTurn, + }); + const internal = taskService as unknown as { + activeWorkspaceTurnHandleByWorkspaceId: Map< + string, + { handleId: string; ownerWorkspaceId: string } + >; + countActiveWorkspaceTurns: () => Promise; + }; + + internal.activeWorkspaceTurnHandleByWorkspaceId.clear(); + expect(await internal.countActiveWorkspaceTurns()).toBe(1); + expect(hasPendingQueuedOrPreparingTurn).toHaveBeenCalledWith("childworkspace"); + + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); + expect(snapshot).toMatchObject({ status: "running", workspaceId: "childworkspace" }); + expect(snapshot?.error).toBeUndefined(); + }); + test("getWorkspaceTurnSnapshot settles stale active handles before returning", async () => { const { parentId, taskService } = await startWorkspaceTurnForTest(); const internal = taskService as unknown as { diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index de3087e1eb..2b6abf81b5 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -4952,7 +4952,8 @@ export class TaskService { return ( (active?.handleId === record.handleId && active.ownerWorkspaceId === record.ownerWorkspaceId) || - this.aiService.isStreaming(record.workspaceId) + this.aiService.isStreaming(record.workspaceId) || + this.workspaceService.hasPendingQueuedOrPreparingTurn(record.workspaceId) ); } From 704b373b9bc580068dbe2cb6e8f5e2e8f08e4967 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 17:03:13 +0000 Subject: [PATCH 15/17] Filter stream mux metadata to workspace turns --- .../agentSession.startupAutoRetry.test.ts | 36 +++++++++++++++++++ src/node/services/agentSession.ts | 17 +++++---- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index 4dbbb7d6cd..6d64eb2f0e 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -165,6 +165,42 @@ describe("AgentSession startup auto-retry recovery", () => { session.dispose(); }); + test("startup auto-retry does not stamp workflow-result metadata on assistant streams", async () => { + const workspaceId = "startup-retry-workflow-result-metadata"; + const { session, historyService, aiService, cleanup } = await createSessionBundle(workspaceId); + cleanups.push(cleanup); + const appendResult = await historyService.appendToHistory( + workspaceId, + createMuxMessage("user-1", "user", "Use the workflow result", { + timestamp: Date.now(), + retrySendOptions: { model: "openai:gpt-4o", agentId: "exec" }, + muxMetadata: { + type: "workflow-result", + rawCommand: "/deep-research mux", + runId: "wfr_1", + }, + }) + ); + expect(appendResult.success).toBe(true); + const streamMessageMock = mock((_payload: Parameters[0]) => + Promise.resolve(Ok(undefined)) + ); + aiService.streamMessage = streamMessageMock as unknown as AIService["streamMessage"]; + const privateSession = session as unknown as { + retryActiveStream: () => Promise; + startupAutoRetryCheckPromise: Promise | null; + }; + + session.ensureStartupAutoRetryCheck(); + await privateSession.startupAutoRetryCheckPromise; + await privateSession.retryActiveStream(); + + expect(streamMessageMock).toHaveBeenCalledTimes(1); + expect(streamMessageMock.mock.calls[0]?.[0].muxMetadata).toBeUndefined(); + + session.dispose(); + }); + test("re-runs startup auto-retry check after busy startup state clears", async () => { const workspaceId = "startup-retry-busy-rerun"; const { session, historyService, events, cleanup } = await createSessionBundle(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index f9a6a69ff3..3f10f84656 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -3543,14 +3543,19 @@ export class AgentSession { // Bind recordFileState to this session for the propose_plan tool const recordFileState = this.fileChangeTracker.record.bind(this.fileChangeTracker); - const typedMuxMetadata = - (options?.muxMetadata as MuxMessageMetadata | undefined) ?? - lastUserMessage?.metadata?.muxMetadata; + const optionsMuxMetadata = options?.muxMetadata as MuxMessageMetadata | undefined; + const retryMuxMetadata = lastUserMessage?.metadata?.muxMetadata; + const streamMuxMetadata = + optionsMuxMetadata?.type === "workspace-turn-task" + ? optionsMuxMetadata + : retryMuxMetadata?.type === "workspace-turn-task" + ? retryMuxMetadata + : undefined; const acpPromptId = - normalizeAcpPromptId(options?.acpPromptId) ?? extractAcpPromptId(typedMuxMetadata); + normalizeAcpPromptId(options?.acpPromptId) ?? extractAcpPromptId(optionsMuxMetadata); const delegatedToolNames = normalizeDelegatedToolNames(options?.delegatedToolNames) ?? - extractAcpDelegatedTools(typedMuxMetadata); + extractAcpDelegatedTools(optionsMuxMetadata); const streamResult = await this.aiService.streamMessage({ messages: historyResult.data, @@ -3567,7 +3572,7 @@ export class AgentSession { agentId: options?.agentId, acpPromptId, delegatedToolNames, - muxMetadata: typedMuxMetadata, + muxMetadata: streamMuxMetadata, recordFileState, changedFileAttachments: changedFileAttachments.length > 0 ? changedFileAttachments : undefined, From 564c236a1dca667006fd4f3c8ff921b5113fc188 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 17:19:17 +0000 Subject: [PATCH 16/17] Settle workspace turn retry exhaustion --- src/node/services/agentSession.ts | 4 + src/node/services/taskService.test.ts | 114 +++++++++++++++++++++++++- src/node/services/taskService.ts | 45 +++++++--- src/node/services/workspaceService.ts | 4 +- 4 files changed, 152 insertions(+), 15 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 3f10f84656..a570d544b2 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -5000,6 +5000,10 @@ export class AgentSession { return !this.messageQueue.isEmpty(); } + hasPendingAutoRetry(): boolean { + return this.retryManager.isRetryPending; + } + hasPendingManualFollowUp(): boolean { return !this.messageQueue.isEmpty() || this.pendingExternalManualFollowUps > 0; } diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 16f5aeea03..2f36b4ccda 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -29,6 +29,7 @@ import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataServi import { SessionUsageService } from "@/node/services/sessionUsageService"; import { WorkspaceGoalService } from "@/node/services/workspaceGoalService"; import { IdleDispatcher } from "@/node/services/idleDispatcher"; +import { TaskHandleStore } from "@/node/services/taskHandleStore"; import { TaskService, ForegroundWaitBackgroundedError } from "@/node/services/taskService"; import { WorkflowRunStore } from "@/node/services/workflows/WorkflowRunStore"; import { recordAgentWorkflowRunReference } from "@/node/services/agentWorkflowRunReferences"; @@ -1604,8 +1605,13 @@ describe("TaskService", () => { }); }); - test("workspace-turn recoverable stream errors leave the handle running", async () => { - const { parentId, taskService } = await startWorkspaceTurnForTest(); + test("workspace-turn recoverable stream errors stay running while retry is pending", async () => { + const hasPendingQueuedOrPreparingTurn = mock( + (workspaceId: string) => workspaceId === "childworkspace" + ); + const { parentId, taskService } = await startWorkspaceTurnForTest({ + hasPendingQueuedOrPreparingTurn, + }); const internal = taskService as unknown as { handleTaskStreamError: (event: ErrorEvent) => Promise; }; @@ -1625,6 +1631,28 @@ describe("TaskService", () => { }); }); + test("workspace-turn exhausted recoverable stream errors mark the handle failed", async () => { + const { parentId, taskService } = await startWorkspaceTurnForTest(); + const internal = taskService as unknown as { + handleTaskStreamError: (event: ErrorEvent) => Promise; + }; + + await internal.handleTaskStreamError({ + type: "error", + workspaceId: "childworkspace", + messageId: "msg_exhausted_context", + error: "Context still too large after retry", + errorType: "context_exceeded", + }); + + const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); + expect(snapshot).toMatchObject({ + status: "error", + workspaceId: "childworkspace", + error: "Context still too large after retry", + }); + }); + test("workspace-turn system stream aborts keep the handle running for resume", async () => { const { parentId, taskService } = await startWorkspaceTurnForTest(); const internal = taskService as unknown as { @@ -8105,6 +8133,88 @@ describe("TaskService", () => { expect(ws?.taskStatus).toBe("running"); }); + test("does not accept agent_report while task-owned workspace turns are still active", async () => { + const config = await createTestConfig(rootDir); + + const projectPath = path.join(rootDir, "repo"); + const rootWorkspaceId = "root-111"; + const parentTaskId = "task-222"; + const workspaceTurnId = "workspace-turn-child"; + const workspaceTurnHandleId = "wst_childturn"; + + await saveWorkspaces( + config, + projectPath, + [ + projectWorkspace(projectPath, "root", rootWorkspaceId), + projectWorkspace(projectPath, "parent-task", parentTaskId, { + name: "agent_exec_parent", + parentWorkspaceId: rootWorkspaceId, + agentType: "exec", + taskStatus: "running", + }), + ], + testTaskSettings() + ); + await new TaskHandleStore(config).upsertWorkspaceTurn({ + kind: "workspace_turn", + handleId: workspaceTurnHandleId, + ownerWorkspaceId: parentTaskId, + workspaceId: workspaceTurnId, + turnId: "turn-1", + status: "running", + createdAt: "2026-06-19T00:00:00.000Z", + updatedAt: "2026-06-19T00:00:00.000Z", + createdWorkspace: true, + disposableWorkspace: false, + }); + + const remove = mock((): Promise> => Promise.resolve(Ok(undefined))); + const { workspaceService, sendMessage } = createWorkspaceServiceMocks({ remove }); + const { taskService } = createTaskServiceHarness(config, { workspaceService }); + ( + taskService as unknown as { + activeWorkspaceTurnHandleByWorkspaceId: Map< + string, + { handleId: string; ownerWorkspaceId: string } + >; + } + ).activeWorkspaceTurnHandleByWorkspaceId.set(workspaceTurnId, { + handleId: workspaceTurnHandleId, + ownerWorkspaceId: parentTaskId, + }); + + await handleTaskServiceStreamEndForTest(taskService, { + type: "stream-end", + workspaceId: parentTaskId, + messageId: "assistant-parent-task", + metadata: { model: "openai:gpt-4o-mini" }, + parts: [ + { + type: "dynamic-tool", + toolCallId: "agent-report-call-1", + toolName: "agent_report", + input: { reportMarkdown: "Premature report", title: "Too early" }, + state: "output-available", + output: { success: true }, + }, + ], + }); + + expect(remove).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledWith( + parentTaskId, + expect.stringContaining(workspaceTurnHandleId), + expect.any(Object), + expect.objectContaining({ synthetic: true, agentInitiated: true }) + ); + const postCfg = config.loadConfigOrDefault(); + const ws = Array.from(postCfg.projects.values()) + .flatMap((p) => p.workspaces) + .find((w) => w.id === parentTaskId); + expect(ws?.taskStatus).toBe("running"); + }); + test("reverts awaiting_report to running on stream end while task has active descendants", async () => { const config = await createTestConfig(rootDir); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 2b6abf81b5..6efbaf6419 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -632,14 +632,14 @@ function buildBackgroundAwaitPrompt(params: { ); const targetLabels = [ - formatBackgroundAwaitTargetList("sub-agent task(s)", params.taskIds), + formatBackgroundAwaitTargetList("task handle(s)", params.taskIds), formatBackgroundAwaitTargetList("workflow run(s)", params.workflowRunIds), ].filter((label): label is string => label != null); const taskIds = [...params.taskIds, ...params.workflowRunIds]; return ( `You have active background ${targetLabels.join(" and ")}. ` + - "You MUST NOT end your turn while any listed sub-agent tasks are queued/starting/running/awaiting_report or workflow runs are pending/running/backgrounded. " + + "You MUST NOT end your turn while any listed task handles are queued/starting/running/awaiting_report or workflow runs are pending/running/backgrounded. " + `Call task_await now with task_ids: ${JSON.stringify(taskIds)} to wait for them. ` + "If any are still queued/starting/running/awaiting_report/backgrounded after that, call task_await again. " + "Only once all listed work is terminal should you write your final response, integrating any reports or workflow results." @@ -5701,12 +5701,15 @@ export class TaskService { return true; } - private async promptTaskForBackgroundWorkflowAwait( + private async promptTaskForBackgroundAwait( workspaceId: string, - workflowRunIds: string[] + params: { taskIds: string[]; workflowRunIds: string[] } ): Promise { - assert(workspaceId.length > 0, "promptTaskForBackgroundWorkflowAwait requires workspaceId"); - assert(workflowRunIds.length > 0, "promptTaskForBackgroundWorkflowAwait requires run IDs"); + assert(workspaceId.length > 0, "promptTaskForBackgroundAwait requires workspaceId"); + assert( + params.taskIds.length > 0 || params.workflowRunIds.length > 0, + "promptTaskForBackgroundAwait requires at least one awaitable target" + ); const cfg = this.config.loadConfigOrDefault(); const entry = findWorkspaceEntry(cfg, workspaceId); @@ -5718,7 +5721,7 @@ export class TaskService { const agentId = entry.workspace.agentId ?? TASK_RECOVERY_FALLBACK_AGENT_ID; const sendResult = await this.workspaceService.sendMessage( workspaceId, - buildBackgroundAwaitPrompt({ taskIds: [], workflowRunIds }), + buildBackgroundAwaitPrompt(params), { model, agentId, @@ -5728,10 +5731,11 @@ export class TaskService { { synthetic: true, agentInitiated: true } ); if (!sendResult.success) { - log.error("Failed to prompt task for active background workflow runs", { + log.error("Failed to prompt task for active background awaitables", { workspaceId, taskName: entry.workspace.name, - workflowRunIds, + taskIds: params.taskIds, + workflowRunIds: params.workflowRunIds, model, agentId, error: sendResult.error, @@ -6328,11 +6332,16 @@ export class TaskService { taskReferencedWorkflowRunIds, event.parts ); - if (taskActiveWorkflowRunIds.length > 0) { + const taskActiveWorkspaceTurnIds = + await this.listActiveWorkspaceTurnTaskIdsForOwner(workspaceId); + if (taskActiveWorkflowRunIds.length > 0 || taskActiveWorkspaceTurnIds.length > 0) { if (status === "awaiting_report") { await this.setTaskStatus(workspaceId, "running"); } - await this.promptTaskForBackgroundWorkflowAwait(workspaceId, taskActiveWorkflowRunIds); + await this.promptTaskForBackgroundAwait(workspaceId, { + taskIds: taskActiveWorkspaceTurnIds, + workflowRunIds: taskActiveWorkflowRunIds, + }); return; } @@ -6429,12 +6438,24 @@ export class TaskService { return records.toReversed().find((record) => record.workspaceId === workspaceId) ?? null; } + private async hasRecoverableWorkspaceTurnRetryInFlight(workspaceId: string): Promise { + await new Promise((resolve) => setImmediate(resolve)); + return ( + this.aiService.isStreaming(workspaceId) || + this.workspaceService.hasPendingQueuedOrPreparingTurn(workspaceId) + ); + } + private async finalizeWorkspaceTurnFromStreamError(event: ErrorEvent): Promise { const record = await this.getActiveWorkspaceTurnRecordForWorkspace(event.workspaceId); if (record == null) { return false; } - if (event.errorType != null && WORKSPACE_TURN_RECOVERABLE_STREAM_ERRORS.has(event.errorType)) { + if ( + event.errorType != null && + WORKSPACE_TURN_RECOVERABLE_STREAM_ERRORS.has(event.errorType) && + (await this.hasRecoverableWorkspaceTurnRetryInFlight(record.workspaceId)) + ) { return true; } const next: WorkspaceTurnTaskHandleRecord = { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index d9a4089eb9..2a41941a15 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -7552,7 +7552,9 @@ export class WorkspaceService extends EventEmitter { return false; } - return session.hasQueuedMessages() || session.isPreparingTurn(); + return ( + session.hasQueuedMessages() || session.isPreparingTurn() || session.hasPendingAutoRetry() + ); } /** From 49c3c4a14675044f3f08102d94cd7dec214f1b88 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 19 Jun 2026 17:37:15 +0000 Subject: [PATCH 17/17] Await stream retry decisions before settling --- src/node/services/agentSession.ts | 32 ++++++++++++++++++++++++++- src/node/services/taskService.test.ts | 23 ++++++++++++++++++- src/node/services/taskService.ts | 2 +- src/node/services/workspaceService.ts | 5 +++++ 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index a570d544b2..ffd41e98f5 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -484,6 +484,9 @@ export class AgentSession { */ private activeStreamFailureHandled = false; + private streamErrorRecoveryDecision: { promise: Promise; resolve: () => void } | null = + null; + /** Tracks whether the current stream included post-compaction attachments. */ private activeStreamHadPostCompactionInjection = false; @@ -777,6 +780,24 @@ export class AgentSession { this.emitChatEvent(event); } + private beginStreamErrorRecoveryDecision(): void { + let resolveDecision!: () => void; + const promise = new Promise((resolve) => { + resolveDecision = resolve; + }); + this.streamErrorRecoveryDecision = { promise, resolve: resolveDecision }; + } + + private resolveStreamErrorRecoveryDecision(): void { + const decision = this.streamErrorRecoveryDecision; + if (decision == null) { + return; + } + + this.streamErrorRecoveryDecision = null; + decision.resolve(); + } + private async handleStreamFailureForAutoRetry(error: RetryFailureError): Promise { assert( typeof error.type === "string" && error.type.length > 0, @@ -3816,6 +3837,7 @@ export class AgentSession { await this.finalizeCompactionRetry(data.messageId); this.setAutoRetryResumeState(retryOptionsForResume, retryAgentInitiated, retryGoalKind); this.setTurnPhase(TurnPhase.PREPARING); + this.resolveStreamErrorRecoveryDecision(); let retryResult: Result; try { retryResult = await this.streamWithHistory( @@ -3902,6 +3924,7 @@ export class AgentSession { // Retry the same request, but without post-compaction injection. this.setTurnPhase(TurnPhase.PREPARING); + this.resolveStreamErrorRecoveryDecision(); let retryResult: Result; try { retryResult = await this.streamWithHistory( @@ -4224,6 +4247,7 @@ export class AgentSession { this.setAutoRetryResumeState(retryOptions, context.agentInitiated, context.goalKind); this.setTurnPhase(TurnPhase.PREPARING); + this.resolveStreamErrorRecoveryDecision(); let retryResult: Result; try { retryResult = await this.streamWithHistory( @@ -4405,6 +4429,7 @@ export class AgentSession { message: data.error, }); await this.updateStartupAutoRetryAbandonFromFailure(failureType, failedUserMessageId); + this.resolveStreamErrorRecoveryDecision(); this.emitChatEvent(streamErrorMessage); this.setTurnPhase(TurnPhase.IDLE); @@ -4815,11 +4840,12 @@ export class AgentSession { } const data = raw as StreamErrorPayload & { workspaceId: string }; this.activeStreamErrorEventReceived = true; + this.beginStreamErrorRecoveryDecision(); void this.handleStreamError({ messageId: data.messageId, error: data.error, errorType: data.errorType, - }); + }).finally(() => this.resolveStreamErrorRecoveryDecision()); }; this.aiListeners.push({ event: "error", handler: errorHandler }); @@ -5000,6 +5026,10 @@ export class AgentSession { return !this.messageQueue.isEmpty(); } + async waitForPendingStreamErrorRecoveryDecision(): Promise { + await this.streamErrorRecoveryDecision?.promise; + } + hasPendingAutoRetry(): boolean { return this.retryManager.isRetryPending; } diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 2f36b4ccda..fe5f6787ac 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -364,6 +364,7 @@ function createWorkspaceServiceMocks( resumeStream: ReturnType; clearQueue: ReturnType; hasPendingQueuedOrPreparingTurn: ReturnType; + waitForPendingStreamErrorRecoveryDecision: ReturnType; remove: ReturnType; emit: ReturnType; getInfo: ReturnType; @@ -380,6 +381,7 @@ function createWorkspaceServiceMocks( resumeStream: ReturnType; clearQueue: ReturnType; hasPendingQueuedOrPreparingTurn: ReturnType; + waitForPendingStreamErrorRecoveryDecision: ReturnType; remove: ReturnType; emit: ReturnType; getInfo: ReturnType; @@ -398,6 +400,9 @@ function createWorkspaceServiceMocks( const clearQueue = overrides?.clearQueue ?? mock((): Result => Ok(undefined)); const hasPendingQueuedOrPreparingTurn = overrides?.hasPendingQueuedOrPreparingTurn ?? mock(() => false); + const waitForPendingStreamErrorRecoveryDecision = + overrides?.waitForPendingStreamErrorRecoveryDecision ?? + mock((): Promise => Promise.resolve()); const remove = overrides?.remove ?? mock((): Promise> => Promise.resolve(Ok(undefined))); const emit = overrides?.emit ?? mock(() => true); @@ -425,6 +430,7 @@ function createWorkspaceServiceMocks( resumeStream, clearQueue, hasPendingQueuedOrPreparingTurn, + waitForPendingStreamErrorRecoveryDecision, remove, emit, getInfo, @@ -439,6 +445,7 @@ function createWorkspaceServiceMocks( resumeStream, clearQueue, hasPendingQueuedOrPreparingTurn, + waitForPendingStreamErrorRecoveryDecision, remove, emit, getInfo, @@ -515,6 +522,7 @@ describe("TaskService", () => { remove?: ReturnType; isStreaming?: ReturnType; hasPendingQueuedOrPreparingTurn?: ReturnType; + waitForPendingStreamErrorRecoveryDecision?: ReturnType; } = {} ) { const config = await createTestConfig(rootDir); @@ -548,6 +556,12 @@ describe("TaskService", () => { ...(options.hasPendingQueuedOrPreparingTurn != null ? { hasPendingQueuedOrPreparingTurn: options.hasPendingQueuedOrPreparingTurn } : {}), + ...(options.waitForPendingStreamErrorRecoveryDecision != null + ? { + waitForPendingStreamErrorRecoveryDecision: + options.waitForPendingStreamErrorRecoveryDecision, + } + : {}), }); const aiMocks = createAIServiceMocks(config, { ...(options.isStreaming != null ? { isStreaming: options.isStreaming } : {}), @@ -1606,11 +1620,17 @@ describe("TaskService", () => { }); test("workspace-turn recoverable stream errors stay running while retry is pending", async () => { + let retryDecisionAwaited = false; const hasPendingQueuedOrPreparingTurn = mock( - (workspaceId: string) => workspaceId === "childworkspace" + (workspaceId: string) => retryDecisionAwaited && workspaceId === "childworkspace" ); + const waitForPendingStreamErrorRecoveryDecision = mock((): Promise => { + retryDecisionAwaited = true; + return Promise.resolve(); + }); const { parentId, taskService } = await startWorkspaceTurnForTest({ hasPendingQueuedOrPreparingTurn, + waitForPendingStreamErrorRecoveryDecision, }); const internal = taskService as unknown as { handleTaskStreamError: (event: ErrorEvent) => Promise; @@ -1624,6 +1644,7 @@ describe("TaskService", () => { errorType: "context_exceeded", }); + expect(waitForPendingStreamErrorRecoveryDecision).toHaveBeenCalledWith("childworkspace"); const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle"); expect(snapshot).toMatchObject({ status: "running", diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 6efbaf6419..ba3cd36e21 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -6439,7 +6439,7 @@ export class TaskService { } private async hasRecoverableWorkspaceTurnRetryInFlight(workspaceId: string): Promise { - await new Promise((resolve) => setImmediate(resolve)); + await this.workspaceService.waitForPendingStreamErrorRecoveryDecision(workspaceId); return ( this.aiService.isStreaming(workspaceId) || this.workspaceService.hasPendingQueuedOrPreparingTurn(workspaceId) diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 2a41941a15..3a2edc2c2f 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -7546,6 +7546,11 @@ export class WorkspaceService extends EventEmitter { } } + async waitForPendingStreamErrorRecoveryDecision(workspaceId: string): Promise { + const session = this.sessions.get(workspaceId.trim()); + await session?.waitForPendingStreamErrorRecoveryDecision(); + } + hasPendingQueuedOrPreparingTurn(workspaceId: string): boolean { const session = this.sessions.get(workspaceId.trim()); if (!session) {