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..a6e5f2bf1b 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,67 @@ 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") === "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,
+ 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 +446,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 +461,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 +500,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 +520,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 +534,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 +568,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 +741,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 +766,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 +1035,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/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.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts
index 14ad3f786e..6d64eb2f0e 100644
--- a/src/node/services/agentSession.startupAutoRetry.test.ts
+++ b/src/node/services/agentSession.startupAutoRetry.test.ts
@@ -124,6 +124,83 @@ 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("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 430ac7c804..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,
@@ -3543,11 +3564,19 @@ export class AgentSession {
// Bind recordFileState to this session for the propose_plan tool
const recordFileState = this.fileChangeTracker.record.bind(this.fileChangeTracker);
+ 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(options?.muxMetadata);
+ normalizeAcpPromptId(options?.acpPromptId) ?? extractAcpPromptId(optionsMuxMetadata);
const delegatedToolNames =
normalizeDelegatedToolNames(options?.delegatedToolNames) ??
- extractAcpDelegatedTools(options?.muxMetadata);
+ extractAcpDelegatedTools(optionsMuxMetadata);
const streamResult = await this.aiService.streamMessage({
messages: historyResult.data,
@@ -3564,6 +3593,7 @@ export class AgentSession {
agentId: options?.agentId,
acpPromptId,
delegatedToolNames,
+ muxMetadata: streamMuxMetadata,
recordFileState,
changedFileAttachments:
changedFileAttachments.length > 0 ? changedFileAttachments : undefined,
@@ -3807,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(
@@ -3893,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(
@@ -4215,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(
@@ -4396,6 +4429,7 @@ export class AgentSession {
message: data.error,
});
await this.updateStartupAutoRetryAbandonFromFailure(failureType, failedUserMessageId);
+ this.resolveStreamErrorRecoveryDecision();
this.emitChatEvent(streamErrorMessage);
this.setTurnPhase(TurnPhase.IDLE);
@@ -4806,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 });
@@ -4991,6 +5026,14 @@ export class AgentSession {
return !this.messageQueue.isEmpty();
}
+ async waitForPendingStreamErrorRecoveryDecision(): Promise {
+ await this.streamErrorRecoveryDecision?.promise;
+ }
+
+ hasPendingAutoRetry(): boolean {
+ return this.retryManager.isRetryPending;
+ }
+
hasPendingManualFollowUp(): boolean {
return !this.messageQueue.isEmpty() || this.pendingExternalManualFollowUps > 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..705a7efda3
--- /dev/null
+++ b/src/node/services/taskHandleStore.test.ts
@@ -0,0 +1,86 @@
+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("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);
+ 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..b9228ee48c
--- /dev/null
+++ b/src/node/services/taskHandleStore.ts
@@ -0,0 +1,265 @@
+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"];
+ };
+ deferredMessageIds?: string[];
+ 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(),
+ deferredMessageIds: z.array(z.string().min(1)).optional(),
+ error: z.string().optional(),
+ })
+ .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" && 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 {
+ 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");
+ if (!isWorkspaceTurnTaskId(handleId)) {
+ return null;
+ }
+ 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) => entry.slice(0, -".json".length))
+ .filter(isWorkspaceTurnTaskId)
+ .map((handleId) => this.readWorkspaceTurnFile(ownerWorkspaceId, handleId))
+ );
+ 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");
+ assertValidWorkspaceTurnTaskId(handleId);
+ 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}`
+ );
+ assertValidWorkspaceTurnTaskId(record.handleId);
+ }
+
+ 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;
+ }
+ 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) {
+ 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..fe5f6787ac 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";
@@ -42,8 +43,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 +86,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"));
@@ -343,142 +358,1595 @@ async function createAgentTask(
});
}
-function createWorkspaceServiceMocks(
- overrides?: Partial<{
- sendMessage: ReturnType;
- resumeStream: ReturnType;
- clearQueue: ReturnType;
- hasPendingQueuedOrPreparingTurn: ReturnType;
- remove: ReturnType;
- emit: ReturnType;
- getInfo: ReturnType;
- replaceHistory: ReturnType;
- updateAgentStatus: ReturnType;
- isExperimentEnabled: ReturnType;
- emitChatEvent: ReturnType;
- isWorkflowInvocationCurrent: ReturnType;
- }>
-): {
- workspaceService: WorkspaceService;
- sendMessage: ReturnType;
- resumeStream: ReturnType;
- clearQueue: ReturnType;
- hasPendingQueuedOrPreparingTurn: ReturnType;
- remove: ReturnType;
- emit: ReturnType;
- getInfo: ReturnType;
- replaceHistory: ReturnType;
- updateAgentStatus: ReturnType;
- isExperimentEnabled: ReturnType;
- emitChatEvent: ReturnType;
- isWorkflowInvocationCurrent: ReturnType;
-} {
- const sendMessage =
- overrides?.sendMessage ?? mock((): Promise> => Promise.resolve(Ok(undefined)));
- const resumeStream =
- overrides?.resumeStream ??
- mock((): Promise> => Promise.resolve(Ok({ started: true })));
- const clearQueue = overrides?.clearQueue ?? mock((): Result => Ok(undefined));
- const hasPendingQueuedOrPreparingTurn =
- overrides?.hasPendingQueuedOrPreparingTurn ?? mock(() => false);
- const remove =
- overrides?.remove ?? mock((): Promise> => Promise.resolve(Ok(undefined)));
- const emit = overrides?.emit ?? mock(() => true);
- const getInfo = overrides?.getInfo ?? mock(() => Promise.resolve(null));
- const replaceHistory =
- overrides?.replaceHistory ?? mock((): Promise> => Promise.resolve(Ok(undefined)));
- const updateAgentStatus =
- overrides?.updateAgentStatus ?? mock((): Promise => Promise.resolve());
- const isExperimentEnabled = overrides?.isExperimentEnabled ?? mock(() => false);
- const emitChatEvent = overrides?.emitChatEvent ?? mock(() => undefined);
- const isWorkflowInvocationCurrent =
- overrides?.isWorkflowInvocationCurrent ?? mock(() => Promise.resolve(true));
+function createWorkspaceServiceMocks(
+ overrides?: Partial<{
+ sendMessage: ReturnType;
+ resumeStream: ReturnType;
+ clearQueue: ReturnType;
+ hasPendingQueuedOrPreparingTurn: ReturnType;
+ waitForPendingStreamErrorRecoveryDecision: ReturnType;
+ remove: ReturnType;
+ emit: ReturnType;
+ getInfo: ReturnType;
+ replaceHistory: ReturnType;
+ updateAgentStatus: ReturnType;
+ isExperimentEnabled: ReturnType;
+ emitChatEvent: ReturnType;
+ isWorkflowInvocationCurrent: ReturnType;
+ create: ReturnType;
+ }>
+): {
+ workspaceService: WorkspaceService;
+ sendMessage: ReturnType;
+ resumeStream: ReturnType;
+ clearQueue: ReturnType;
+ hasPendingQueuedOrPreparingTurn: ReturnType;
+ waitForPendingStreamErrorRecoveryDecision: ReturnType;
+ remove: ReturnType;
+ emit: ReturnType;
+ getInfo: ReturnType;
+ replaceHistory: ReturnType;
+ updateAgentStatus: ReturnType;
+ isExperimentEnabled: ReturnType;
+ emitChatEvent: ReturnType;
+ isWorkflowInvocationCurrent: ReturnType;
+ create: ReturnType;
+} {
+ const sendMessage =
+ overrides?.sendMessage ?? mock((): Promise> => Promise.resolve(Ok(undefined)));
+ const resumeStream =
+ overrides?.resumeStream ??
+ mock((): Promise> => Promise.resolve(Ok({ started: true })));
+ 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);
+ const getInfo = overrides?.getInfo ?? mock(() => Promise.resolve(null));
+ const replaceHistory =
+ overrides?.replaceHistory ?? mock((): Promise> => Promise.resolve(Ok(undefined)));
+ const updateAgentStatus =
+ overrides?.updateAgentStatus ?? mock((): Promise => Promise.resolve());
+ const isExperimentEnabled = overrides?.isExperimentEnabled ?? mock(() => false);
+ const emitChatEvent = overrides?.emitChatEvent ?? mock(() => undefined);
+ 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,
+ hasPendingQueuedOrPreparingTurn,
+ waitForPendingStreamErrorRecoveryDecision,
+ remove,
+ emit,
+ getInfo,
+ replaceHistory,
+ updateAgentStatus,
+ isExperimentEnabled,
+ emitChatEvent,
+ isWorkflowInvocationCurrent,
+ } as unknown as WorkspaceService,
+ create,
+ sendMessage,
+ resumeStream,
+ clearQueue,
+ hasPendingQueuedOrPreparingTurn,
+ waitForPendingStreamErrorRecoveryDecision,
+ remove,
+ emit,
+ getInfo,
+ replaceHistory,
+ updateAgentStatus,
+ isExperimentEnabled,
+ emitChatEvent,
+ isWorkflowInvocationCurrent,
+ };
+}
+
+function createTaskServiceHarness(
+ config: Config,
+ overrides?: {
+ aiService?: AIService;
+ workspaceService?: WorkspaceService;
+ initStateManager?: InitStateManager;
+ sessionUsageService?: SessionUsageService;
+ workspaceGoalService?: WorkspaceGoalService;
+ }
+): {
+ historyService: HistoryService;
+ partialService: HistoryService;
+ taskService: TaskService;
+ aiService: AIService;
+ workspaceService: WorkspaceService;
+ initStateManager: InitStateManager;
+} {
+ const historyService = new HistoryService(config);
+ const partialService = historyService;
+
+ const aiService = overrides?.aiService ?? createAIServiceMocks(config).aiService;
+ const workspaceService =
+ overrides?.workspaceService ?? createWorkspaceServiceMocks().workspaceService;
+ const initStateManager = overrides?.initStateManager ?? createMockInitStateManager();
+
+ const taskService = new TaskService(
+ config,
+ historyService,
+ aiService,
+ workspaceService,
+ initStateManager,
+ undefined,
+ overrides?.sessionUsageService,
+ overrides?.workspaceGoalService
+ );
+
+ return {
+ historyService,
+ partialService,
+ taskService,
+ aiService,
+ workspaceService,
+ initStateManager,
+ };
+}
+
+describe("TaskService", () => {
+ let rootDir: string;
+
+ beforeEach(async () => {
+ rootDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-taskService-"));
+ });
+
+ afterEach(async () => {
+ await fsPromises.rm(rootDir, { recursive: true, force: true });
+ });
+
+ async function startWorkspaceTurnForTest(
+ options: {
+ stableIds?: string[];
+ disposable?: boolean;
+ sendMessage?: ReturnType;
+ remove?: ReturnType;
+ isStreaming?: ReturnType;
+ hasPendingQueuedOrPreparingTurn?: ReturnType;
+ waitForPendingStreamErrorRecoveryDecision?: 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 } : {}),
+ ...(options.hasPendingQueuedOrPreparingTurn != null
+ ? { hasPendingQueuedOrPreparingTurn: options.hasPendingQueuedOrPreparingTurn }
+ : {}),
+ ...(options.waitForPendingStreamErrorRecoveryDecision != null
+ ? {
+ waitForPendingStreamErrorRecoveryDecision:
+ options.waitForPendingStreamErrorRecoveryDecision,
+ }
+ : {}),
+ });
+ const aiMocks = createAIServiceMocks(config, {
+ ...(options.isStreaming != null ? { isStreaming: options.isStreaming } : {}),
+ });
+ const { historyService, 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,
+ historyService,
+ 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 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 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> => {
+ 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("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 {
+ 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("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 {
+ 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("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 {
+ 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 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 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();
+ 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();
+ 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", 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 without correlation metadata interrupts the active handle", 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: "interrupted",
+ workspaceId: "childworkspace",
+ messageId: "msg_1",
+ error: "Workspace turn superseded by an uncorrelated workspace stream-end",
+ });
+ expect(snapshot?.reportMarkdown).toBeUndefined();
+ });
+
+ 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 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",
+ 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 stay running while retry is pending", async () => {
+ let retryDecisionAwaited = false;
+ const hasPendingQueuedOrPreparingTurn = mock(
+ (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;
+ };
+
+ await internal.handleTaskStreamError({
+ type: "error",
+ workspaceId: "childworkspace",
+ messageId: "msg_1",
+ error: "Context too large",
+ errorType: "context_exceeded",
+ });
+
+ expect(waitForPendingStreamErrorRecoveryDecision).toHaveBeenCalledWith("childworkspace");
+ const snapshot = await taskService.getWorkspaceTurnSnapshot(parentId, "wst_handle");
+ expect(snapshot).toMatchObject({
+ status: "running",
+ workspaceId: "childworkspace",
+ });
+ });
+
+ 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 {
+ 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 {
+ 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;
+ }
+ );
- return {
- workspaceService: {
- sendMessage,
- resumeStream,
- clearQueue,
- hasPendingQueuedOrPreparingTurn,
- remove,
- emit,
- getInfo,
- replaceHistory,
- updateAgentStatus,
- isExperimentEnabled,
- emitChatEvent,
- isWorkflowInvocationCurrent,
- } as unknown as WorkspaceService,
- sendMessage,
- resumeStream,
- clearQueue,
- hasPendingQueuedOrPreparingTurn,
- remove,
- emit,
- getInfo,
- replaceHistory,
- updateAgentStatus,
- isExperimentEnabled,
- emitChatEvent,
- isWorkflowInvocationCurrent,
- };
-}
+ const report = await taskService.waitForWorkspaceTurn("wst_handle", {
+ requestingWorkspaceId: parentId,
+ timeoutMs: 100,
+ });
-function createTaskServiceHarness(
- config: Config,
- overrides?: {
- aiService?: AIService;
- workspaceService?: WorkspaceService;
- initStateManager?: InitStateManager;
- sessionUsageService?: SessionUsageService;
- workspaceGoalService?: WorkspaceGoalService;
- }
-): {
- historyService: HistoryService;
- partialService: HistoryService;
- taskService: TaskService;
- aiService: AIService;
- workspaceService: WorkspaceService;
- initStateManager: InitStateManager;
-} {
- const historyService = new HistoryService(config);
- const partialService = historyService;
+ expect(triggered).toBe(true);
+ expect(report.reportMarkdown).toBe("Done");
+ });
- const aiService = overrides?.aiService ?? createAIServiceMocks(config).aiService;
- const workspaceService =
- overrides?.workspaceService ?? createWorkspaceServiceMocks().workspaceService;
- const initStateManager = overrides?.initStateManager ?? createMockInitStateManager();
+ 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 taskService = new TaskService(
- config,
- historyService,
- aiService,
- workspaceService,
- initStateManager,
- undefined,
- overrides?.sessionUsageService,
- overrides?.workspaceGoalService
- );
+ 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();
+ });
- return {
- historyService,
- partialService,
- taskService,
- aiService,
- workspaceService,
- initStateManager,
- };
-}
+ test("waitForWorkspaceTurn foreground waits can be sent to background", async () => {
+ const { parentId, taskService } = await startWorkspaceTurnForTest();
-describe("TaskService", () => {
- let rootDir: string;
+ const waitResult = taskService
+ .waitForWorkspaceTurn("wst_handle", {
+ requestingWorkspaceId: parentId,
+ timeoutMs: 1_000,
+ backgroundOnMessageQueued: true,
+ })
+ .then(
+ () => null,
+ (error: unknown) => error
+ );
- beforeEach(async () => {
- rootDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-taskService-"));
+ expect(taskService.backgroundForegroundWaitsForWorkspace(parentId)).toBe(1);
+ expect(await waitResult).toBeInstanceOf(ForegroundWaitBackgroundedError);
+ expect(taskService.backgroundForegroundWaitsForWorkspace(parentId)).toBe(0);
});
- afterEach(async () => {
- await fsPromises.rm(rootDir, { recursive: true, force: true });
+ 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: "authentication",
+ });
+ 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 () => {
@@ -4845,17 +6313,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);
@@ -6689,6 +8154,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 e8c92cf911..ba3cd36e21 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,
@@ -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,54 @@ 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;
+}
+
+type WorkspaceTurnMuxMetadata = Extract;
+
+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;
@@ -443,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
@@ -481,13 +543,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 +582,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");
}
@@ -571,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."
@@ -839,6 +900,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;
@@ -853,8 +917,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 +1304,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 +1319,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 +1960,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 +2671,198 @@ 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 ((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."
+ );
+ }
+
+ 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 === "fork") {
+ return Err('Task.createWorkspaceTurn: workspace.mode="fork" is not supported yet');
+ }
+
+ 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: this.buildWorkspaceTurnMuxMetadata(record),
+ 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 +2965,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 +3741,7 @@ export class TaskService {
private registerBackgroundableForegroundWaiter(
workspaceId: string,
- waiter: PendingTaskWaiter
+ waiter: BackgroundableForegroundWaiter
): void {
let set = this.backgroundableForegroundWaitersByWorkspaceId.get(workspaceId);
if (!set) {
@@ -3477,7 +3753,7 @@ export class TaskService {
private unregisterBackgroundableForegroundWaiter(
workspaceId: string,
- waiter: PendingTaskWaiter
+ waiter: BackgroundableForegroundWaiter
): void {
const set = this.backgroundableForegroundWaitersByWorkspaceId.get(workspaceId);
if (!set) return;
@@ -3510,6 +3786,257 @@ 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 isTerminalWorkspaceTurnStatus(status: WorkspaceTurnTaskStatus): boolean {
+ return status === "completed" || status === "interrupted" || status === "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.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(
+ 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 +4496,81 @@ export class TaskService {
return result;
}
+ async getWorkspaceTurnSnapshot(
+ ownerWorkspaceId: string,
+ handleId: string
+ ): Promise {
+ if (!isWorkspaceTurnTaskId(handleId)) {
+ return null;
+ }
+ 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(
+ 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 +4940,98 @@ 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) ||
+ this.workspaceService.hasPendingQueuedOrPreparingTurn(record.workspaceId)
+ );
+ }
+
+ private async settleStaleWorkspaceTurn(record: WorkspaceTurnTaskHandleRecord): Promise {
+ 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",
+ 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 +5220,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,
@@ -5006,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);
@@ -5023,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,
@@ -5033,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,
@@ -5046,6 +5745,305 @@ 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 {
+ if (typeof muxMetadata !== "object" || muxMetadata == null || Array.isArray(muxMetadata)) {
+ return null;
+ }
+ const data = muxMetadata 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 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(
+ (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 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 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 {
+ 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()) {
+ if (this.isDeferredWorkspaceTurnMessage(record, message.id)) {
+ continue;
+ }
+ const event = this.buildWorkspaceTurnStreamEndEventFromHistory(record, message);
+ if (event != null) {
+ return this.buildTerminalWorkspaceTurnRecordFromEvent(record, event);
+ }
+ }
+ return null;
+ }
+
+ private async markWorkspaceTurnStreamEndDeferred(event: StreamEndEvent): Promise {
+ const metadata = this.getWorkspaceTurnMetadata(event);
+ if (metadata == null) {
+ return;
+ }
+ 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],
+ });
+ });
+ }
+
+ private resolveWorkspaceTurnMuxMetadataForStreamEnd(
+ event: StreamEndEvent
+ ): WorkspaceTurnMuxMetadata | undefined {
+ const metadata = this.getWorkspaceTurnMetadata(event);
+ 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 false;
+ }
+ const record = await this.taskHandleStore.getWorkspaceTurn(
+ active.ownerWorkspaceId,
+ active.handleId
+ );
+ 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;
+ }
+
+ 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 metadata = this.getWorkspaceTurnMetadata(event);
+ if (metadata == null) {
+ if (event.metadata.muxMetadata != null) {
+ return false;
+ }
+ return await this.interruptWorkspaceTurnFromUncorrelatedStreamEnd(event);
+ }
+ const record = await this.taskHandleStore.getWorkspaceTurn(
+ metadata.ownerWorkspaceId,
+ metadata.taskHandleId
+ );
+ if (record == null) {
+ log.warn("Ignoring missing workspace turn stream-end handle", {
+ workspaceId: event.workspaceId,
+ taskHandleId: metadata.taskHandleId,
+ });
+ return true;
+ }
+ 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;
+ }
+ if (this.isDeferredWorkspaceTurnMessage(record, event.messageId)) {
+ return true;
+ }
+
+ const next = this.buildTerminalWorkspaceTurnRecordFromEvent(record, event);
+ await this.settleWorkspaceTurn({
+ record,
+ next,
+ waiterSettlement:
+ next.status === "completed"
+ ? { status: "completed", result: this.buildWorkspaceTurnWaitResult(next) }
+ : { status: "error", error: new Error(next.error ?? "Workspace turn failed") },
+ });
+ return true;
+ }
+
private async handleStreamEnd(event: StreamEndEvent): Promise {
const workspaceId = event.workspaceId;
@@ -5070,18 +6068,24 @@ 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;
}
}
+ await this.markWorkspaceTurnStreamEndDeferred(event);
+
if (this.aiService.isStreaming(workspaceId)) {
return;
}
@@ -5095,9 +6099,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 +6118,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 +6146,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;
@@ -5178,10 +6195,12 @@ export class TaskService {
taskIds: blockingTaskIds,
workflowRunIds: activeWorkflowRunIds,
});
+ const workspaceTurnMuxMetadata = 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,
@@ -5191,15 +6210,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 +6277,10 @@ export class TaskService {
return;
}
+ if (await this.finalizeWorkspaceTurnFromStreamEnd(event)) {
+ return;
+ }
+
const status = entry.workspace.taskStatus;
const reportArgs = this.findAgentReportArgsInParts(event.parts);
@@ -5302,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;
}
@@ -5345,7 +6380,102 @@ 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;
+ }
+ if (event.abortReason !== "user") {
+ 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 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 records = await this.taskHandleStore.listAllWorkspaceTurns({
+ statuses: ["starting", "running"],
+ });
+ return records.toReversed().find((record) => record.workspaceId === workspaceId) ?? null;
+ }
+
+ private async hasRecoverableWorkspaceTurnRetryInFlight(workspaceId: string): Promise {
+ await this.workspaceService.waitForPendingStreamErrorRecoveryDecision(workspaceId);
+ 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) &&
+ (await this.hasRecoverableWorkspaceTurnRetryInFlight(record.workspaceId))
+ ) {
+ 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..b86d387b87 100644
--- a/src/node/services/tools/task.test.ts
+++ b/src/node/services/tools/task.test.ts
@@ -93,6 +93,82 @@ 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" });
+
+ 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" });
@@ -956,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 1358c5e233..5785daef1c 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,111 @@ 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 (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,
+ 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 +488,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..1b52b25178 100644
--- a/src/node/services/tools/task_await.test.ts
+++ b/src/node/services/tools/task_await.test.ts
@@ -40,6 +40,231 @@ 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("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("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 532e2e077f..81932237b2 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,177 @@ 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: timeoutMs ?? DEFAULT_TASK_AWAIT_TIMEOUT_MS,
+ 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 };
+ 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: latest.status,
+ 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 +691,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." };
diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts
index d9a4089eb9..3a2edc2c2f 100644
--- a/src/node/services/workspaceService.ts
+++ b/src/node/services/workspaceService.ts
@@ -7546,13 +7546,20 @@ 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) {
return false;
}
- return session.hasQueuedMessages() || session.isPreparingTurn();
+ return (
+ session.hasQueuedMessages() || session.isPreparingTurn() || session.hasPendingAutoRetry()
+ );
}
/**