authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-10-11 07:34:02+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 21:34:02-07:00
logb9afba59f435e2e0d6368392b49f10d187125a09
tree5bba699a1d1639bfc1fa15200d0e0a05f4c2489a
parenta908a7ac8780c5b4d6fb0bed8945d640f69d2d1d
signature Signed by PGP key B5690EEEBB952194

feat(web): show Claude workflow phases and members in Lineage (#12598)

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: GPT-6.1-Sol (Codex) <noreply@openai.com> Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

56 files changed, 7214 insertions(+), 659 deletions(-)

apps/server/src/auth/RpcAuthorization.test.ts+7
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 ProviderInstanceId,8 ProviderInstanceId,
9 ThreadId,9 ThreadId,
10 AuthOrchestrationOperateScope,10 AuthOrchestrationOperateScope,
11 ORCHESTRATION_V2_WS_METHODS,
11 AuthOrchestrationReadScope,12 AuthOrchestrationReadScope,
12 AuthSourceControlWriteScope,13 AuthSourceControlWriteScope,
13 AuthPreviewOperateScope,14 AuthPreviewOperateScope,
...@@ -35,6 +36,12 @@ describe("RPC authorization scopes", () => {...@@ -35,6 +36,12 @@ describe("RPC authorization scopes", () => {
35 expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys()));36 expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys()));
36 });37 });
3738
39 it("requires operate access to stop a workflow", () => {
40 expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.stopWorkflow)).toBe(
41 AuthOrchestrationOperateScope,
42 );
43 });
44
38 it("authorizes background policy reporting and observation deliberately", () => {45 it("authorizes background policy reporting and observation deliberately", () => {
39 expect(requiredScopeForRpcMethod(WS_METHODS.serverReportClientActivity)).toBe(46 expect(requiredScopeForRpcMethod(WS_METHODS.serverReportClientActivity)).toBe(
40 AuthOrchestrationReadScope,47 AuthOrchestrationReadScope,
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -46,6 +46,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -46,6 +46,7 @@ export const RPC_REQUIRED_SCOPES = {
46 ...CLIENT_GUARDED_RPC_SCOPES,46 ...CLIENT_GUARDED_RPC_SCOPES,
47 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,47 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
48 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,48 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,
49 [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope,
49 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,50 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
50 [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,51 [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,
51 [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,52 [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,
apps/server/src/mcp/OrchestratorMcpService.ts+1
...@@ -879,6 +879,7 @@ function timelineItem(input: {...@@ -879,6 +879,7 @@ function timelineItem(input: {
879 visibility: input.row.visibility,879 visibility: input.row.visibility,
880 sourceThreadId: input.row.sourceThreadId,880 sourceThreadId: input.row.sourceThreadId,
881 itemId: input.row.sourceItemId,881 itemId: input.row.sourceItemId,
882 ...(input.row.item.type === "subagent" ? { subagentId: input.row.item.subagentId } : {}),
882 runId: input.row.item.runId,883 runId: input.row.item.runId,
883 messageId,884 messageId,
884 createdBy: message?.createdBy ?? null,885 createdBy: message?.createdBy ?? null,
apps/server/src/mcp/toolkits/core.test.ts+132-21
...@@ -8,9 +8,14 @@ import {...@@ -8,9 +8,14 @@ import {
8 ChatImageAttachment,8 ChatImageAttachment,
9 CommandId,9 CommandId,
10 EnvironmentId,10 EnvironmentId,
11 NodeId,
12 OrchestratorMcpThreadReadResult,
13 ProviderDriverKind,
11 ProviderInstanceId,14 ProviderInstanceId,
12 RunId,15 RunId,
13 ThreadId,16 ThreadId,
17 TurnItemId,
18 type OrchestrationV2ProjectedTurnItem,
14 type OrchestrationV2ThreadShell,19 type OrchestrationV2ThreadShell,
15} from "@t3tools/contracts";20} from "@t3tools/contracts";
16import * as Effect from "effect/Effect";21import * as Effect from "effect/Effect";
...@@ -69,6 +74,7 @@ import {...@@ -69,6 +74,7 @@ import {
69import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";74import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
7075
71const decodeMcpAttachmentInput = Schema.decodeUnknownEffect(McpAttachmentInput);76const decodeMcpAttachmentInput = Schema.decodeUnknownEffect(McpAttachmentInput);
77const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadReadResult);
7278
73// Registration asks for every service the thread tools declare; these cases call none that use them.79// Registration asks for every service the thread tools declare; these cases call none that use them.
74const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe(80const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe(
...@@ -663,16 +669,127 @@ function scheduledTask(id: string, runtimeMode: "auto" | "full-access"): never {...@@ -663,16 +669,127 @@ function scheduledTask(id: string, runtimeMode: "auto" | "full-access"): never {
663 } as never;669 } as never;
664}670}
665671
666it.effect("a caller cannot interrupt a thread that runs above its own modes", () =>672it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])(
667 Effect.gen(function* () {673 "a caller cannot use %s on a thread that runs above its own modes",
674 (toolName) =>
675 Effect.gen(function* () {
676 const server = yield* McpServer.McpServer;
677 const result = yield* server
678 .callTool({
679 name: toolName,
680 arguments: {
681 threadId: "full-access-thread",
682 ...(toolName === "t3_workflow_stop" ? { subagentId: "workflow-coordinator" } : {}),
683 },
684 })
685 .pipe(
686 Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")),
687 Effect.provideService(McpSchema.McpServerClient, client),
688 );
689 expect(declaredFailure(result)).toMatchObject({ code: "runtime_mode_escalation_denied" });
690 }).pipe(
691 Effect.provide(
692 McpHttpServer.layerOrchestratorToolkit.pipe(
693 Layer.provideMerge(McpServer.McpServer.layer),
694 Layer.provide(NodeCrypto.layer),
695 Layer.provide(
696 Layer.mock(ThreadManagement.ThreadManagementService)({
697 getThreadShell: () =>
698 Effect.succeed({
699 projectId: "project-a",
700 deletedAt: null,
701 runtimeMode: "full-access",
702 interactionMode: "default",
703 } as never),
704 getProjectThreadRecords: () =>
705 Effect.succeed({
706 thread: {
707 id: ThreadId.make("full-access-thread"),
708 projectId: "project-a",
709 runtimeMode: "full-access",
710 interactionMode: "default",
711 deletedAt: null,
712 },
713 runs: [],
714 } as never),
715 stopWorkflow: () => Effect.die("workflow stop must not dispatch above the ceiling"),
716 interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"),
717 }),
718 ),
719 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
720 Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})),
721 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
722 Layer.provide(Layer.mock(ProjectService.ProjectService)({})),
723 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
724 ),
725 ),
726 ),
727);
728
729it.effect("discovers workflow stop targets and requires orchestration capability", () => {
730 const threadId = ThreadId.make("workflow-parent");
731 const shell = McpToolAccessTestkit.liveThreadShell(threadId);
732 const subagentId = NodeId.make("workflow-coordinator");
733 const itemId = TurnItemId.make("workflow-coordinator-activity");
734 const row: OrchestrationV2ProjectedTurnItem = {
735 position: 0,
736 visibility: "local",
737 sourceThreadId: threadId,
738 sourceItemId: itemId,
739 item: {
740 id: itemId,
741 threadId,
742 runId: shell.activeRunId,
743 nodeId: subagentId,
744 providerThreadId: null,
745 providerTurnId: null,
746 nativeItemRef: null,
747 parentItemId: null,
748 ordinal: 0,
749 status: "running",
750 title: "Workflow: checkout review",
751 startedAt: shell.createdAt,
752 completedAt: null,
753 updatedAt: shell.updatedAt,
754 type: "subagent",
755 subagentId,
756 origin: "provider_native",
757 driver: ProviderDriverKind.make("claudeAgent"),
758 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
759 childThreadId: ThreadId.make("workflow-child"),
760 prompt: "Review checkout",
761 result: null,
762 },
763 };
764 const stopped: Array<{ readonly threadId: ThreadId; readonly subagentId: string }> = [];
765 return Effect.gen(function* () {
668 const server = yield* McpServer.McpServer;766 const server = yield* McpServer.McpServer;
669 const result = yield* server767 const scope = clientScope("full-access");
670 .callTool({ name: "t3_thread_interrupt", arguments: { threadId: "full-access-thread" } })768 const read = yield* server
769 .callTool({ name: "t3_thread_read", arguments: { threadId, view: "activity" } })
671 .pipe(770 .pipe(
672 Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")),771 Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
673 Effect.provideService(McpSchema.McpServerClient, client),772 Effect.provideService(McpSchema.McpServerClient, client),
674 );773 );
675 expect(declaredFailure(result)).toMatchObject({ code: "runtime_mode_escalation_denied" });774 expect(read.isError).toBe(false);
775 const result = yield* decodeThreadReadResult(read.structuredContent);
776 expect(result.items[0]?.subagentId).toBe(subagentId);
777 const call = (invocation: McpInvocationContext.McpInvocationScope) =>
778 server
779 .callTool({
780 name: "t3_workflow_stop",
781 arguments: { threadId, subagentId: result.items[0]?.subagentId },
782 })
783 .pipe(
784 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
785 Effect.provideService(McpSchema.McpServerClient, client),
786 );
787 const denied = yield* call({ ...scope, capabilities: new Set(["worktree"]) });
788 expect(declaredFailure(denied)).toMatchObject({ code: "capability_denied" });
789 expect(stopped).toEqual([]);
790 const allowed = yield* call(scope);
791 expect(allowed.isError).toBe(false);
792 expect(stopped).toEqual([{ threadId: "workflow-parent", subagentId: "workflow-coordinator" }]);
676 }).pipe(793 }).pipe(
677 Effect.provide(794 Effect.provide(
678 McpHttpServer.layerOrchestratorToolkit.pipe(795 McpHttpServer.layerOrchestratorToolkit.pipe(
...@@ -680,20 +797,14 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", ()...@@ -680,20 +797,14 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", ()
680 Layer.provide(NodeCrypto.layer),797 Layer.provide(NodeCrypto.layer),
681 Layer.provide(798 Layer.provide(
682 Layer.mock(ThreadManagement.ThreadManagementService)({799 Layer.mock(ThreadManagement.ThreadManagementService)({
683 getThreadShell: () =>800 getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)),
684 Effect.succeed({ projectId: "project-a", deletedAt: null } as never),
685 getProjectThreadRecords: () =>801 getProjectThreadRecords: () =>
686 Effect.succeed({802 Effect.succeed(McpToolAccessTestkit.idleThreadProjection(shell)),
687 thread: {803 getTimelinePage: () => Effect.succeed({ items: [row], totalItems: 1, hasMore: false }),
688 id: ThreadId.make("full-access-thread"),804 stopWorkflow: (input) =>
689 projectId: "project-a",805 Effect.sync(() => {
690 runtimeMode: "full-access",806 stopped.push(input);
691 interactionMode: "default",807 }),
692 deletedAt: null,
693 },
694 runs: [],
695 } as never),
696 interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"),
697 }),808 }),
698 ),809 ),
699 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),810 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
...@@ -703,8 +814,8 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", ()...@@ -703,8 +814,8 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", ()
703 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),814 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
704 ),815 ),
705 ),816 ),
706 ),817 );
707);818});
708819
709it.effect("only the caller that prepared a pending upload can discard it", () =>820it.effect("only the caller that prepared a pending upload can discard it", () =>
710 Effect.gen(function* () {821 Effect.gen(function* () {
apps/server/src/mcp/toolkits/orchestrator/handlers.ts+22
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { OrchestratorMcpFailure } from "@t3tools/contracts";
1import { OrchestratorToolkit } from "./tools.ts";2import { OrchestratorToolkit } from "./tools.ts";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
34
5import { readCaller } from "../../threadAccess.ts";
4import * as McpInvocationContext from "../../McpInvocationContext.ts";6import * as McpInvocationContext from "../../McpInvocationContext.ts";
5import * as McpToolAccess from "../../McpToolAccess.ts";7import * as McpToolAccess from "../../McpToolAccess.ts";
6import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts";8import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts";
...@@ -35,6 +37,26 @@ const handlers = {...@@ -35,6 +37,26 @@ const handlers = {
35 return yield* service.cancelTask(scope, input);37 return yield* service.cancelTask(scope, input);
36 }),38 }),
37 ),39 ),
40 t3_workflow_stop: McpToolAccess.writesThreads(
41 (input) => [input.threadId],
42 (input) =>
43 Effect.gen(function* () {
44 const { threads } = yield* readCaller();
45 return yield* threads.stopWorkflow(input).pipe(
46 Effect.as({}),
47 Effect.mapError(
48 (cause) =>
49 new OrchestratorMcpFailure({
50 code:
51 cause.reason === "unavailable" || cause.reason === "stop-failed"
52 ? "orchestration_error"
53 : "invalid_request",
54 message: cause.message,
55 }),
56 ),
57 );
58 }),
59 ),
38 schedule_task: McpToolAccess.startsThreads(60 schedule_task: McpToolAccess.startsThreads(
39 // A scheduled task runs with the caller's own modes.61 // A scheduled task runs with the caller's own modes.
40 () => ({}),62 () => ({}),
apps/server/src/mcp/toolkits/orchestrator/tools.ts+15
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 OrchestratorMcpCapabilitiesResult,2 OrchestratorMcpCapabilitiesResult,
3 OrchestrationV2StopWorkflowInput,
3 OrchestratorMcpCreateThreadsInput,4 OrchestratorMcpCreateThreadsInput,
4 OrchestratorMcpCreateThreadsResult,5 OrchestratorMcpCreateThreadsResult,
5 OrchestratorMcpDelegateTaskInput,6 OrchestratorMcpDelegateTaskInput,
...@@ -31,6 +32,7 @@ import {...@@ -31,6 +32,7 @@ import {
31 ThreadMetadataMcpUpdateResult,32 ThreadMetadataMcpUpdateResult,
32} from "@t3tools/contracts";33} from "@t3tools/contracts";
33import { Tool, Toolkit } from "effect/ai";34import { Tool, Toolkit } from "effect/ai";
35import * as Schema from "effect/Schema";
3436
35import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";37import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
36import * as McpInvocationContext from "../../McpInvocationContext.ts";38import * as McpInvocationContext from "../../McpInvocationContext.ts";
...@@ -100,6 +102,18 @@ const TaskCancelTool = Tool.make("task_cancel", {...@@ -100,6 +102,18 @@ const TaskCancelTool = Tool.make("task_cancel", {
100 .annotate(Tool.Title, "Cancel delegated task")102 .annotate(Tool.Title, "Cancel delegated task")
101 .annotate(Tool.Destructive, true);103 .annotate(Tool.Destructive, true);
102104
105const WorkflowStopTool = Tool.make("t3_workflow_stop", {
106 description:
107 "Stop one running Claude workflow in a T3 thread. Use the workflow coordinator's subagentId returned by t3_thread_read with view='activity'. Stops the whole workflow, including its phases and agents. Does not stop the parent thread or other workflows. Individual workflow phases and agents cannot be stopped separately.",
108 parameters: OrchestrationV2StopWorkflowInput,
109 success: Schema.Struct({}),
110 failure: OrchestratorMcpFailure,
111 failureMode: "return",
112 dependencies,
113})
114 .annotate(Tool.Title, "Stop a workflow")
115 .annotate(Tool.Destructive, true);
116
103export const ScheduleTaskTool = Tool.make("schedule_task", {117export const ScheduleTaskTool = Tool.make("schedule_task", {
104 description:118 description:
105 "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.",119 "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.",
...@@ -261,6 +275,7 @@ export const OrchestratorToolkit = Toolkit.make(...@@ -261,6 +275,7 @@ export const OrchestratorToolkit = Toolkit.make(
261 DelegateTaskTool,275 DelegateTaskTool,
262 TaskStatusTool,276 TaskStatusTool,
263 TaskCancelTool,277 TaskCancelTool,
278 WorkflowStopTool,
264 ScheduleTaskTool,279 ScheduleTaskTool,
265 ListScheduledTasksTool,280 ListScheduledTasksTool,
266 UpdateScheduledTaskTool,281 UpdateScheduledTaskTool,
apps/server/src/observability/RpcInstrumentation.ts+1
...@@ -17,6 +17,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];...@@ -17,6 +17,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
17const RPC_AGGREGATES = {17const RPC_AGGREGATES = {
18 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2",18 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2",
19 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration",19 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration",
20 [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: "orchestration",
20 [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: "orchestration",21 [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: "orchestration",
21 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: "orchestration",22 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: "orchestration",
22 [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: "orchestration",23 [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: "orchestration",
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+2382-11
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1// @effect-diagnostics nodeBuiltinImport:off - the fake Claude CLI hands the SDK Node streams.1// @effect-diagnostics nodeBuiltinImport:off -- workflow fixtures use files and the fake Claude CLI uses Node streams
2import * as NodeEvents from "node:events";2import * as NodeEvents from "node:events";
3import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
4import * as NodeStream from "node:stream";6import * as NodeStream from "node:stream";
57
6import type {8import type {
...@@ -44,6 +46,7 @@ import {...@@ -44,6 +46,7 @@ import {
44 ThreadId,46 ThreadId,
45} from "@t3tools/contracts";47} from "@t3tools/contracts";
46import { assert, describe, it } from "@effect/vitest";48import { assert, describe, it } from "@effect/vitest";
49import * as Clock from "effect/Clock";
47import * as Context from "effect/Context";50import * as Context from "effect/Context";
48import * as DateTime from "effect/DateTime";51import * as DateTime from "effect/DateTime";
49import * as Deferred from "effect/Deferred";52import * as Deferred from "effect/Deferred";
...@@ -1024,6 +1027,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {...@@ -1024,6 +1027,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {
1024 offer: () => Effect.void,1027 offer: () => Effect.void,
1025 setModel: () => Effect.void,1028 setModel: () => Effect.void,
1026 setPermissionMode: () => Effect.void,1029 setPermissionMode: () => Effect.void,
1030 stopTask: () => Effect.void,
1027 interrupt: Effect.void,1031 interrupt: Effect.void,
1028 close: Effect.void,1032 close: Effect.void,
1029 };1033 };
...@@ -1183,6 +1187,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio...@@ -1183,6 +1187,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio
1183 offer: () => Effect.void,1187 offer: () => Effect.void,
1184 setModel: () => Effect.void,1188 setModel: () => Effect.void,
1185 setPermissionMode: () => Effect.void,1189 setPermissionMode: () => Effect.void,
1190 stopTask: () => Effect.void,
1186 interrupt: Effect.void,1191 interrupt: Effect.void,
1187 close: Effect.void,1192 close: Effect.void,
1188 };1193 };
...@@ -1302,6 +1307,7 @@ describe("ClaudeAdapterV2 resume compaction", () => {...@@ -1302,6 +1307,7 @@ describe("ClaudeAdapterV2 resume compaction", () => {
1302 offer: () => Effect.void,1307 offer: () => Effect.void,
1303 setModel: () => Effect.void,1308 setModel: () => Effect.void,
1304 setPermissionMode: () => Effect.void,1309 setPermissionMode: () => Effect.void,
1310 stopTask: () => Effect.void,
1305 interrupt: Effect.void,1311 interrupt: Effect.void,
1306 close: Effect.void,1312 close: Effect.void,
1307 };1313 };
...@@ -1527,6 +1533,7 @@ describe("ClaudeAdapterV2 attachments", () => {...@@ -1527,6 +1533,7 @@ describe("ClaudeAdapterV2 attachments", () => {
1527 }),1533 }),
1528 setModel: () => Effect.void,1534 setModel: () => Effect.void,
1529 setPermissionMode: () => Effect.void,1535 setPermissionMode: () => Effect.void,
1536 stopTask: () => Effect.void,
1530 interrupt: Effect.void,1537 interrupt: Effect.void,
1531 close: Effect.void,1538 close: Effect.void,
1532 }),1539 }),
...@@ -1671,6 +1678,7 @@ describe("ClaudeAdapterV2 attachments", () => {...@@ -1671,6 +1678,7 @@ describe("ClaudeAdapterV2 attachments", () => {
1671 offer: () => Effect.void,1678 offer: () => Effect.void,
1672 setModel: () => Effect.void,1679 setModel: () => Effect.void,
1673 setPermissionMode: () => Effect.void,1680 setPermissionMode: () => Effect.void,
1681 stopTask: () => Effect.void,
1674 interrupt: Effect.void,1682 interrupt: Effect.void,
1675 close: Effect.void,1683 close: Effect.void,
1676 };1684 };
...@@ -1765,6 +1773,7 @@ describe("ClaudeAdapterV2 native fork", () => {...@@ -1765,6 +1773,7 @@ describe("ClaudeAdapterV2 native fork", () => {
1765 offer: () => Effect.void,1773 offer: () => Effect.void,
1766 setModel: () => Effect.void,1774 setModel: () => Effect.void,
1767 setPermissionMode: () => Effect.void,1775 setPermissionMode: () => Effect.void,
1776 stopTask: () => Effect.void,
1768 interrupt: Effect.void,1777 interrupt: Effect.void,
1769 close: Effect.void,1778 close: Effect.void,
1770 };1779 };
...@@ -2056,6 +2065,7 @@ describe("ClaudeAdapterV2 native session identity", () => {...@@ -2056,6 +2065,7 @@ describe("ClaudeAdapterV2 native session identity", () => {
2056 offer: () => Effect.void,2065 offer: () => Effect.void,
2057 setModel: () => Effect.void,2066 setModel: () => Effect.void,
2058 setPermissionMode: () => Effect.void,2067 setPermissionMode: () => Effect.void,
2068 stopTask: () => Effect.void,
2059 interrupt: Effect.void,2069 interrupt: Effect.void,
2060 close: Effect.void,2070 close: Effect.void,
2061 };2071 };
...@@ -2309,6 +2319,8 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2309,6 +2319,8 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2309 readonly interrupt?: Effect.Effect<void>;2319 readonly interrupt?: Effect.Effect<void>;
2310 readonly stopTask?: (taskId: string) => Effect.Effect<void>;2320 readonly stopTask?: (taskId: string) => Effect.Effect<void>;
2311 readonly environment?: NodeJS.ProcessEnv;2321 readonly environment?: NodeJS.ProcessEnv;
2322 readonly subagentLaunchToolUseId?: string;
2323 readonly nativeSessionId?: string;
2312 readonly settings?: ClaudeSettings;2324 readonly settings?: ClaudeSettings;
2313 // A CLI process opened after the first streams from its own queue, so the2325 // A CLI process opened after the first streams from its own queue, so the
2314 // first one can exit (Queue.shutdown) and a later turn can start another.2326 // first one can exit (Queue.shutdown) and a later turn can start another.
...@@ -2323,24 +2335,30 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2323,24 +2335,30 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2323 const sdkMessages = yield* Queue.unbounded<SDKMessage>();2335 const sdkMessages = yield* Queue.unbounded<SDKMessage>();
2324 const processQueues: Array<Queue.Queue<SDKMessage>> = [];2336 const processQueues: Array<Queue.Queue<SDKMessage>> = [];
2325 const processedMessages = new WeakMap<SDKMessage, Deferred.Deferred<void>>();2337 const processedMessages = new WeakMap<SDKMessage, Deferred.Deferred<void>>();
2326 const offerAndWait = Effect.fnUntraced(function* (message: SDKMessage) {2338 const offerAndWait = Effect.fnUntraced(function* (
2339 message: SDKMessage,
2340 offered?: Deferred.Deferred<void>,
2341 ) {
2327 const processed = yield* Deferred.make<void>();2342 const processed = yield* Deferred.make<void>();
2328 processedMessages.set(message, processed);2343 processedMessages.set(message, processed);
2329 yield* Queue.offer(sdkMessages, message);2344 yield* Queue.offer(sdkMessages, message);
2345 if (offered !== undefined) yield* Deferred.succeed(offered, undefined);
2330 yield* Deferred.await(processed);2346 yield* Deferred.await(processed);
2331 });2347 });
2332 const offeredMessages: Array<SDKUserMessage> = [];2348 const offeredMessages: Array<SDKUserMessage> = [];
2333 const permissionModeChanges: Array<string> = [];2349 const permissionModeChanges: Array<string> = [];
2334 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =2350 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
2335 [];2351 [];
2336 const subagentReceipts =2352 const continuationReceipts =
2337 yield* Queue.unbounded<2353 yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>();
2338 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated" }>
2339 >();
2340 const terminalReceipts =2354 const terminalReceipts =
2341 yield* Queue.unbounded<2355 yield* Queue.unbounded<
2342 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }>2356 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }>
2343 >();2357 >();
2358 const subagentReceipts =
2359 yield* Queue.unbounded<
2360 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated" }>
2361 >();
2344 const systemNoticeReceipts =2362 const systemNoticeReceipts =
2345 yield* Queue.unbounded<2363 yield* Queue.unbounded<
2346 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn_item.updated" }>2364 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn_item.updated" }>
...@@ -2359,10 +2377,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2359,10 +2377,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2359 offer: (request) =>2377 offer: (request) =>
2360 Effect.sync(() => {2378 Effect.sync(() => {
2361 continuationRequests.push(request);2379 continuationRequests.push(request);
2362 }),2380 }).pipe(Effect.andThen(Queue.offer(continuationReceipts, request))),
2363 },2381 },
2364 queryRunner: {2382 queryRunner: {
2365 allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION),2383 allocateSessionId: Effect.succeed(options?.nativeSessionId ?? WAKE_NATIVE_SESSION),
2366 open: (input) =>2384 open: (input) =>
2367 Effect.gen(function* () {2385 Effect.gen(function* () {
2368 openedOptions = input.options;2386 openedOptions = input.options;
...@@ -2377,6 +2395,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2377,6 +2395,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2377 }),2395 }),
2378 setModel: () => Effect.void,2396 setModel: () => Effect.void,
2379 setPermissionMode: () => Effect.void,2397 setPermissionMode: () => Effect.void,
2398 stopTask: options?.stopTask ?? (() => Effect.void),
2380 interrupt: Effect.void,2399 interrupt: Effect.void,
2381 close: Queue.shutdown(processMessages),2400 close: Queue.shutdown(processMessages),
2382 };2401 };
...@@ -2409,13 +2428,13 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2409,13 +2428,13 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2409 Effect.sync(() => {2428 Effect.sync(() => {
2410 permissionModeChanges.push(mode);2429 permissionModeChanges.push(mode);
2411 }),2430 }),
2412 ...(options?.stopTask === undefined ? {} : { stopTask: options.stopTask }),2431 stopTask: options?.stopTask ?? (() => Effect.void),
2413 interrupt: options?.interrupt ?? Effect.void,2432 interrupt: options?.interrupt ?? Effect.void,
2414 close: options?.close?.(sdkMessages) ?? Effect.void,2433 close: options?.close?.(sdkMessages) ?? Effect.void,
2415 };2434 };
2416 }),2435 }),
2417 forkSession: () => Effect.die("unused forkSession"),2436 forkSession: () => Effect.die("unused forkSession"),
2418 subagentLaunchToolUseId: () => Effect.succeed(null),2437 subagentLaunchToolUseId: () => Effect.succeed(options?.subagentLaunchToolUseId ?? null),
2419 assertComplete: Effect.void,2438 assertComplete: Effect.void,
2420 },2439 },
2421 });2440 });
...@@ -2436,7 +2455,9 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2436,7 +2455,9 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2436 Stream.runForEach((event) =>2455 Stream.runForEach((event) =>
2437 Effect.gen(function* () {2456 Effect.gen(function* () {
2438 events.push(event);2457 events.push(event);
2439 if (event.type === "subagent.updated") yield* Queue.offer(subagentReceipts, event);2458 if (event.type === "subagent.updated") {
2459 yield* Queue.offer(subagentReceipts, event);
2460 }
2440 if (event.type === "turn.terminal") {2461 if (event.type === "turn.terminal") {
2441 yield* Queue.offer(terminalReceipts, event);2462 yield* Queue.offer(terminalReceipts, event);
2442 }2463 }
...@@ -2474,6 +2495,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2474,6 +2495,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2474 },2495 },
2475 permissionModeChanges,2496 permissionModeChanges,
2476 continuationRequests,2497 continuationRequests,
2498 continuationReceipts,
2477 events,2499 events,
2478 terminalReceipts,2500 terminalReceipts,
2479 subagentReceipts,2501 subagentReceipts,
...@@ -4709,6 +4731,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4709,6 +4731,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4709 offer: () => Effect.void,4731 offer: () => Effect.void,
4710 setModel: () => Effect.void,4732 setModel: () => Effect.void,
4711 setPermissionMode: () => Effect.void,4733 setPermissionMode: () => Effect.void,
4734 stopTask: () => Effect.void,
4712 interrupt: Effect.void,4735 interrupt: Effect.void,
4713 // The first CLI process keeps streaming until the test ends4736 // The first CLI process keeps streaming until the test ends
4714 // it, so Stop stays parked waiting for it to exit.4737 // it, so Stop stays parked waiting for it to exit.
...@@ -4968,6 +4991,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4968,6 +4991,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4968 offer: () => Effect.void,4991 offer: () => Effect.void,
4969 setModel: () => Effect.void,4992 setModel: () => Effect.void,
4970 setPermissionMode: () => Effect.void,4993 setPermissionMode: () => Effect.void,
4994 stopTask: () => Effect.void,
4971 interrupt: Effect.void,4995 interrupt: Effect.void,
4972 close: Queue.shutdown(queue),4996 close: Queue.shutdown(queue),
4973 };4997 };
...@@ -6547,6 +6571,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6547,6 +6571,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6547 const harness = yield* makeWakeHarnessWithOptions({6571 const harness = yield* makeWakeHarnessWithOptions({
6548 close: (sdkMessages) =>6572 close: (sdkMessages) =>
6549 Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))),6573 Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))),
6574 stopTask: () => Effect.void,
6550 interrupt: Deferred.succeed(interruptStarted, undefined),6575 interrupt: Deferred.succeed(interruptStarted, undefined),
6551 });6576 });
6552 const idAllocator = yield* IdAllocator.IdAllocatorV2;6577 const idAllocator = yield* IdAllocator.IdAllocatorV2;
...@@ -6608,6 +6633,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6608,6 +6633,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6608 const harness = yield* makeWakeHarnessWithOptions({6633 const harness = yield* makeWakeHarnessWithOptions({
6609 close: (sdkMessages) =>6634 close: (sdkMessages) =>
6610 Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))),6635 Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))),
6636 stopTask: () => Effect.void,
6611 interrupt: Deferred.succeed(interruptStarted, undefined),6637 interrupt: Deferred.succeed(interruptStarted, undefined),
6612 });6638 });
6613 const idAllocator = yield* IdAllocator.IdAllocatorV2;6639 const idAllocator = yield* IdAllocator.IdAllocatorV2;
...@@ -9692,6 +9718,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -9692,6 +9718,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
9692 offer: () => Effect.void,9718 offer: () => Effect.void,
9693 setModel: () => Effect.void,9719 setModel: () => Effect.void,
9694 setPermissionMode: () => Effect.void,9720 setPermissionMode: () => Effect.void,
9721 stopTask: () => Effect.void,
9695 interrupt: Effect.void,9722 interrupt: Effect.void,
9696 // End this process stream so openQuery can replace it.9723 // End this process stream so openQuery can replace it.
9697 close: Queue.shutdown(sdkMessages),9724 close: Queue.shutdown(sdkMessages),
...@@ -9881,6 +9908,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -9881,6 +9908,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
9881 offer: () => Effect.void,9908 offer: () => Effect.void,
9882 setModel: () => Effect.void,9909 setModel: () => Effect.void,
9883 setPermissionMode: () => Effect.void,9910 setPermissionMode: () => Effect.void,
9911 stopTask: () => Effect.void,
9884 interrupt: Effect.void,9912 interrupt: Effect.void,
9885 close: Queue.shutdown(sdkMessages),9913 close: Queue.shutdown(sdkMessages),
9886 };9914 };
...@@ -10122,6 +10150,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10122,6 +10150,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10122 offer: () => Effect.void,10150 offer: () => Effect.void,
10123 setModel: () => Effect.void,10151 setModel: () => Effect.void,
10124 setPermissionMode: () => Effect.void,10152 setPermissionMode: () => Effect.void,
10153 stopTask: () => Effect.void,
10125 interrupt: Effect.void,10154 interrupt: Effect.void,
10126 close: Queue.shutdown(sdkMessages),10155 close: Queue.shutdown(sdkMessages),
10127 };10156 };
...@@ -10320,6 +10349,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10320,6 +10349,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10320 offer: () => Effect.void,10349 offer: () => Effect.void,
10321 setModel: () => Effect.void,10350 setModel: () => Effect.void,
10322 setPermissionMode: () => Effect.void,10351 setPermissionMode: () => Effect.void,
10352 stopTask: () => Effect.void,
10323 interrupt: Effect.void,10353 interrupt: Effect.void,
10324 close: Queue.shutdown(sdkMessages),10354 close: Queue.shutdown(sdkMessages),
10325 };10355 };
...@@ -10502,6 +10532,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10502,6 +10532,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10502 offer: () => Effect.void,10532 offer: () => Effect.void,
10503 setModel: () => Effect.void,10533 setModel: () => Effect.void,
10504 setPermissionMode: () => Effect.void,10534 setPermissionMode: () => Effect.void,
10535 stopTask: () => Effect.void,
10505 interrupt: Effect.void,10536 interrupt: Effect.void,
10506 close: Queue.shutdown(sdkMessages),10537 close: Queue.shutdown(sdkMessages),
10507 };10538 };
...@@ -10640,6 +10671,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10640,6 +10671,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10640 offer: () => Effect.void,10671 offer: () => Effect.void,
10641 setModel: () => Effect.void,10672 setModel: () => Effect.void,
10642 setPermissionMode: () => Effect.void,10673 setPermissionMode: () => Effect.void,
10674 stopTask: () => Effect.void,
10643 interrupt: Effect.void,10675 interrupt: Effect.void,
10644 close: Queue.shutdown(sdkMessages),10676 close: Queue.shutdown(sdkMessages),
10645 };10677 };
...@@ -10874,6 +10906,2345 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10874,6 +10906,2345 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10874 ),10906 ),
10875 );10907 );
1087610908
10909 const WORKFLOW_TASK_ID = "task-workflow-run";
10910 const WORKFLOW_TOOL_USE_ID = "toolu-workflow-run";
10911 const makeWorkflowHarnessWithOptions = (
10912 options?: Parameters<typeof makeWakeHarnessWithOptions>[0],
10913 ) =>
10914 Effect.gen(function* () {
10915 const fileSystem = yield* FileSystem.FileSystem;
10916 const configDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-wf-" });
10917 const transcriptDir = NodePath.join(configDir, "projects", "workflow");
10918 yield* fileSystem.makeDirectory(transcriptDir, { recursive: true });
10919 yield* fileSystem.writeFileString(
10920 NodePath.join(transcriptDir, "agent-a1.jsonl"),
10921 '{"type":"assistant","message":{"role":"assistant","id":"msg_a1","content":[{"type":"text","text":"A1 from the transcript"}]}}\n',
10922 );
10923 const harness = yield* makeWakeHarnessWithOptions({
10924 ...options,
10925 environment: { ...options?.environment, CLAUDE_CONFIG_DIR: configDir },
10926 });
10927 return { ...harness, transcriptDir };
10928 });
10929 const makeWorkflowHarness = makeWorkflowHarnessWithOptions();
10930
10931 const workflowToolUse = claudeSdkFrame({
10932 type: "assistant",
10933 message: {
10934 model: "claude-sonnet-4-6",
10935 content: [
10936 {
10937 type: "tool_use",
10938 id: WORKFLOW_TOOL_USE_ID,
10939 name: "Workflow",
10940 input: { script: "alpha.js" },
10941 },
10942 ],
10943 },
10944 parent_tool_use_id: null,
10945 uuid: "00000000-0000-4000-8000-000000001001",
10946 session_id: WAKE_NATIVE_SESSION,
10947 });
10948 const workflowTaskStarted = claudeSdkFrame({
10949 type: "system",
10950 subtype: "task_started",
10951 task_id: WORKFLOW_TASK_ID,
10952 tool_use_id: WORKFLOW_TOOL_USE_ID,
10953 description: "Run the alpha workflow",
10954 task_type: "local_workflow",
10955 workflow_name: "probe-wf",
10956 uuid: "00000000-0000-4000-8000-000000001002",
10957 session_id: WAKE_NATIVE_SESSION,
10958 });
10959 const workflowLaunchAck = (transcriptDir: string) =>
10960 claudeSdkFrame({
10961 type: "user",
10962 message: {
10963 role: "user",
10964 content: [
10965 {
10966 type: "tool_result",
10967 tool_use_id: WORKFLOW_TOOL_USE_ID,
10968 content: [{ type: "text", text: "Workflow launched." }],
10969 },
10970 ],
10971 },
10972 parent_tool_use_id: null,
10973 uuid: "00000000-0000-4000-8000-000000001003",
10974 session_id: WAKE_NATIVE_SESSION,
10975 tool_use_result: {
10976 taskType: "local_workflow",
10977 runId: "wf_probe",
10978 transcriptDir,
10979 },
10980 });
10981 const workflowSnapshot = (input: {
10982 readonly uuid: string;
10983 readonly state: "queued" | "start" | "done";
10984 readonly secondState?: "queued" | "start" | "done";
10985 readonly startedAt?: number;
10986 readonly label?: string;
10987 readonly prompt?: string;
10988 readonly model?: string;
10989 readonly attempt?: number;
10990 readonly omitResult?: boolean;
10991 }) =>
10992 claudeSdkFrame({
10993 type: "system",
10994 subtype: "task_progress",
10995 task_id: WORKFLOW_TASK_ID,
10996 tool_use_id: WORKFLOW_TOOL_USE_ID,
10997 description: "Alpha: alpha:one",
10998 workflow_progress: [
10999 { type: "workflow_phase", index: 1, title: "Alpha" },
11000 {
11001 type: "workflow_agent",
11002 index: 1,
11003 label: input.label ?? "alpha:one",
11004 agentId: "a1",
11005 state: input.state,
11006 phaseIndex: 1,
11007 phaseTitle: "Alpha",
11008 model: input.model ?? "claude-opus-5[1m]",
11009 promptPreview: input.prompt ?? "Reply with exactly: A1",
11010 ...(input.startedAt === undefined ? {} : { startedAt: input.startedAt }),
11011 ...(input.attempt === undefined ? {} : { attempt: input.attempt }),
11012 ...(input.state === "done" && input.omitResult !== true
11013 ? { resultPreview: "A1 excerpt" }
11014 : {}),
11015 },
11016 {
11017 type: "workflow_agent",
11018 index: 2,
11019 label: "alpha:two",
11020 agentId: "a2",
11021 state: input.secondState ?? input.state,
11022 phaseIndex: 1,
11023 phaseTitle: "Alpha",
11024 promptPreview: "Reply with exactly: A2",
11025 ...(input.state === "done" ? { resultPreview: "A2 excerpt" } : {}),
11026 },
11027 ],
11028 uuid: input.uuid,
11029 session_id: WAKE_NATIVE_SESSION,
11030 });
11031 const workflowNotification = (input: {
11032 readonly uuid: string;
11033 readonly status: "completed" | "failed" | "stopped";
11034 }) =>
11035 claudeSdkFrame({
11036 type: "system",
11037 subtype: "task_notification",
11038 task_id: WORKFLOW_TASK_ID,
11039 tool_use_id: WORKFLOW_TOOL_USE_ID,
11040 status: input.status,
11041 summary: "Workflow finished.",
11042 output_file: "/tmp/workflow.output",
11043 uuid: input.uuid,
11044 session_id: WAKE_NATIVE_SESSION,
11045 });
11046 const workflowCoordinatorEvents = (
11047 events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>,
11048 ) =>
11049 events.filter(
11050 (
11051 event,
11052 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated" }> =>
11053 event.type === "subagent.updated" &&
11054 event.subagent.nativeTaskRef?.nativeId === WORKFLOW_TASK_ID,
11055 );
11056 const workflowMemberEvents = (
11057 events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>,
11058 index: number,
11059 ) =>
11060 events.filter(
11061 (
11062 event,
11063 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated" }> =>
11064 event.type === "subagent.updated" &&
11065 event.subagent.nativeTaskRef?.nativeId?.endsWith(`:agent:${index}`) === true,
11066 );
11067 const threadMessages = (
11068 events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>,
11069 threadId: ThreadId | null | undefined,
11070 ) =>
11071 events.filter(
11072 (
11073 event,
11074 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "message.updated" }> =>
11075 event.type === "message.updated" && event.message.threadId === threadId,
11076 );
11077
11078 const launchWorkflow = Effect.fnUntraced(function* (input: {
11079 readonly harness: Effect.Success<typeof makeWorkflowHarness>;
11080 readonly snapshotUuid: string;
11081 readonly state?: "queued" | "start";
11082 }) {
11083 yield* input.harness.offerAndWait(workflowToolUse);
11084 yield* input.harness.offerAndWait(workflowTaskStarted);
11085 yield* input.harness.offerAndWait(workflowLaunchAck(input.harness.transcriptDir));
11086 yield* input.harness.offerAndWait(
11087 workflowSnapshot({ uuid: input.snapshotUuid, state: input.state ?? "start" }),
11088 );
11089 yield* awaitUntil(
11090 () => workflowMemberEvents(input.harness.events, 2).length === 1,
11091 "workflow members projected",
11092 );
11093 });
11094
11095 it.effect.each(["Agent", "Workflow"] as const)(
11096 "keeps a workflow running after its %s launch reply without an async flag",
11097 (toolName) =>
11098 Effect.scoped(
11099 Effect.gen(function* () {
11100 const harness = yield* makeWorkflowHarness;
11101 yield* harness.runtime.startTurn(
11102 makeClaudeTestTurnInput({
11103 threadId: harness.threadId,
11104 providerThread: harness.providerThread,
11105 now: yield* DateTime.now,
11106 attemptId: RunAttemptId.make(`attempt-workflow-launch-${toolName}`),
11107 text: "Run the workflow.",
11108 attachments: [],
11109 }),
11110 );
11111 yield* harness.offerAndWait(
11112 claudeSdkFrame({
11113 ...workflowToolUse,
11114 message: {
11115 model: "claude-sonnet-4-6",
11116 content: [
11117 { type: "tool_use", id: WORKFLOW_TOOL_USE_ID, name: toolName, input: {} },
11118 ],
11119 },
11120 }),
11121 );
11122 yield* harness.offerAndWait(workflowTaskStarted);
11123 yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir));
11124 yield* harness.offerAndWait(
11125 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001200", state: "start" }),
11126 );
11127 yield* Queue.offer(
11128 harness.sdkMessages,
11129 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001201", result: "Launched." }),
11130 );
11131 yield* Queue.take(harness.terminalReceipts);
11132 assert.equal(
11133 workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status,
11134 "running",
11135 );
11136 assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running");
11137 assert.isTrue(yield* harness.hasPendingBackgroundWork);
11138 const toolItems = harness.events.flatMap((event) =>
11139 event.type === "turn_item.updated" &&
11140 event.turnItem.type === "dynamic_tool" &&
11141 event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TOOL_USE_ID
11142 ? [event.turnItem.status]
11143 : [],
11144 );
11145 assert.deepEqual(toolItems, toolName === "Workflow" ? ["running", "completed"] : []);
11146 yield* harness.offerAndWait(
11147 workflowNotification({
11148 uuid: "00000000-0000-4000-8000-000000001202",
11149 status: "completed",
11150 }),
11151 );
11152 yield* Queue.take(harness.continuationReceipts);
11153 yield* harness.runtime.startTurn(
11154 makeClaudeTestTurnInput({
11155 threadId: harness.threadId,
11156 providerThread: harness.providerThread,
11157 now: yield* DateTime.now,
11158 attemptId: RunAttemptId.make(`attempt-workflow-complete-${toolName}`),
11159 text: "Workflow finished.",
11160 attachments: [],
11161 providerTurnOrdinal: 2,
11162 messageCreatedBy: "agent",
11163 messageCreationSource: "provider",
11164 }),
11165 );
11166 yield* Queue.offer(
11167 harness.sdkMessages,
11168 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001205", result: "Finished." }),
11169 );
11170 yield* Queue.take(harness.terminalReceipts);
11171 assert.equal(
11172 workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status,
11173 "completed",
11174 );
11175 assert.equal(
11176 workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status,
11177 "completed",
11178 );
11179 assert.isFalse(yield* harness.hasPendingBackgroundWork);
11180 }).pipe(
11181 Effect.provide(
11182 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
11183 ),
11184 ),
11185 ),
11186 );
11187
11188 it.effect("scopes workflow member artifacts to the provider thread", () =>
11189 Effect.scoped(
11190 Effect.gen(function* () {
11191 const memberArtifacts: Array<Array<string>> = [];
11192 const memberThreads: Array<ThreadId | null | undefined> = [];
11193 for (const nativeSessionId of ["workflow-session-a", "workflow-session-b"]) {
11194 const harness = yield* makeWorkflowHarnessWithOptions({ nativeSessionId });
11195 yield* harness.runtime.startTurn(
11196 makeClaudeTestTurnInput({
11197 threadId: harness.threadId,
11198 providerThread: harness.providerThread,
11199 now: yield* DateTime.now,
11200 attemptId: RunAttemptId.make(`attempt-${nativeSessionId}`),
11201 text: "Run the workflow.",
11202 attachments: [],
11203 }),
11204 );
11205 for (const frame of [
11206 workflowToolUse,
11207 workflowTaskStarted,
11208 workflowLaunchAck(harness.transcriptDir),
11209 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001203", state: "done" }),
11210 ]) {
11211 yield* harness.offerAndWait(claudeSdkFrame({ ...frame, session_id: nativeSessionId }));
11212 }
11213 yield* Queue.offer(
11214 harness.sdkMessages,
11215 claudeSdkFrame({
11216 ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001204", result: "Done." }),
11217 session_id: nativeSessionId,
11218 }),
11219 );
11220 yield* Queue.take(harness.terminalReceipts);
11221 const member = workflowMemberEvents(harness.events, 1).at(-1)?.subagent;
11222 assert.isDefined(member);
11223 memberThreads.push(member?.childThreadId);
11224 const ids = harness.events.flatMap<string>((event) => {
11225 if (event.type === "subagent.updated" && event.subagent.id === member?.id)
11226 return [event.subagent.id];
11227 if (event.type === "node.updated" && event.node.threadId === member?.childThreadId)
11228 return [event.node.id];
11229 if (
11230 event.type === "message.updated" &&
11231 event.message.threadId === member?.childThreadId
11232 )
11233 return [event.message.id];
11234 if (
11235 event.type === "turn_item.updated" &&
11236 event.turnItem.threadId === member?.childThreadId
11237 )
11238 return [event.turnItem.id];
11239 return [];
11240 });
11241 assert.isAtLeast(new Set(ids).size, 6);
11242 memberArtifacts.push(ids);
11243 }
11244 assert.notEqual(memberThreads[0], memberThreads[1]);
11245 assert.deepEqual(
11246 memberArtifacts[0]?.filter((id) => memberArtifacts[1]?.includes(id)),
11247 [],
11248 );
11249 }).pipe(
11250 Effect.provide(
11251 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
11252 ),
11253 ),
11254 ),
11255 );
11256
11257 it.effect("stops only a live workflow task and waits for its stopped notification", () =>
11258 Effect.scoped(
11259 Effect.gen(function* () {
11260 const stoppedTasks: Array<string> = [];
11261 let interrupted = false;
11262 let closed = false;
11263 const harness = yield* makeWorkflowHarnessWithOptions({
11264 stopTask: (taskId) =>
11265 Effect.sync(() => {
11266 stoppedTasks.push(taskId);
11267 }),
11268 interrupt: Effect.sync(() => {
11269 interrupted = true;
11270 }),
11271 close: () =>
11272 Effect.sync(() => {
11273 closed = true;
11274 }),
11275 });
11276 yield* harness.runtime.startTurn(
11277 makeClaudeTestTurnInput({
11278 threadId: harness.threadId,
11279 providerThread: harness.providerThread,
11280 now: yield* DateTime.now,
11281 attemptId: RunAttemptId.make("attempt-workflow-stop"),
11282 text: "Run the workflow.",
11283 attachments: [],
11284 }),
11285 );
11286 yield* harness.offerAndWait(workflowToolUse);
11287 yield* harness.offerAndWait(workflowTaskStarted);
11288 yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir));
11289 yield* harness.offerAndWait(
11290 workflowSnapshot({
11291 uuid: "00000000-0000-4000-8000-000000001100",
11292 state: "start",
11293 }),
11294 );
11295 let member = yield* Queue.take(harness.subagentReceipts);
11296 while (!member.subagent.nativeTaskRef?.nativeId?.endsWith(":agent:2")) {
11297 member = yield* Queue.take(harness.subagentReceipts);
11298 }
11299 const stopTask = harness.runtime.stopTask!;
11300 for (const taskId of ["missing-task", `${WORKFLOW_TASK_ID}:agent:1`]) {
11301 const exit = yield* stopTask({ providerThread: harness.providerThread, taskId }).pipe(
11302 Effect.exit,
11303 );
11304 assert.isTrue(Exit.isFailure(exit));
11305 }
11306 const wrongThread = {
11307 ...harness.providerThread,
11308 nativeThreadRef: {
11309 driver: harness.providerThread.driver,
11310 nativeId: "wrong-session",
11311 strength: "strong" as const,
11312 },
11313 };
11314 assert.isTrue(
11315 Exit.isFailure(
11316 yield* stopTask({
11317 providerThread: wrongThread,
11318 taskId: WORKFLOW_TASK_ID,
11319 }).pipe(Effect.exit),
11320 ),
11321 );
11322 assert.deepEqual(stoppedTasks, []);
11323 yield* stopTask({ providerThread: harness.providerThread, taskId: WORKFLOW_TASK_ID });
11324 assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]);
11325 assert.isFalse(interrupted);
11326 assert.isFalse(closed);
11327 assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, "running");
11328 yield* harness.offerAndWait(
11329 workflowNotification({
11330 uuid: "00000000-0000-4000-8000-000000001101",
11331 status: "stopped",
11332 }),
11333 );
11334 member = yield* Queue.take(harness.subagentReceipts);
11335 while (!member.subagent.nativeTaskRef?.nativeId?.endsWith(":agent:2")) {
11336 member = yield* Queue.take(harness.subagentReceipts);
11337 }
11338 assert.equal(
11339 workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status,
11340 "cancelled",
11341 );
11342 for (const index of [1, 2]) {
11343 assert.equal(
11344 workflowMemberEvents(harness.events, index).at(-1)?.subagent.status,
11345 "cancelled",
11346 );
11347 }
11348 assert.isTrue(
11349 Exit.isFailure(
11350 yield* stopTask({
11351 providerThread: harness.providerThread,
11352 taskId: WORKFLOW_TASK_ID,
11353 }).pipe(Effect.exit),
11354 ),
11355 );
11356 assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]);
11357 }).pipe(
11358 Effect.provide(
11359 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
11360 ),
11361 ),
11362 ),
11363 );
11364
11365 it.effect("projects each workflow member as its own subagent thread", () =>
11366 Effect.scoped(
11367 Effect.gen(function* () {
11368 const harness = yield* makeWorkflowHarness;
11369 const now = yield* DateTime.now;
11370 const coordinatorTurnItems = () =>
11371 harness.events.filter(
11372 (event) =>
11373 event.type === "turn_item.updated" &&
11374 event.turnItem.type === "subagent" &&
11375 event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID,
11376 );
11377
11378 yield* harness.runtime.startTurn(
11379 makeClaudeTestTurnInput({
11380 threadId: harness.threadId,
11381 providerThread: harness.providerThread,
11382 now,
11383 attemptId: RunAttemptId.make("attempt-claude-workflow-members"),
11384 text: "Run the alpha workflow.",
11385 attachments: [],
11386 }),
11387 );
11388 yield* launchWorkflow({
11389 harness,
11390 snapshotUuid: "00000000-0000-4000-8000-000000001004",
11391 state: "queued",
11392 });
11393
11394 const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent;
11395 const first = workflowMemberEvents(harness.events, 1).at(-1)?.subagent;
11396 const second = workflowMemberEvents(harness.events, 2).at(-1)?.subagent;
11397 assert.equal(first?.status, "pending");
11398 assert.isNull(first?.startedAt);
11399 assert.equal(second?.status, "pending");
11400 assert.isNull(second?.startedAt);
11401 const coordinatorThread = harness.events.find(
11402 (event) =>
11403 event.type === "app_thread.created" &&
11404 event.appThread.id === coordinator?.childThreadId,
11405 );
11406 assert.equal(
11407 coordinatorThread?.type === "app_thread.created"
11408 ? coordinatorThread.appThread.title
11409 : null,
11410 "Workflow · probe-wf",
11411 );
11412 assert.equal(first?.title, "alpha:one");
11413 assert.equal(first?.prompt, "Reply with exactly: A1");
11414 assert.equal(first?.model, "claude-opus-5[1m]");
11415 // Members hang off the coordinator's own thread, not the launching run's.
11416 assert.equal(first?.threadId, coordinator?.childThreadId);
11417 assert.equal(second?.threadId, coordinator?.childThreadId);
11418 assert.notEqual(first?.childThreadId, second?.childThreadId);
11419
11420 assert.equal(
11421 harness.events.find(
11422 (
11423 event,
11424 ): event is Extract<
11425 ProviderAdapter.ProviderAdapterV2Event,
11426 { type: "app_thread.created" }
11427 > => event.type === "app_thread.created" && event.appThread.id === first?.childThreadId,
11428 )?.appThread.title,
11429 "alpha:one",
11430 );
11431 const memberNodes = harness.events.filter(
11432 (
11433 event,
11434 ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "node.updated" }> =>
11435 event.type === "node.updated" &&
11436 event.node.nativeItemRef?.nativeId === first?.nativeTaskRef?.nativeId,
11437 );
11438 assert.deepEqual(
11439 memberNodes.map((event) => event.node.kind),
11440 ["subagent", "root_turn"],
11441 );
11442 assert.deepEqual(
11443 memberNodes.map((event) => [event.node.status, event.node.startedAt]),
11444 [
11445 ["pending", null],
11446 ["pending", null],
11447 ],
11448 );
11449 assert.deepEqual(
11450 threadMessages(harness.events, first?.childThreadId).map((event) => event.message.text),
11451 ["Reply with exactly: A1"],
11452 );
11453 assert.deepEqual(
11454 threadMessages(harness.events, second?.childThreadId).map((event) => event.message.text),
11455 ["Reply with exactly: A2"],
11456 );
11457
11458 yield* TestClock.adjust("60 seconds");
11459 const memberStartedAt = DateTime.add(now, { seconds: 30 });
11460 yield* harness.offerAndWait(
11461 workflowSnapshot({
11462 uuid: "00000000-0000-4000-8000-000000001007",
11463 state: "start",
11464 secondState: "queued",
11465 startedAt: DateTime.toEpochMillis(memberStartedAt),
11466 }),
11467 );
11468 let started = yield* Queue.take(harness.subagentReceipts);
11469 while (started.subagent.id !== first?.id || started.subagent.status !== "running") {
11470 started = yield* Queue.take(harness.subagentReceipts);
11471 }
11472 assert.deepEqual(started.subagent.startedAt, memberStartedAt);
11473 const startedRoot = harness.events.findLast(
11474 (event) => event.type === "node.updated" && event.node.threadId === first?.childThreadId,
11475 );
11476 assert.equal(
11477 startedRoot?.type === "node.updated" ? startedRoot.node.status : null,
11478 "running",
11479 );
11480 assert.deepEqual(
11481 startedRoot?.type === "node.updated" ? startedRoot.node.startedAt : null,
11482 memberStartedAt,
11483 );
11484
11485 const turnItemsBeforeRepeat = coordinatorTurnItems().length;
11486 yield* Queue.offer(
11487 harness.sdkMessages,
11488 workflowSnapshot({
11489 uuid: "00000000-0000-4000-8000-000000001005",
11490 state: "start",
11491 secondState: "queued",
11492 }),
11493 );
11494 // The coordinator's turn item is emitted after its members are
11495 // projected, so a re-emitted member would already be in `events`.
11496 yield* awaitUntil(
11497 () => coordinatorTurnItems().length > turnItemsBeforeRepeat,
11498 "repeated workflow snapshot",
11499 );
11500 assert.lengthOf(workflowMemberEvents(harness.events, 1), 2);
11501 assert.lengthOf(workflowMemberEvents(harness.events, 2), 1);
11502
11503 const firstThreadId = first?.childThreadId;
11504 const firstThreadEvents = () =>
11505 harness.events.filter(
11506 (
11507 event,
11508 ): event is Extract<
11509 ProviderAdapter.ProviderAdapterV2Event,
11510 { type: "app_thread.created" }
11511 > => event.type === "app_thread.created" && event.appThread.id === firstThreadId,
11512 );
11513 const turnItemsBeforeMetadata = coordinatorTurnItems().length;
11514 yield* Queue.offer(
11515 harness.sdkMessages,
11516 workflowSnapshot({
11517 uuid: "00000000-0000-4000-8000-000000001017",
11518 state: "start",
11519 secondState: "queued",
11520 label: "alpha:review",
11521 prompt: "Review the change",
11522 model: "claude-sonnet-4-6",
11523 }),
11524 );
11525 yield* awaitUntil(
11526 () => coordinatorTurnItems().length > turnItemsBeforeMetadata,
11527 "updated workflow member metadata",
11528 );
11529 assert.equal(
11530 workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title,
11531 "alpha:review",
11532 );
11533 assert.equal(
11534 workflowMemberEvents(harness.events, 1).at(-1)?.subagent.prompt,
11535 "Review the change",
11536 );
11537 assert.equal(
11538 workflowMemberEvents(harness.events, 1).at(-1)?.subagent.model,
11539 "claude-sonnet-4-6",
11540 );
11541 assert.equal(firstThreadEvents().at(-1)?.appThread.title, "alpha:review");
11542 assert.equal(
11543 firstThreadEvents().at(-1)?.appThread.modelSelection.model,
11544 "claude-sonnet-4-6",
11545 );
11546 assert.deepEqual(
11547 firstThreadEvents().at(-1)?.appThread.createdAt,
11548 firstThreadEvents()[0]?.appThread.createdAt,
11549 );
11550 assert.equal(
11551 threadMessages(harness.events, firstThreadId).at(-1)?.message.text,
11552 "Review the change",
11553 );
11554
11555 yield* TestClock.adjust("10 seconds");
11556 yield* harness.offerAndWait(
11557 workflowSnapshot({
11558 uuid: "00000000-0000-4000-8000-000000001018",
11559 state: "done",
11560 secondState: "queued",
11561 omitResult: true,
11562 }),
11563 );
11564 let finished = yield* Queue.take(harness.subagentReceipts);
11565 while (finished.subagent.id !== first?.id || finished.subagent.status !== "completed") {
11566 finished = yield* Queue.take(harness.subagentReceipts);
11567 }
11568 const completedAt = finished.subagent.completedAt;
11569 assert.deepEqual(completedAt, DateTime.add(now, { seconds: 70 }));
11570 yield* TestClock.adjust("60 seconds");
11571 yield* harness.offerAndWait(
11572 workflowSnapshot({
11573 uuid: "00000000-0000-4000-8000-000000001019",
11574 state: "done",
11575 secondState: "queued",
11576 }),
11577 );
11578 let excerpt = yield* Queue.take(harness.subagentReceipts);
11579 while (excerpt.subagent.id !== first?.id || excerpt.subagent.result !== "A1 excerpt") {
11580 excerpt = yield* Queue.take(harness.subagentReceipts);
11581 }
11582 assert.deepEqual(excerpt.subagent.completedAt, completedAt);
11583 const finishedRoot = harness.events.findLast(
11584 (event) => event.type === "node.updated" && event.node.threadId === firstThreadId,
11585 );
11586 assert.deepEqual(
11587 finishedRoot?.type === "node.updated" ? finishedRoot.node.completedAt : undefined,
11588 completedAt,
11589 );
11590
11591 yield* harness.offerAndWait(
11592 workflowNotification({ uuid: "00000000-0000-4000-8000-000000001008", status: "stopped" }),
11593 );
11594 yield* Queue.offer(
11595 harness.sdkMessages,
11596 makeResultFrame({
11597 uuid: "00000000-0000-4000-8000-000000001006",
11598 result: "Launched the workflow.",
11599 }),
11600 );
11601 yield* Queue.take(harness.terminalReceipts);
11602 const stopped = workflowMemberEvents(harness.events, 2).at(-1)?.subagent;
11603 assert.equal(stopped?.status, "cancelled");
11604 assert.isNull(stopped?.startedAt);
11605 const stoppedRoot = harness.events.findLast(
11606 (event) => event.type === "node.updated" && event.node.threadId === second?.childThreadId,
11607 );
11608 assert.equal(
11609 stoppedRoot?.type === "node.updated" ? stoppedRoot.node.status : null,
11610 "cancelled",
11611 );
11612 assert.isNull(
11613 stoppedRoot?.type === "node.updated" ? stoppedRoot.node.startedAt : undefined,
11614 );
11615 }).pipe(
11616 Effect.provide(
11617 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
11618 ),
11619 ),
11620 ),
11621 );
11622
11623 it.effect("projects workflow member threads after resuming a restarted session", () =>
11624 Effect.scoped(
11625 Effect.gen(function* () {
11626 const harness = yield* makeWorkflowHarnessWithOptions({
11627 subagentLaunchToolUseId: WORKFLOW_TOOL_USE_ID,
11628 });
11629 yield* harness.runtime.startTurn(
11630 makeClaudeTestTurnInput({
11631 threadId: harness.threadId,
11632 providerThread: harness.providerThread,
11633 now: yield* DateTime.now,
11634 attemptId: RunAttemptId.make("attempt-claude-workflow-resume"),
11635 text: "Continue the workflow.",
11636 attachments: [],
11637 }),
11638 );
11639 yield* harness.offerAndWait(
11640 claudeSdkFrame({
11641 ...workflowToolUse,
11642 message: {
11643 model: "claude-sonnet-4-6",
11644 content: [
11645 { type: "tool_use", id: "toolu-resume-workflow", name: "SendMessage", input: {} },
11646 ],
11647 },
11648 }),
11649 );
11650 yield* harness.offerAndWait(
11651 claudeSdkFrame({
11652 ...workflowTaskStarted,
11653 tool_use_id: "toolu-resume-workflow",
11654 prompt: "Continue the workflow.",
11655 }),
11656 );
11657 yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir));
11658 yield* harness.offerAndWait(
11659 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001040", state: "start" }),
11660 );
11661 yield* harness.offerAndWait(
11662 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001041", result: "Resumed." }),
11663 );
11664 yield* Queue.take(harness.terminalReceipts);
11665 const member = workflowMemberEvents(harness.events, 1).at(-1)?.subagent;
11666 const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent;
11667 assert.isDefined(member);
11668 assert.equal(member?.status, "running");
11669 assert.isTrue(
11670 harness.events.some(
11671 (event) =>
11672 event.type === "app_thread.created" && event.appThread.id === member?.childThreadId,
11673 ),
11674 );
11675 assert.deepEqual(
11676 threadMessages(harness.events, member?.childThreadId).map((event) => event.message.text),
11677 ["Reply with exactly: A1"],
11678 );
11679 assert.isFalse(
11680 harness.events.some(
11681 (event) =>
11682 event.type === "app_thread.created" &&
11683 event.appThread.id === coordinator?.childThreadId,
11684 ),
11685 );
11686 }).pipe(
11687 Effect.provide(
11688 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
11689 ),
11690 ),
11691 ),
11692 );
11693
11694 it.effect("does not republish a workflow roster on progress-only frames", () =>
11695 Effect.scoped(
11696 Effect.gen(function* () {
11697 const harness = yield* makeWorkflowHarness;
11698 const now = yield* DateTime.now;
11699 yield* harness.runtime.startTurn(
11700 makeClaudeTestTurnInput({
11701 threadId: harness.threadId,
11702 providerThread: harness.providerThread,
11703 now,
11704 attemptId: RunAttemptId.make("attempt-claude-workflow-progress-only"),
11705 text: "Run the alpha workflow.",
11706 attachments: [],
11707 }),
11708 );
11709 yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001120" });
11710 const coordinatorCount = () => workflowCoordinatorEvents(harness.events).length;
11711 const offerProgressOnly = (index: number) =>
11712 Queue.offer(
11713 harness.sdkMessages,
11714 claudeSdkFrame({
11715 type: "system",
11716 subtype: "task_progress",
11717 task_id: WORKFLOW_TASK_ID,
11718 tool_use_id: WORKFLOW_TOOL_USE_ID,
11719 description: "Member is working",
11720 uuid: `00000000-0000-4000-8000-${String(1200 + index).padStart(12, "0")}`,
11721 session_id: WAKE_NATIVE_SESSION,
11722 }),
11723 );
11724
11725 const inTurnCount = coordinatorCount();
11726 for (let index = 0; index < 5; index++) yield* offerProgressOnly(index);
11727 yield* Queue.offer(
11728 harness.sdkMessages,
11729 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001121", state: "start" }),
11730 );
11731 yield* awaitUntil(() => coordinatorCount() > inTurnCount, "in-turn workflow snapshot");
11732 assert.equal(coordinatorCount(), inTurnCount + 1);
11733
11734 yield* Queue.offer(
11735 harness.sdkMessages,
11736 makeResultFrame({
11737 uuid: "00000000-0000-4000-8000-000000001122",
11738 result: "Launched the workflow.",
11739 }),
11740 );
11741 yield* Queue.take(harness.terminalReceipts);
11742
11743 const betweenTurnCount = coordinatorCount();
11744 for (let index = 5; index < 10; index++) yield* offerProgressOnly(index);
11745 yield* Queue.offer(
11746 harness.sdkMessages,
11747 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001123", state: "done" }),
11748 );
11749 yield* awaitUntil(
11750 () => coordinatorCount() > betweenTurnCount,
11751 "between-turn workflow snapshot",
11752 );
11753 assert.equal(coordinatorCount(), betweenTurnCount + 1);
11754 }).pipe(
11755 Effect.provide(
11756 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
11757 ),
11758 ),
11759 ),
11760 );
11761
11762 it.effect("keeps resumed workflow tools open after stale interrupted cleanup", () =>
11763 Effect.scoped(
11764 Effect.gen(function* () {
11765 const clock = yield* Clock.Clock;
11766 const cleanupPaused = yield* Deferred.make<void>();
11767 const releaseCleanup = yield* Deferred.make<void>();
11768 let readsUntilPause = 0;
11769 yield* Effect.gen(function* () {
11770 const harness = yield* makeWorkflowHarnessWithOptions({
11771 close: Queue.shutdown,
11772 freshQueueOnReopen: true,
11773 });
11774 const oldAttempt = RunAttemptId.make("attempt-workflow-stale-cleanup-old");
11775 const oldBash = "toolu-workflow-stale-cleanup-old-bash";
11776 const newBash = "toolu-workflow-stale-cleanup-new-bash";
11777 const resumeTool = "toolu-workflow-stale-cleanup-resume";
11778 const toolUpdates = (nativeId: string) =>
11779 harness.events.flatMap((event) =>
11780 event.type === "turn_item.updated" &&
11781 event.turnItem.type === "command_execution" &&
11782 event.turnItem.nativeItemRef?.nativeId === nativeId
11783 ? [event.turnItem]
11784 : [],
11785 );
11786 yield* harness.runtime.startTurn(
11787 makeClaudeTestTurnInput({
11788 threadId: harness.threadId,
11789 providerThread: harness.providerThread,
11790 now: yield* DateTime.now,
11791 attemptId: oldAttempt,
11792 text: "Run the workflow.",
11793 attachments: [],
11794 }),
11795 );
11796 yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001150" });
11797 for (const frame of makeSubagentAssistantFrames({
11798 parentToolUseId: WORKFLOW_TOOL_USE_ID,
11799 uuid: "00000000-0000-4000-8000-000000001151",
11800 bashToolUseId: oldBash,
11801 })) {
11802 yield* harness.offerAndWait(frame);
11803 }
11804 yield* awaitUntil(() => toolUpdates(oldBash).length === 1, "old workflow tool running");
11805 readsUntilPause = 2;
11806 const stop = yield* harness.runtime
11807 .interruptTurn({
11808 providerThread: harness.providerThread,
11809 providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
11810 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
11811 nativeTurnId: `turn:${oldAttempt}`,
11812 }),
11813 })
11814 .pipe(Effect.forkScoped);
11815 yield* Deferred.await(cleanupPaused);
11816 yield* TestClock.adjust("10 seconds");
11817 yield* Fiber.join(stop);
11818 yield* Queue.take(harness.terminalReceipts);
11819 const oldToolCount = toolUpdates(oldBash).length;
11820 yield* harness.runtime.startTurn(
11821 makeClaudeTestTurnInput({
11822 threadId: harness.threadId,
11823 providerThread: harness.providerThread,
11824 now: yield* DateTime.now,
11825 attemptId: RunAttemptId.make("attempt-workflow-stale-cleanup-new"),
11826 text: "Resume the workflow.",
11827 attachments: [],
11828 providerTurnOrdinal: 2,
11829 }),
11830 );
11831 const resumedQueue = harness.processQueues[1]!;
11832 yield* Queue.offer(
11833 resumedQueue,
11834 claudeSdkFrame({
11835 ...workflowTaskStarted,
11836 tool_use_id: resumeTool,
11837 prompt: "Resume the workflow.",
11838 uuid: "00000000-0000-4000-8000-000000001152",
11839 }),
11840 );
11841 for (const frame of makeSubagentAssistantFrames({
11842 parentToolUseId: resumeTool,
11843 uuid: "00000000-0000-4000-8000-000000001153",
11844 bashToolUseId: newBash,
11845 })) {
11846 yield* Queue.offer(resumedQueue, frame);
11847 }
11848 yield* awaitUntil(
11849 () => toolUpdates(newBash).length === 1,
11850 "resumed workflow tool running",
11851 );
11852 yield* Deferred.succeed(releaseCleanup, undefined);
11853 yield* Queue.take(harness.terminalReceipts);
11854 assert.lengthOf(toolUpdates(oldBash), oldToolCount);
11855 assert.deepEqual(
11856 toolUpdates(newBash).map((item) => item.status),
11857 ["running"],
11858 );
11859 yield* Queue.offer(
11860 resumedQueue,
11861 makeSubagentToolResultFrame({
11862 parentToolUseId: resumeTool,
11863 uuid: "00000000-0000-4000-8000-000000001154",
11864 toolUseId: newBash,
11865 }),
11866 );
11867 yield* awaitUntil(
11868 () => toolUpdates(newBash).at(-1)?.status === "completed",
11869 "resumed workflow tool completed",
11870 );
11871 assert.equal(toolUpdates(newBash).at(-1)?.output, "ok");
11872 }).pipe(
11873 Effect.provideService(Clock.Clock, {
11874 ...clock,
11875 currentTimeMillis: Effect.gen(function* () {
11876 if (readsUntilPause > 0 && --readsUntilPause === 0) {
11877 yield* Deferred.succeed(cleanupPaused, undefined);
11878 yield* Deferred.await(releaseCleanup);
11879 }
11880 return yield* clock.currentTimeMillis;
11881 }),
11882 }),
11883 );
11884 }).pipe(
11885 Effect.provide(
11886 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
11887 ),
11888 ),
11889 ),
11890 );
11891
11892 it.effect.each(["root result", "query close"] as const)(
11893 "keeps a waiting continuation inactive until replay can handle %s",
11894 (arrival) =>
11895 Effect.scoped(
11896 Effect.gen(function* () {
11897 const crypto = yield* Crypto.Crypto;
11898 const digestStarted = yield* Deferred.make<void>();
11899 const releaseDigest = yield* Deferred.make<void>();
11900 const closeStarted = yield* Deferred.make<void>();
11901 let pauseDigest = false;
11902 yield* Effect.gen(function* () {
11903 const harness = yield* makeWorkflowHarnessWithOptions({
11904 freshQueueOnReopen: true,
11905 close: (messages) =>
11906 Deferred.succeed(closeStarted, undefined).pipe(
11907 Effect.andThen(Queue.shutdown(messages)),
11908 ),
11909 });
11910 const start = (attemptId: string, ordinal: number, continuation: boolean) =>
11911 harness.runtime.startTurn(
11912 makeClaudeTestTurnInput({
11913 threadId: harness.threadId,
11914 providerThread: harness.providerThread,
11915 now: DateTime.makeUnsafe(0),
11916 attemptId: RunAttemptId.make(attemptId),
11917 providerTurnOrdinal: ordinal,
11918 text: "Continue working.",
11919 attachments: [],
11920 ...(continuation
11921 ? { messageCreatedBy: "agent", messageCreationSource: "provider" }
11922 : {}),
11923 }),
11924 );
11925 yield* start("attempt-workflow-waiting-launch", 1, false);
11926 yield* launchWorkflow({
11927 harness,
11928 snapshotUuid: "00000000-0000-4000-8000-000000003200",
11929 });
11930 const fileSystem = yield* FileSystem.FileSystem;
11931 const liveTranscriptDir = NodePath.join(harness.transcriptDir, "live");
11932 yield* fileSystem.makeDirectory(liveTranscriptDir);
11933 yield* fileSystem.writeFileString(
11934 NodePath.join(liveTranscriptDir, "agent-a1.jsonl"),
11935 '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Live member answer"}]}}\n',
11936 );
11937 const liveTaskId = "task-workflow-live-while-waiting";
11938 const liveToolUseId = "toolu-workflow-live-while-waiting";
11939 yield* harness.offerAndWait(
11940 claudeSdkFrame({
11941 type: "assistant",
11942 message: {
11943 model: "claude-sonnet-4-6",
11944 content: [{ type: "tool_use", id: liveToolUseId, name: "Workflow", input: {} }],
11945 },
11946 parent_tool_use_id: null,
11947 session_id: WAKE_NATIVE_SESSION,
11948 uuid: "00000000-0000-4000-8000-000000003201",
11949 }),
11950 );
11951 yield* harness.offerAndWait(
11952 claudeSdkFrame({
11953 ...workflowTaskStarted,
11954 task_id: liveTaskId,
11955 tool_use_id: liveToolUseId,
11956 uuid: "00000000-0000-4000-8000-000000003202",
11957 }),
11958 );
11959 yield* harness.offerAndWait(
11960 claudeSdkFrame({
11961 type: "user",
11962 message: {
11963 role: "user",
11964 content: [
11965 { type: "tool_result", tool_use_id: liveToolUseId, content: "Launched." },
11966 ],
11967 },
11968 tool_use_result: {
11969 taskType: "local_workflow",
11970 runId: "wf_live_while_waiting",
11971 transcriptDir: liveTranscriptDir,
11972 },
11973 parent_tool_use_id: null,
11974 session_id: WAKE_NATIVE_SESSION,
11975 uuid: "00000000-0000-4000-8000-000000003203",
11976 }),
11977 );
11978 const liveProgress = (state: "start" | "done", uuid: string) =>
11979 claudeSdkFrame({
11980 ...workflowSnapshot({ state, uuid }),
11981 task_id: liveTaskId,
11982 tool_use_id: liveToolUseId,
11983 });
11984 yield* harness.offerAndWait(
11985 liveProgress("start", "00000000-0000-4000-8000-000000003204"),
11986 );
11987 yield* harness.offerAndWait(
11988 makeResultFrame({
11989 uuid: "00000000-0000-4000-8000-000000003205",
11990 result: "Launched both workflows.",
11991 }),
11992 );
11993 yield* Queue.take(harness.terminalReceipts);
11994 yield* harness.offerAndWait(
11995 workflowNotification({
11996 uuid: "00000000-0000-4000-8000-000000003206",
11997 status: "completed",
11998 }),
11999 );
12000 yield* Queue.take(harness.continuationReceipts);
12001 yield* harness.offerAndWait(wakeTurnInit);
12002 pauseDigest = true;
12003 const progress = yield* harness
12004 .offerAndWait(liveProgress("done", "00000000-0000-4000-8000-000000003207"))
12005 .pipe(Effect.forkScoped);
12006 yield* Deferred.await(digestStarted);
12007 const replay = yield* start("attempt-workflow-waiting-replay", 2, true).pipe(
12008 Effect.forkScoped({ startImmediately: true }),
12009 );
12010 if (arrival === "query close") {
12011 const stop = yield* harness.runtime
12012 .interruptTurn({
12013 providerThread: harness.providerThread,
12014 providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
12015 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
12016 nativeTurnId: "turn:attempt-workflow-waiting-replay",
12017 }),
12018 requestRuntimeRestart: true,
12019 })
12020 .pipe(Effect.forkScoped);
12021 yield* Deferred.await(closeStarted);
12022 yield* Deferred.succeed(releaseDigest, undefined);
12023 yield* Fiber.join(progress);
12024 yield* Fiber.join(stop);
12025 assert.isTrue(Exit.isFailure(yield* Fiber.await(replay)));
12026 } else {
12027 const offered = yield* Deferred.make<void>();
12028 const result = yield* harness
12029 .offerAndWait(wakeResult, offered)
12030 .pipe(Effect.forkScoped);
12031 yield* Deferred.await(offered);
12032 yield* Deferred.succeed(releaseDigest, undefined);
12033 yield* Fiber.join(progress);
12034 yield* Fiber.join(replay);
12035 yield* Fiber.join(result);
12036 yield* Queue.take(harness.terminalReceipts);
12037 assert.lengthOf(harness.continuationRequests, 1);
12038 }
12039 yield* start("attempt-workflow-waiting-next-user", 3, false);
12040 }).pipe(
12041 Effect.provideService(Crypto.Crypto, {
12042 ...crypto,
12043 digest: (algorithm, data) =>
12044 Effect.gen(function* () {
12045 if (pauseDigest) {
12046 pauseDigest = false;
12047 yield* Deferred.succeed(digestStarted, undefined);
12048 yield* Deferred.await(releaseDigest);
12049 }
12050 return yield* crypto.digest(algorithm, data);
12051 }),
12052 }),
12053 );
12054 }).pipe(
12055 Effect.provide(
12056 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
12057 ),
12058 ),
12059 ),
12060 );
12061
12062 it.effect.each(["registry", "transcript"] as const)(
12063 "does not replay stale idle progress over a completed workflow after awaiting %s",
12064 (boundary) =>
12065 Effect.scoped(
12066 Effect.gen(function* () {
12067 const clock = yield* Clock.Clock;
12068 const crypto = yield* Crypto.Crypto;
12069 const progressRead = yield* Deferred.make<void>();
12070 const releaseProgress = yield* Deferred.make<void>();
12071 let pauseNextRead = false;
12072 yield* Effect.gen(function* () {
12073 const harness = yield* makeWorkflowHarness;
12074 yield* harness.runtime.startTurn(
12075 makeClaudeTestTurnInput({
12076 threadId: harness.threadId,
12077 providerThread: harness.providerThread,
12078 now: yield* DateTime.now,
12079 attemptId: RunAttemptId.make("attempt-claude-idle-race-launch"),
12080 text: "Run the workflow.",
12081 attachments: [],
12082 }),
12083 );
12084 yield* launchWorkflow({
12085 harness,
12086 snapshotUuid: "00000000-0000-4000-8000-000000001060",
12087 });
12088 yield* harness.offerAndWait(
12089 makeResultFrame({
12090 uuid: "00000000-0000-4000-8000-000000001061",
12091 result: "Launched.",
12092 }),
12093 );
12094 yield* Queue.take(harness.terminalReceipts);
12095 yield* harness.offerAndWait(
12096 workflowNotification({
12097 uuid: "00000000-0000-4000-8000-000000001062",
12098 status: "completed",
12099 }),
12100 );
12101 if (boundary === "registry") {
12102 yield* harness.offerAndWait(
12103 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001063", result: "Done." }),
12104 );
12105 }
12106 const continuationInput = makeClaudeTestTurnInput({
12107 threadId: harness.threadId,
12108 providerThread: harness.providerThread,
12109 now: yield* DateTime.now,
12110 attemptId: RunAttemptId.make("attempt-claude-idle-race-continuation"),
12111 text: "Continue.",
12112 attachments: [],
12113 providerTurnOrdinal: 2,
12114 messageCreatedBy: "agent",
12115 messageCreationSource: "provider",
12116 });
12117 pauseNextRead = true;
12118 const progress = yield* harness
12119 .offerAndWait(
12120 workflowSnapshot({
12121 uuid: "00000000-0000-4000-8000-000000001064",
12122 state: boundary === "transcript" ? "done" : "start",
12123 secondState: "start",
12124 }),
12125 )
12126 .pipe(Effect.forkScoped);
12127 yield* Deferred.await(progressRead);
12128 const continuation = yield* harness.runtime
12129 .startTurn(continuationInput)
12130 .pipe(Effect.forkScoped);
12131 yield* Deferred.succeed(releaseProgress, undefined);
12132 yield* Fiber.join(progress);
12133 yield* Fiber.join(continuation);
12134 if (boundary === "registry") {
12135 yield* Queue.take(harness.terminalReceipts);
12136 } else {
12137 const lastMemberId = workflowMemberEvents(harness.events, 2)[0]?.subagent.id;
12138 while (true) {
12139 const event = yield* Queue.take(harness.subagentReceipts);
12140 if (event.subagent.id === lastMemberId && event.subagent.status === "completed")
12141 break;
12142 }
12143 }
12144 if (boundary === "transcript") {
12145 yield* harness.offerAndWait(
12146 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001065", result: "Done." }),
12147 );
12148 yield* Queue.take(harness.terminalReceipts);
12149 }
12150 assert.isFalse(yield* harness.hasPendingBackgroundWork);
12151 assert.equal(
12152 workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status,
12153 "completed",
12154 );
12155 const member = workflowMemberEvents(harness.events, 2).at(-1)?.subagent;
12156 assert.equal(member?.status, "completed");
12157 const root = harness.events.findLast(
12158 (event) =>
12159 event.type === "node.updated" && event.node.threadId === member?.childThreadId,
12160 );
12161 assert.equal(root?.type === "node.updated" ? root.node.status : null, "completed");
12162 const item = harness.events.findLast(
12163 (event) =>
12164 event.type === "turn_item.updated" &&
12165 event.turnItem.type === "subagent" &&
12166 event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID,
12167 );
12168 assert.equal(
12169 item?.type === "turn_item.updated" ? item.turnItem.status : null,
12170 "completed",
12171 );
12172 }).pipe(
12173 Effect.provideService(Clock.Clock, {
12174 ...clock,
12175 currentTimeMillis: Effect.gen(function* () {
12176 if (boundary === "registry" && pauseNextRead) {
12177 pauseNextRead = false;
12178 yield* Deferred.succeed(progressRead, undefined);
12179 yield* Deferred.await(releaseProgress);
12180 }
12181 return yield* clock.currentTimeMillis;
12182 }),
12183 }),
12184 Effect.provideService(Crypto.Crypto, {
12185 ...crypto,
12186 digest: (algorithm, data) =>
12187 Effect.gen(function* () {
12188 if (boundary === "transcript" && pauseNextRead) {
12189 pauseNextRead = false;
12190 yield* Deferred.succeed(progressRead, undefined);
12191 yield* Deferred.await(releaseProgress);
12192 }
12193 return yield* crypto.digest(algorithm, data);
12194 }),
12195 }),
12196 );
12197 }).pipe(
12198 Effect.provide(
12199 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
12200 ),
12201 ),
12202 ),
12203 );
12204
12205 it.effect.each([
12206 "root result",
12207 "resume after root result",
12208 "completion before buffered start",
12209 ] as const)("orders %s arriving during workflow transcript replay", (arrival) =>
12210 Effect.scoped(
12211 Effect.gen(function* () {
12212 const crypto = yield* Crypto.Crypto;
12213 const digestStarted = yield* Deferred.make<void>();
12214 const releaseDigest = yield* Deferred.make<void>();
12215 const liveFrameOffered = yield* Deferred.make<void>();
12216 let pauseDigest = false;
12217 yield* Effect.gen(function* () {
12218 const harness = yield* makeWorkflowHarnessWithOptions({ close: Queue.shutdown });
12219 const taskId = "task-resumed-during-workflow-replay";
12220 const taskStart = (toolUseId: string, prompt: string, uuid: string) =>
12221 claudeSdkFrame({
12222 ...workflowTaskStarted,
12223 task_id: taskId,
12224 task_type: "local_agent",
12225 is_backgrounded: true,
12226 subagent_type: "general-purpose",
12227 spawn_depth: 1,
12228 workflow_name: undefined,
12229 tool_use_id: toolUseId,
12230 prompt,
12231 uuid,
12232 });
12233 const taskFinished = (toolUseId: string, summary: string, uuid: string) =>
12234 claudeSdkFrame({
12235 ...workflowNotification({ uuid, status: "completed" }),
12236 task_id: taskId,
12237 tool_use_id: toolUseId,
12238 summary,
12239 });
12240 yield* harness.runtime.startTurn(
12241 makeClaudeTestTurnInput({
12242 threadId: harness.threadId,
12243 providerThread: harness.providerThread,
12244 now: yield* DateTime.now,
12245 attemptId: RunAttemptId.make("attempt-workflow-replay-launch"),
12246 text: "Run the workflow and a background subagent.",
12247 attachments: [],
12248 }),
12249 );
12250 yield* harness.offerAndWait(workflowToolUse);
12251 yield* harness.offerAndWait(workflowTaskStarted);
12252 yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir));
12253 yield* harness.offerAndWait(
12254 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000003001", state: "start" }),
12255 );
12256 if (arrival !== "completion before buffered start") {
12257 yield* harness.offerAndWait(
12258 taskStart("toolu-initial", "Initial work.", "00000000-0000-4000-8000-000000003002"),
12259 );
12260 }
12261 yield* harness.offerAndWait(
12262 makeResultFrame({
12263 uuid: "00000000-0000-4000-8000-000000003003",
12264 result: "Launched both tasks.",
12265 }),
12266 );
12267 yield* Queue.take(harness.terminalReceipts);
12268 yield* harness.offerAndWait(
12269 workflowNotification({
12270 uuid: "00000000-0000-4000-8000-000000003004",
12271 status: "completed",
12272 }),
12273 );
12274 yield* Queue.take(harness.continuationReceipts);
12275 if (arrival === "completion before buffered start") {
12276 yield* harness.offerAndWait(
12277 taskStart("toolu-initial", "Initial work.", "00000000-0000-4000-8000-000000003005"),
12278 );
12279 } else {
12280 yield* harness.offerAndWait(
12281 taskFinished(
12282 "toolu-initial",
12283 "Initial result.",
12284 "00000000-0000-4000-8000-000000003005",
12285 ),
12286 );
12287 }
12288 if (arrival === "root result") {
12289 yield* harness.offerAndWait(
12290 claudeSdkFrame({
12291 type: "system",
12292 subtype: "init",
12293 session_id: WAKE_NATIVE_SESSION,
12294 uuid: "00000000-0000-4000-8000-000000003006",
12295 }),
12296 );
12297 }
12298 pauseDigest = true;
12299 const replay = yield* harness.runtime
12300 .startTurn(
12301 makeClaudeTestTurnInput({
12302 threadId: harness.threadId,
12303 providerThread: harness.providerThread,
12304 now: yield* DateTime.now,
12305 attemptId: RunAttemptId.make("attempt-workflow-replay-continuation"),
12306 providerTurnOrdinal: 2,
12307 text: "Continue.",
12308 attachments: [],
12309 messageCreatedBy: "agent",
12310 messageCreationSource: "provider",
12311 }),
12312 )
12313 .pipe(Effect.forkScoped);
12314 yield* Deferred.await(digestStarted);
12315 const liveFrames = yield* Effect.gen(function* () {
12316 if (arrival === "completion before buffered start") {
12317 yield* harness.offerAndWait(
12318 taskFinished(
12319 "toolu-initial",
12320 "Initial result.",
12321 "00000000-0000-4000-8000-000000003007",
12322 ),
12323 liveFrameOffered,
12324 );
12325 return;
12326 }
12327 yield* harness.offerAndWait(
12328 claudeSdkFrame({
12329 ...makeResultFrame({
12330 uuid: "00000000-0000-4000-8000-000000003008",
12331 result: "Native wake turn finished.",
12332 }),
12333 origin: { kind: "task-notification" },
12334 num_turns: 1,
12335 }),
12336 liveFrameOffered,
12337 );
12338 if (arrival === "resume after root result") {
12339 yield* harness.offerAndWait(
12340 claudeSdkFrame({
12341 type: "system",
12342 subtype: "init",
12343 session_id: WAKE_NATIVE_SESSION,
12344 uuid: "00000000-0000-4000-8000-000000003009",
12345 }),
12346 );
12347 yield* harness.offerAndWait(
12348 taskStart(
12349 "toolu-latest",
12350 "Latest live resume.",
12351 "00000000-0000-4000-8000-000000003010",
12352 ),
12353 );
12354 }
12355 }).pipe(Effect.forkScoped);
12356 yield* Deferred.await(liveFrameOffered);
12357 yield* Deferred.succeed(releaseDigest, undefined);
12358 yield* Fiber.join(replay);
12359 yield* Fiber.join(liveFrames);
12360 if (arrival === "resume after root result") {
12361 yield* Queue.take(harness.terminalReceipts);
12362 yield* Queue.take(harness.continuationReceipts);
12363 yield* harness.runtime.startTurn(
12364 makeClaudeTestTurnInput({
12365 threadId: harness.threadId,
12366 providerThread: harness.providerThread,
12367 now: yield* DateTime.now,
12368 attemptId: RunAttemptId.make("attempt-workflow-replay-resume-continuation"),
12369 providerTurnOrdinal: 3,
12370 text: "Continue.",
12371 attachments: [],
12372 messageCreatedBy: "agent",
12373 messageCreationSource: "provider",
12374 }),
12375 );
12376 }
12377 if (arrival !== "root result") {
12378 yield* harness.offerAndWait(
12379 makeResultFrame({
12380 uuid: "00000000-0000-4000-8000-000000003011",
12381 result: "Workflow answer delivered.",
12382 }),
12383 );
12384 }
12385 yield* Queue.take(harness.terminalReceipts);
12386 const latest = harness.events
12387 .flatMap((event) =>
12388 event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === taskId
12389 ? [event.subagent]
12390 : [],
12391 )
12392 .at(-1);
12393 if (arrival === "resume after root result") {
12394 assert.equal(latest?.status, "running");
12395 assert.equal(latest?.prompt, "Latest live resume.");
12396 assert.isNull(latest?.result);
12397 assert.isTrue(yield* harness.hasPendingBackgroundWork);
12398 } else {
12399 assert.equal(latest?.status, "completed");
12400 assert.equal(latest?.result, "Initial result.");
12401 assert.isFalse(yield* harness.hasPendingBackgroundWork);
12402 assert.lengthOf(harness.continuationRequests, 1);
12403 // The native wake turn ended once; no extra provider result will
12404 // arrive to settle a mistakenly opened continuation.
12405 yield* harness.runtime.startTurn(
12406 makeClaudeTestTurnInput({
12407 threadId: harness.threadId,
12408 providerThread: harness.providerThread,
12409 now: yield* DateTime.now,
12410 attemptId: RunAttemptId.make("attempt-workflow-replay-next-user"),
12411 providerTurnOrdinal: 3,
12412 text: "Next task.",
12413 attachments: [],
12414 }),
12415 );
12416 }
12417 }).pipe(
12418 Effect.provideService(Crypto.Crypto, {
12419 ...crypto,
12420 digest: (algorithm, data) =>
12421 Effect.gen(function* () {
12422 if (pauseDigest) {
12423 pauseDigest = false;
12424 yield* Deferred.succeed(digestStarted, undefined);
12425 yield* Deferred.await(releaseDigest);
12426 }
12427 return yield* crypto.digest(algorithm, data);
12428 }),
12429 }),
12430 );
12431 }).pipe(
12432 Effect.provide(
12433 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
12434 ),
12435 ),
12436 ),
12437 );
12438
12439 it.effect("preserves buffered workflow outcomes when interrupted during replay", () =>
12440 Effect.scoped(
12441 Effect.gen(function* () {
12442 const crypto = yield* Crypto.Crypto;
12443 const digestStarted = yield* Deferred.make<void>();
12444 const releaseDigest = yield* Deferred.make<void>();
12445 let pauseDigest = false;
12446 yield* Effect.gen(function* () {
12447 const harness = yield* makeWorkflowHarnessWithOptions({ close: Queue.shutdown });
12448 const secondTaskId = "task-workflow-buffered-completion";
12449 const secondToolUseId = "toolu-workflow-buffered-completion";
12450 yield* harness.runtime.startTurn(
12451 makeClaudeTestTurnInput({
12452 threadId: harness.threadId,
12453 providerThread: harness.providerThread,
12454 now: yield* DateTime.now,
12455 attemptId: RunAttemptId.make("attempt-workflow-buffered-launch"),
12456 text: "Run both workflows.",
12457 attachments: [],
12458 }),
12459 );
12460 yield* launchWorkflow({
12461 harness,
12462 snapshotUuid: "00000000-0000-4000-8000-000000003100",
12463 });
12464 yield* harness.offerAndWait(
12465 claudeSdkFrame({
12466 ...workflowTaskStarted,
12467 task_id: secondTaskId,
12468 tool_use_id: secondToolUseId,
12469 uuid: "00000000-0000-4000-8000-000000003101",
12470 }),
12471 );
12472 yield* harness.offerAndWait(
12473 claudeSdkFrame({
12474 ...workflowSnapshot({
12475 uuid: "00000000-0000-4000-8000-000000003102",
12476 state: "start",
12477 }),
12478 task_id: secondTaskId,
12479 tool_use_id: secondToolUseId,
12480 }),
12481 );
12482 yield* harness.offerAndWait(
12483 makeResultFrame({
12484 uuid: "00000000-0000-4000-8000-000000003103",
12485 result: "Launched both workflows.",
12486 }),
12487 );
12488 yield* Queue.take(harness.terminalReceipts);
12489 yield* harness.offerAndWait(
12490 workflowNotification({
12491 uuid: "00000000-0000-4000-8000-000000003104",
12492 status: "completed",
12493 }),
12494 );
12495 yield* Queue.take(harness.continuationReceipts);
12496 yield* harness.offerAndWait(
12497 claudeSdkFrame({
12498 ...workflowNotification({
12499 uuid: "00000000-0000-4000-8000-000000003105",
12500 status: "completed",
12501 }),
12502 task_id: secondTaskId,
12503 tool_use_id: secondToolUseId,
12504 }),
12505 );
12506 const continuationInput = makeClaudeTestTurnInput({
12507 threadId: harness.threadId,
12508 providerThread: harness.providerThread,
12509 now: yield* DateTime.now,
12510 attemptId: RunAttemptId.make("attempt-workflow-buffered-replay"),
12511 providerTurnOrdinal: 2,
12512 text: "Continue.",
12513 attachments: [],
12514 messageCreatedBy: "agent",
12515 messageCreationSource: "provider",
12516 });
12517 pauseDigest = true;
12518 const replay = yield* harness.runtime
12519 .startTurn(continuationInput)
12520 .pipe(Effect.forkScoped);
12521 yield* Deferred.await(digestStarted);
12522 yield* harness.runtime.interruptTurn({
12523 providerThread: harness.providerThread,
12524 providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
12525 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
12526 nativeTurnId: `turn:${continuationInput.attemptId}`,
12527 }),
12528 });
12529 yield* Deferred.succeed(releaseDigest, undefined);
12530 yield* Fiber.join(replay);
12531 const second = harness.events
12532 .flatMap((event) =>
12533 event.type === "subagent.updated" &&
12534 event.subagent.nativeTaskRef?.nativeId === secondTaskId
12535 ? [event.subagent]
12536 : [],
12537 )
12538 .at(-1);
12539 assert.equal(second?.status, "completed");
12540 assert.equal(second?.result, "Workflow finished.");
12541 }).pipe(
12542 Effect.provideService(Crypto.Crypto, {
12543 ...crypto,
12544 digest: (algorithm, data) =>
12545 Effect.gen(function* () {
12546 if (pauseDigest) {
12547 pauseDigest = false;
12548 yield* Deferred.succeed(digestStarted, undefined);
12549 yield* Deferred.await(releaseDigest);
12550 }
12551 return yield* crypto.digest(algorithm, data);
12552 }),
12553 }),
12554 );
12555 }).pipe(
12556 Effect.provide(
12557 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
12558 ),
12559 ),
12560 ),
12561 );
12562
12563 it.effect.each([
12564 ["api_error", false],
12565 ["aborted_streaming", false],
12566 ["api_error", true],
12567 ] as const)(
12568 "stops the run's live workflow after root result %s, continuation=%s",
12569 ([terminalReason, continuation]) =>
12570 Effect.scoped(
12571 Effect.gen(function* () {
12572 const stoppedTasks: Array<string> = [];
12573 const harness = yield* makeWorkflowHarnessWithOptions({
12574 stopTask: (taskId) => Effect.sync(() => void stoppedTasks.push(taskId)),
12575 });
12576 const now = yield* DateTime.now;
12577 const turnInput = {
12578 threadId: harness.threadId,
12579 providerThread: harness.providerThread,
12580 now,
12581 attemptId: RunAttemptId.make("attempt-claude-workflow-root-failure"),
12582 text: "Run the workflow.",
12583 attachments: [],
12584 };
12585 yield* harness.runtime.startTurn(makeClaudeTestTurnInput(turnInput));
12586 yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001050" });
12587 if (continuation) {
12588 yield* harness.offerAndWait(
12589 makeResultFrame({
12590 uuid: "00000000-0000-4000-8000-000000001051",
12591 result: "Launched.",
12592 }),
12593 );
12594 yield* Queue.take(harness.terminalReceipts);
12595 yield* harness.offerAndWait(
12596 claudeSdkFrame({
12597 ...workflowTaskStarted,
12598 uuid: "00000000-0000-4000-8000-000000001052",
12599 }),
12600 );
12601 }
12602 yield* harness.offerAndWait(
12603 makeResultFrame({
12604 uuid: "00000000-0000-4000-8000-000000001053",
12605 result: "Root turn ended.",
12606 terminalReason,
12607 isError: terminalReason === "api_error",
12608 }),
12609 );
12610 if (continuation) {
12611 yield* harness.runtime.startTurn(
12612 makeClaudeTestTurnInput({
12613 ...turnInput,
12614 attemptId: RunAttemptId.make("attempt-claude-workflow-root-failure-wake"),
12615 providerTurnOrdinal: 2,
12616 messageCreatedBy: "agent",
12617 messageCreationSource: "provider",
12618 }),
12619 );
12620 }
12621 const terminal = yield* Queue.take(harness.terminalReceipts);
12622 assert.equal(terminal.status, terminalReason === "api_error" ? "failed" : "interrupted");
12623 assert.equal(terminal.threadDisposition, "reusable");
12624 // The run following the workflow (its launcher, or the run a resumed
12625 // workflow was re-attributed to) stops following it, so it is stopped
12626 // instead of left running out of sight.
12627 const settled = terminalReason === "api_error" ? "failed" : "cancelled";
12628 assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]);
12629 assert.isFalse(yield* harness.hasPendingBackgroundWork);
12630 assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, settled);
12631 for (const index of [1, 2]) {
12632 assert.equal(
12633 workflowMemberEvents(harness.events, index).at(-1)?.subagent.status,
12634 settled,
12635 );
12636 }
12637 }).pipe(
12638 Effect.provide(
12639 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
12640 ),
12641 ),
12642 ),
12643 );
12644
12645 it.effect.each([
12646 ["stopped", false, false, false],
12647 ["failed", false, false, false],
12648 ["completed", false, false, false],
12649 ["stopped", true, false, false],
12650 ["failed", true, false, false],
12651 ["completed", true, false, false],
12652 ["interrupted", false, false, false],
12653 ["interrupted", true, false, false],
12654 ["interrupt_timeout", true, false, false],
12655 ["stop_buffered", true, false, false],
12656 ["stop_buffered", true, false, "buffered"],
12657 ["stop_buffered", true, false, "live"],
12658 ["drain_buffered", true, false, "live"],
12659 ["drain_buffered", true, false, "live_after_buffered"],
12660 ["query_failed", false, false, false],
12661 ["query_failed", true, false, false],
12662 ["query_failed", true, false, "buffered"],
12663 ["query_failed", true, true, false],
12664 ] as const)(
12665 "projects workflow members on %s, between turns=%s, after an unrelated failed turn=%s, resume=%s",
12666 ([status, betweenTurns, afterFailedTurn, resumed]) =>
12667 Effect.scoped(
12668 Effect.gen(function* () {
12669 const interrupted = status === "interrupted" || status === "interrupt_timeout";
12670 const liveResume = resumed === "live" || resumed === "live_after_buffered";
12671 const expected =
12672 status === "drain_buffered"
12673 ? resumed === "live_after_buffered"
12674 ? "completed"
12675 : "running"
12676 : status === "stopped" || interrupted || (status === "stop_buffered" && resumed)
12677 ? "cancelled"
12678 : status === "query_failed"
12679 ? "failed"
12680 : status === "stop_buffered"
12681 ? "completed"
12682 : status;
12683 const harness = yield* makeWorkflowHarnessWithOptions({
12684 close: status === "interrupt_timeout" ? () => Effect.void : Queue.shutdown,
12685 });
12686 const now = yield* DateTime.now;
12687 yield* harness.runtime.startTurn(
12688 makeClaudeTestTurnInput({
12689 threadId: harness.threadId,
12690 providerThread: harness.providerThread,
12691 now,
12692 attemptId: RunAttemptId.make("attempt-workflow-terminal"),
12693 text: "Run the workflow.",
12694 attachments: [],
12695 }),
12696 );
12697 yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001010" });
12698 if (betweenTurns) {
12699 yield* Queue.offer(
12700 harness.sdkMessages,
12701 makeResultFrame({
12702 uuid: "00000000-0000-4000-8000-000000001013",
12703 result: "Launched.",
12704 }),
12705 );
12706 yield* Queue.take(harness.terminalReceipts);
12707 if (afterFailedTurn || status === "interrupt_timeout") {
12708 yield* harness.runtime.startTurn(
12709 makeClaudeTestTurnInput({
12710 threadId: harness.threadId,
12711 providerThread: harness.providerThread,
12712 now,
12713 attemptId: RunAttemptId.make("attempt-unrelated-failed"),
12714 providerTurnOrdinal: 2,
12715 text: "Unrelated follow-up.",
12716 attachments: [],
12717 }),
12718 );
12719 if (afterFailedTurn) {
12720 yield* Queue.offer(
12721 harness.sdkMessages,
12722 makeResultFrame({
12723 uuid: "00000000-0000-4000-8000-000000001015",
12724 result: "Unrelated follow-up failed.",
12725 terminalReason: "api_error",
12726 isError: true,
12727 }),
12728 );
12729 yield* Queue.take(harness.terminalReceipts);
12730 }
12731 }
12732 if (status === "query_failed") {
12733 yield* harness.offerAndWait(
12734 workflowSnapshot({
12735 uuid: "00000000-0000-4000-8000-000000001014",
12736 state: "start",
12737 label: "Updated between turns",
12738 }),
12739 );
12740 }
12741 }
12742 const bufferedCompletion =
12743 status === "stop_buffered" || status === "drain_buffered" || resumed;
12744 if ((!interrupted && status !== "query_failed") || resumed)
12745 yield* Queue.offer(
12746 harness.sdkMessages,
12747 workflowNotification({
12748 uuid: "00000000-0000-4000-8000-000000001011",
12749 status: bufferedCompletion ? "completed" : status,
12750 }),
12751 );
12752 if (betweenTurns && ((status !== "query_failed" && !interrupted) || resumed)) {
12753 yield* Queue.take(harness.continuationReceipts);
12754 }
12755 if (resumed) {
12756 if (resumed === "live_after_buffered") {
12757 yield* harness.offerAndWait(
12758 claudeSdkFrame({
12759 ...workflowTaskStarted,
12760 tool_use_id: "toolu-workflow-earlier-resume",
12761 prompt: "Earlier buffered resume.",
12762 uuid: "00000000-0000-4000-8000-000000001019",
12763 }),
12764 );
12765 yield* harness.offerAndWait(
12766 claudeSdkFrame({
12767 ...workflowNotification({
12768 uuid: "00000000-0000-4000-8000-000000001020",
12769 status: "completed",
12770 }),
12771 tool_use_id: "toolu-workflow-earlier-resume",
12772 }),
12773 );
12774 }
12775 if (liveResume) {
12776 yield* harness.runtime.startTurn(
12777 makeClaudeTestTurnInput({
12778 threadId: harness.threadId,
12779 providerThread: harness.providerThread,
12780 now,
12781 attemptId: RunAttemptId.make("attempt-workflow-live-resume"),
12782 providerTurnOrdinal: 2,
12783 text: "Continue the workflow.",
12784 attachments: [],
12785 }),
12786 );
12787 }
12788 yield* harness.offerAndWait(
12789 claudeSdkFrame({
12790 ...workflowTaskStarted,
12791 tool_use_id: "toolu-workflow-resume",
12792 prompt: "Continue the workflow.",
12793 uuid: "00000000-0000-4000-8000-000000001016",
12794 }),
12795 );
12796 yield* harness.offerAndWait(
12797 workflowSnapshot({
12798 uuid: "00000000-0000-4000-8000-000000001017",
12799 state: "start",
12800 attempt: 2,
12801 label: "Updated between turns",
12802 }),
12803 );
12804 if (liveResume) {
12805 if (resumed === "live_after_buffered") {
12806 yield* harness.offerAndWait(
12807 claudeSdkFrame({
12808 ...workflowNotification({
12809 uuid: "00000000-0000-4000-8000-000000001021",
12810 status: "completed",
12811 }),
12812 tool_use_id: "toolu-workflow-resume",
12813 summary: "Latest workflow result.",
12814 }),
12815 );
12816 }
12817 yield* harness.offerAndWait(
12818 makeResultFrame({
12819 uuid: "00000000-0000-4000-8000-000000001018",
12820 result: "Resumed the workflow.",
12821 }),
12822 );
12823 yield* Queue.take(harness.terminalReceipts);
12824 }
12825 }
12826 if (betweenTurns && status !== "query_failed" && !interrupted && !bufferedCompletion) {
12827 yield* harness.runtime.startTurn(
12828 makeClaudeTestTurnInput({
12829 threadId: harness.threadId,
12830 providerThread: harness.providerThread,
12831 now,
12832 attemptId: RunAttemptId.make("attempt-workflow-terminal-wake"),
12833 text: "Workflow finished.",
12834 attachments: [],
12835 providerTurnOrdinal: 2,
12836 messageCreatedBy: "agent",
12837 messageCreationSource: "provider",
12838 }),
12839 );
12840 }
12841 if (interrupted || status === "stop_buffered") {
12842 const interrupt = harness.runtime.interruptTurn({
12843 providerThread: harness.providerThread,
12844 providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
12845 driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
12846 nativeTurnId:
12847 status === "interrupt_timeout"
12848 ? "turn:attempt-unrelated-failed"
12849 : liveResume
12850 ? "turn:attempt-workflow-live-resume"
12851 : "turn:attempt-workflow-terminal",
12852 }),
12853 ...(betweenTurns ? { requestRuntimeRestart: true } : {}),
12854 });
12855 if (status === "interrupt_timeout") {
12856 const stop = yield* interrupt.pipe(Effect.forkScoped);
12857 yield* TestClock.adjust("10 seconds");
12858 yield* Fiber.join(stop);
12859 assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted");
12860 assert.isFalse(yield* harness.hasPendingBackgroundWork);
12861 } else {
12862 yield* interrupt;
12863 }
12864 } else if (status === "query_failed") {
12865 yield* Queue.shutdown(harness.sdkMessages);
12866 } else if (status === "drain_buffered") {
12867 yield* harness.runtime.startTurn(
12868 makeClaudeTestTurnInput({
12869 threadId: harness.threadId,
12870 providerThread: harness.providerThread,
12871 now,
12872 attemptId: RunAttemptId.make("attempt-workflow-terminal-wake"),
12873 text: "Workflow finished.",
12874 attachments: [],
12875 providerTurnOrdinal: 3,
12876 messageCreatedBy: "agent",
12877 messageCreationSource: "provider",
12878 }),
12879 );
12880 assert.equal(
12881 workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status,
12882 expected,
12883 );
12884 } else {
12885 yield* Queue.offer(
12886 harness.sdkMessages,
12887 makeResultFrame({
12888 uuid: "00000000-0000-4000-8000-000000001012",
12889 result: "Finished.",
12890 }),
12891 );
12892 }
12893 if (
12894 betweenTurns &&
12895 (status === "query_failed" || interrupted || status === "stop_buffered")
12896 ) {
12897 // The launching turn already ended. Await the last member's
12898 // terminal event, which follows its root node update.
12899 const lastMemberId = workflowMemberEvents(harness.events, 2)[0]?.subagent.id;
12900 while (true) {
12901 const update = yield* Queue.take(harness.subagentReceipts);
12902 if (update.subagent.id === lastMemberId && update.subagent.status !== "running")
12903 break;
12904 }
12905 assert.isFalse(yield* harness.hasPendingBackgroundWork);
12906 if (status === "query_failed") {
12907 assert.equal(
12908 workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title,
12909 "Updated between turns",
12910 );
12911 }
12912 } else {
12913 yield* Queue.take(harness.terminalReceipts);
12914 }
12915 assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, expected);
12916 if (status === "drain_buffered")
12917 assert.equal(yield* harness.hasPendingBackgroundWork, expected === "running");
12918 if (resumed === "live_after_buffered") {
12919 assert.equal(
12920 workflowCoordinatorEvents(harness.events).at(-1)?.subagent.result,
12921 "Latest workflow result.",
12922 );
12923 }
12924 if (resumed) {
12925 assert.notEqual(
12926 workflowCoordinatorEvents(harness.events).at(-1)?.subagent.result,
12927 "Workflow finished.",
12928 );
12929 }
12930 for (const index of [1, 2]) {
12931 const updates = workflowMemberEvents(harness.events, index);
12932 const member = updates.at(-1)?.subagent;
12933 assert.equal(member?.status, expected);
12934 assert.deepEqual(member?.startedAt, updates[0]?.subagent.startedAt);
12935 const root = harness.events.findLast(
12936 (event) =>
12937 event.type === "node.updated" && event.node.threadId === member?.childThreadId,
12938 );
12939 assert.equal(root?.type === "node.updated" ? root.node.status : null, expected);
12940 }
12941 }).pipe(
12942 Effect.provide(
12943 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
12944 ),
12945 ),
12946 ),
12947 );
12948
12949 it.effect("reads a transcript that appears after a terminal workflow notification", () =>
12950 Effect.scoped(
12951 Effect.gen(function* () {
12952 const harness = yield* makeWorkflowHarness;
12953 const now = yield* DateTime.now;
12954 yield* harness.runtime.startTurn(
12955 makeClaudeTestTurnInput({
12956 threadId: harness.threadId,
12957 providerThread: harness.providerThread,
12958 now,
12959 attemptId: RunAttemptId.make("attempt-claude-workflow-late-terminal-answer"),
12960 text: "Run the alpha workflow.",
12961 attachments: [],
12962 }),
12963 );
12964 yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001030" });
12965 const secondThreadId = workflowMemberEvents(harness.events, 2)[0]?.subagent.childThreadId;
12966 yield* Queue.offer(
12967 harness.sdkMessages,
12968 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001031", state: "done" }),
12969 );
12970 yield* awaitUntil(
12971 () => threadMessages(harness.events, secondThreadId).length === 2,
12972 "workflow member excerpt",
12973 );
12974 const notification = workflowNotification({
12975 uuid: "00000000-0000-4000-8000-000000001032",
12976 status: "completed",
12977 });
12978 yield* Queue.offer(harness.sdkMessages, notification);
12979 yield* awaitUntil(
12980 () =>
12981 harness.events.some(
12982 (event) =>
12983 event.type === "turn_item.updated" &&
12984 event.turnItem.type === "subagent" &&
12985 event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID &&
12986 event.turnItem.status === "completed",
12987 ),
12988 "terminal workflow notification",
12989 );
12990 NodeFS.writeFileSync(
12991 NodePath.join(harness.transcriptDir, "agent-a2.jsonl"),
12992 '{"type":"assistant","message":{"role":"assistant","id":"msg_late","content":[{"type":"text","text":"A2 final answer"}]}}\n',
12993 );
12994 yield* Queue.offer(
12995 harness.sdkMessages,
12996 claudeSdkFrame({ ...notification, uuid: "00000000-0000-4000-8000-000000001033" }),
12997 );
12998 yield* awaitUntil(
12999 () =>
13000 threadMessages(harness.events, secondThreadId).at(-1)?.message.text ===
13001 "A2 final answer",
13002 "late terminal transcript answer",
13003 );
13004 yield* Queue.offer(
13005 harness.sdkMessages,
13006 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001034", result: "Done." }),
13007 );
13008 yield* Queue.take(harness.terminalReceipts);
13009 }).pipe(
13010 Effect.provide(
13011 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
13012 ),
13013 ),
13014 ),
13015 );
13016
13017 it.effect("answers settled workflow members from a transcript, or the snapshot excerpt", () =>
13018 Effect.scoped(
13019 Effect.gen(function* () {
13020 const harness = yield* makeWorkflowHarness;
13021 const now = yield* DateTime.now;
13022
13023 yield* harness.runtime.startTurn(
13024 makeClaudeTestTurnInput({
13025 threadId: harness.threadId,
13026 providerThread: harness.providerThread,
13027 now,
13028 attemptId: RunAttemptId.make("attempt-claude-workflow-settled"),
13029 text: "Run the alpha workflow in the background.",
13030 attachments: [],
13031 }),
13032 );
13033 yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001007" });
13034 const firstThreadId = workflowMemberEvents(harness.events, 1)[0]?.subagent.childThreadId;
13035 const secondThreadId = workflowMemberEvents(harness.events, 2)[0]?.subagent.childThreadId;
13036 const currentAssistantAnswers = () => [
13037 ...new Map(
13038 threadMessages(harness.events, firstThreadId)
13039 .filter((event) => event.message.role === "assistant")
13040 .map((event) => [event.message.id, event.message.text]),
13041 ).values(),
13042 ];
13043 yield* Queue.offer(
13044 harness.sdkMessages,
13045 makeResultFrame({
13046 uuid: "00000000-0000-4000-8000-000000001008",
13047 result: "Launched the workflow.",
13048 }),
13049 );
13050 yield* Queue.take(harness.terminalReceipts);
13051 yield* harness.runtime.startTurn(
13052 makeClaudeTestTurnInput({
13053 threadId: harness.threadId,
13054 providerThread: harness.providerThread,
13055 now,
13056 attemptId: RunAttemptId.make("attempt-unrelated-failure"),
13057 text: "Do another task.",
13058 attachments: [],
13059 providerTurnOrdinal: 2,
13060 }),
13061 );
13062 yield* Queue.offer(
13063 harness.sdkMessages,
13064 makeResultFrame({
13065 uuid: "00000000-0000-4000-8000-000000001014",
13066 result: "Another task failed.",
13067 terminalReason: "api_error",
13068 isError: true,
13069 }),
13070 );
13071 yield* Queue.take(harness.terminalReceipts);
13072 assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, "running");
13073 assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running");
13074
13075 // The run outlives the turn that launched it, so this snapshot arrives
13076 // with no turn context at all.
13077 yield* Queue.offer(
13078 harness.sdkMessages,
13079 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001009", state: "done" }),
13080 );
13081 yield* awaitUntil(
13082 () => threadMessages(harness.events, secondThreadId).length === 2,
13083 "settled workflow member answers",
13084 );
13085
13086 const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent;
13087 assert.deepEqual(
13088 coordinator?.workflow?.agents.map((member) => member.state),
13089 ["completed", "completed"],
13090 );
13091 assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "completed");
13092 assert.equal(workflowMemberEvents(harness.events, 2).at(-1)?.subagent.status, "completed");
13093 assert.deepEqual(
13094 threadMessages(harness.events, firstThreadId).map((event) => event.message.text),
13095 ["Reply with exactly: A1", "A1 from the transcript"],
13096 );
13097 assert.deepEqual(
13098 threadMessages(harness.events, secondThreadId).map((event) => event.message.text),
13099 ["Reply with exactly: A2", "Partial answer from workflow progress:\n\nA2 excerpt"],
13100 );
13101 const coordinatorEventsBeforeRepeat = workflowCoordinatorEvents(harness.events).length;
13102 const memberEventsBeforeRepeat = workflowMemberEvents(harness.events, 2).length;
13103 NodeFS.appendFileSync(
13104 NodePath.join(harness.transcriptDir, "agent-a1.jsonl"),
13105 '{"type":"assistant","message":{"role":"assistant","id":"msg_a1","content":[{"type":"text","text":"A1 continued"}]}}\n',
13106 );
13107 yield* Queue.offer(
13108 harness.sdkMessages,
13109 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001015", state: "done" }),
13110 );
13111 yield* awaitUntil(
13112 () => workflowCoordinatorEvents(harness.events).length > coordinatorEventsBeforeRepeat,
13113 "repeated settled workflow snapshot",
13114 );
13115 yield* awaitUntil(
13116 () =>
13117 threadMessages(harness.events, firstThreadId).at(-1)?.message.text ===
13118 "A1 from the transcript\n\nA1 continued",
13119 "continued workflow transcript answer",
13120 );
13121 assert.lengthOf(workflowMemberEvents(harness.events, 2), memberEventsBeforeRepeat);
13122 assert.lengthOf(threadMessages(harness.events, secondThreadId), 2);
13123 assert.equal(
13124 threadMessages(harness.events, firstThreadId).at(-1)?.message.text,
13125 "A1 from the transcript\n\nA1 continued",
13126 );
13127 NodeFS.writeFileSync(
13128 NodePath.join(harness.transcriptDir, "agent-a2.jsonl"),
13129 '{"type":"assistant","message":{"role":"assistant","id":"msg_a2","content":[{"type":"text","text":"A2 from the transcript"}]}}\n',
13130 );
13131 yield* Queue.offer(
13132 harness.sdkMessages,
13133 workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001016", state: "done" }),
13134 );
13135 yield* awaitUntil(
13136 () => threadMessages(harness.events, secondThreadId).length === 3,
13137 "late workflow transcript answer",
13138 );
13139 assert.lengthOf(workflowMemberEvents(harness.events, 2), memberEventsBeforeRepeat);
13140 assert.equal(
13141 threadMessages(harness.events, secondThreadId).at(-1)?.message.text,
13142 "A2 from the transcript",
13143 );
13144 const previousStartedAt = workflowMemberEvents(harness.events, 1).at(-1)?.subagent
13145 .startedAt;
13146 yield* TestClock.adjust(1_000);
13147 NodeFS.writeFileSync(
13148 NodePath.join(harness.transcriptDir, "agent-a1.jsonl"),
13149 [
13150 '{"type":"assistant","message":{"role":"assistant","id":"msg_first","content":[{"type":"text","text":"First answer"}]}}',
13151 '{"type":"assistant","message":{"role":"assistant","id":"msg_second","content":[{"type":"text","text":"Second answer"}]}}',
13152 ].join("\n") + "\n",
13153 );
13154 yield* harness.offerAndWait(
13155 workflowSnapshot({
13156 uuid: "00000000-0000-4000-8000-000000001018",
13157 state: "done",
13158 attempt: 2,
13159 }),
13160 );
13161 yield* awaitUntil(
13162 () => currentAssistantAnswers().join("\n\n").includes("Second answer"),
13163 "two-turn workflow member answer",
13164 );
13165 assert.equal(currentAssistantAnswers().join("\n\n"), "First answer\n\nSecond answer");
13166 assert.notDeepEqual(
13167 workflowMemberEvents(harness.events, 1).at(-1)?.subagent.startedAt,
13168 previousStartedAt,
13169 );
13170 yield* Queue.offer(
13171 harness.sdkMessages,
13172 workflowSnapshot({
13173 uuid: "00000000-0000-4000-8000-000000001019",
13174 state: "start",
13175 attempt: 3,
13176 }),
13177 );
13178 yield* awaitUntil(
13179 () => workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status === "running",
13180 "retried workflow member running",
13181 );
13182 assert.notInclude(currentAssistantAnswers().join("\n\n"), "First answer");
13183 assert.notInclude(currentAssistantAnswers().join("\n\n"), "Second answer");
13184 const answerEventsDuringRetry = threadMessages(harness.events, firstThreadId).length;
13185 yield* Queue.offer(
13186 harness.sdkMessages,
13187 workflowSnapshot({
13188 uuid: "00000000-0000-4000-8000-000000001020",
13189 state: "start",
13190 attempt: 3,
13191 }),
13192 );
13193 NodeFS.writeFileSync(
13194 NodePath.join(harness.transcriptDir, "agent-a1.jsonl"),
13195 '{"type":"assistant","message":{"role":"assistant","id":"msg_retry","content":[{"type":"text","text":"A1 retry answer"}]}}\n',
13196 );
13197 yield* Queue.offer(
13198 harness.sdkMessages,
13199 workflowSnapshot({
13200 uuid: "00000000-0000-4000-8000-000000001021",
13201 state: "done",
13202 attempt: 3,
13203 }),
13204 );
13205 yield* awaitUntil(
13206 () =>
13207 threadMessages(harness.events, firstThreadId).at(-1)?.message.text ===
13208 "A1 retry answer",
13209 "retried workflow member answer",
13210 );
13211 assert.deepEqual(currentAssistantAnswers(), ["A1 retry answer"]);
13212 assert.lengthOf(threadMessages(harness.events, firstThreadId), answerEventsDuringRetry + 1);
13213 NodeFS.rmSync(NodePath.join(harness.transcriptDir, "agent-a1.jsonl"));
13214 yield* Queue.offer(
13215 harness.sdkMessages,
13216 workflowSnapshot({
13217 uuid: "00000000-0000-4000-8000-000000001022",
13218 state: "start",
13219 attempt: 4,
13220 }),
13221 );
13222 yield* awaitUntil(
13223 () => currentAssistantAnswers().at(-1) === "Retry in progress.",
13224 "retry progress answer",
13225 );
13226 yield* Queue.offer(
13227 harness.sdkMessages,
13228 workflowSnapshot({
13229 uuid: "00000000-0000-4000-8000-000000001023",
13230 state: "done",
13231 attempt: 4,
13232 omitResult: true,
13233 }),
13234 );
13235 yield* awaitUntil(
13236 () => currentAssistantAnswers().at(-1) === "No answer from this workflow attempt.",
13237 "empty retry answer",
13238 );
13239 assert.deepEqual(currentAssistantAnswers(), ["No answer from this workflow attempt."]);
13240 }).pipe(
13241 Effect.provide(
13242 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
13243 ),
13244 ),
13245 ),
13246 );
13247
10877 describe("native goals", () => {13248 describe("native goals", () => {
10878 const syntheticFrame = (uuid: string, text: string) =>13249 const syntheticFrame = (uuid: string, text: string) =>
10879 claudeSdkFrame({13250 claudeSdkFrame({
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts+6-1
...@@ -17,8 +17,8 @@ import {...@@ -17,8 +17,8 @@ import {
17 type ProviderReplayTranscript,17 type ProviderReplayTranscript,
18} from "@t3tools/contracts";18} from "@t3tools/contracts";
19import * as Cause from "effect/Cause";19import * as Cause from "effect/Cause";
20import * as Duration from "effect/Duration";
21import * as Crypto from "effect/Crypto";20import * as Crypto from "effect/Crypto";
21import * as Duration from "effect/Duration";
22import * as Effect from "effect/Effect";22import * as Effect from "effect/Effect";
23import * as Exit from "effect/Exit";23import * as Exit from "effect/Exit";
24import * as Layer from "effect/Layer";24import * as Layer from "effect/Layer";
...@@ -242,6 +242,7 @@ interface ClaudeSubagentFoundFrame {...@@ -242,6 +242,7 @@ interface ClaudeSubagentFoundFrame {
242}242}
243243
244type ClaudeOutboundFrame =244type ClaudeOutboundFrame =
245 | { readonly type: "query.stop_task"; readonly taskId: string }
245 | ClaudeQueryOpenFrame246 | ClaudeQueryOpenFrame
246 | ClaudePromptOfferFrame247 | ClaudePromptOfferFrame
247 | ClaudeQuerySetModelFrame248 | ClaudeQuerySetModelFrame
...@@ -825,6 +826,10 @@ function makeReplayQueryRunner(...@@ -825,6 +826,10 @@ function makeReplayQueryRunner(
825 mode,826 mode,
826 });827 });
827 }),828 }),
829 stopTask: (taskId) =>
830 replayEffect(() => {
831 assertNextOutboundFrame({ type: "query.stop_task", taskId });
832 }),
828 interrupt: replayEffect(() => {833 interrupt: replayEffect(() => {
829 assertNextOutboundFrame({ type: "query.interrupt" });834 assertNextOutboundFrame({ type: "query.interrupt" });
830 }),835 }),
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+896-235
...@@ -4,6 +4,15 @@ import {...@@ -4,6 +4,15 @@ import {
4 formatReadToolLabel,4 formatReadToolLabel,
5 formatSearchToolLabel,5 formatSearchToolLabel,
6} from "@t3tools/shared/toolActivity";6} from "@t3tools/shared/toolActivity";
7import { field, text } from "../unknownField.ts";
8import { readWorkflowAgentAnswers } from "../workflowAgentAnswers.ts";
9import {
10 CLAUDE_WORKFLOW_TASK_TYPE,
11 hasClaudeWorkflowSnapshot,
12 mergeClaudeWorkflowProgress,
13 parseClaudeWorkflowRunHandles,
14} from "./claudeWorkflowProgress.ts";
15
7import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";16import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
8import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts";17import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts";
9import {18import {
...@@ -58,6 +67,9 @@ import {...@@ -58,6 +67,9 @@ import {
58 type OrchestrationV2RuntimeRequest,67 type OrchestrationV2RuntimeRequest,
59 type OrchestrationV2UserInputQuestion,68 type OrchestrationV2UserInputQuestion,
60 type OrchestrationV2Subagent,69 type OrchestrationV2Subagent,
70 type OrchestrationV2AppThread,
71 type OrchestrationV2SubagentWorkflow,
72 type OrchestrationV2WorkflowRunHandles,
61 type OrchestrationV2TurnItem,73 type OrchestrationV2TurnItem,
62 type OrchestrationV2WebSearchResult,74 type OrchestrationV2WebSearchResult,
63 type ProviderApprovalDecision,75 type ProviderApprovalDecision,
...@@ -66,15 +78,18 @@ import {...@@ -66,15 +78,18 @@ import {
66 type ProviderRequestKind,78 type ProviderRequestKind,
67 type ProviderUserInputAnswers,79 type ProviderUserInputAnswers,
68 type ProviderThreadId,80 type ProviderThreadId,
81 type RunId,
69 type ThreadId,82 type ThreadId,
70} from "@t3tools/contracts";83} from "@t3tools/contracts";
7184
72import * as Cause from "effect/Cause";85import * as Cause from "effect/Cause";
73import * as Context from "effect/Context";86import * as Context from "effect/Context";
74import * as Crypto from "effect/Crypto";87import * as Crypto from "effect/Crypto";
88import * as Hex from "effect/encoding/Hex";
75import * as DateTime from "effect/DateTime";89import * as DateTime from "effect/DateTime";
76import * as Deferred from "effect/Deferred";90import * as Deferred from "effect/Deferred";
77import * as Effect from "effect/Effect";91import * as Effect from "effect/Effect";
92import * as Equal from "effect/Equal";
78import * as Exit from "effect/Exit";93import * as Exit from "effect/Exit";
79import * as FileSystem from "effect/FileSystem";94import * as FileSystem from "effect/FileSystem";
80import * as Layer from "effect/Layer";95import * as Layer from "effect/Layer";
...@@ -83,6 +98,7 @@ import * as Path from "effect/Path";...@@ -83,6 +98,7 @@ import * as Path from "effect/Path";
83import * as Queue from "effect/Queue";98import * as Queue from "effect/Queue";
84import * as Ref from "effect/Ref";99import * as Ref from "effect/Ref";
85import * as Schema from "effect/Schema";100import * as Schema from "effect/Schema";
101import * as Semaphore from "effect/Semaphore";
86import * as Stream from "effect/Stream";102import * as Stream from "effect/Stream";
87103
88import { resolveAttachmentPath } from "../../attachmentStore.ts";104import { resolveAttachmentPath } from "../../attachmentStore.ts";
...@@ -365,7 +381,7 @@ export interface ClaudeAgentSdkQuerySession {...@@ -365,7 +381,7 @@ export interface ClaudeAgentSdkQuerySession {
365 readonly setPermissionMode: (381 readonly setPermissionMode: (
366 mode: PermissionMode,382 mode: PermissionMode,
367 ) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;383 ) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
368 readonly stopTask?: (taskId: string) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;384 readonly stopTask: (taskId: string) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
369 readonly interrupt: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;385 readonly interrupt: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
370 readonly close: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;386 readonly close: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
371}387}
...@@ -513,6 +529,14 @@ export type ClaudeAgentSdkProtocolLogEvent =...@@ -513,6 +529,14 @@ export type ClaudeAgentSdkProtocolLogEvent =
513 readonly mode: PermissionMode;529 readonly mode: PermissionMode;
514 };530 };
515 }531 }
532 | {
533 readonly direction: "outgoing";
534 readonly stage: "decoded";
535 readonly payload: {
536 readonly type: "query.stop_task";
537 readonly taskId: string;
538 };
539 }
516 | {540 | {
517 readonly direction: "outgoing";541 readonly direction: "outgoing";
518 readonly stage: "decoded";542 readonly stage: "decoded";
...@@ -809,7 +833,15 @@ export const layerQueryRunner: Layer.Layer<...@@ -809,7 +833,15 @@ export const layerQueryRunner: Layer.Layer<
809 Effect.tryPromise({833 Effect.tryPromise({
810 try: () => queryRuntime.stopTask(taskId),834 try: () => queryRuntime.stopTask(taskId),
811 catch: (cause) => queryRunnerError(cause, "stopTask"),835 catch: (cause) => queryRunnerError(cause, "stopTask"),
812 }),836 }).pipe(
837 Effect.tap(() =>
838 logProtocolEvent({
839 direction: "outgoing",
840 stage: "decoded",
841 payload: { type: "query.stop_task", taskId },
842 }),
843 ),
844 ),
813 interrupt: Effect.tryPromise({845 interrupt: Effect.tryPromise({
814 try: () => queryRuntime.interrupt(),846 try: () => queryRuntime.interrupt(),
815 catch: (cause) => queryRunnerError(cause, "interrupt"),847 catch: (cause) => queryRunnerError(cause, "interrupt"),
...@@ -1534,6 +1566,12 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input:...@@ -1534,6 +1566,12 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input:
1534 } satisfies SDKUserMessage;1566 } satisfies SDKUserMessage;
1535});1567});
15361568
1569const sha256Hex = Effect.fnUntraced(function* (crypto: Crypto.Crypto, value: string) {
1570 return Hex.encode(
1571 yield* crypto.digest("SHA-256", new TextEncoder().encode(value)).pipe(Effect.orDie),
1572 );
1573});
1574
1537export function isClaudePromptUuid(value: string): value is NonNullable<SDKUserMessage["uuid"]> {1575export function isClaudePromptUuid(value: string): value is NonNullable<SDKUserMessage["uuid"]> {
1538 return /^[^-]+-[^-]+-[^-]+-[^-]+-[^-]+$/.test(value);1576 return /^[^-]+-[^-]+-[^-]+-[^-]+-[^-]+$/.test(value);
1539}1577}
...@@ -2908,6 +2946,12 @@ interface ActiveClaudeSubagent {...@@ -2908,6 +2946,12 @@ interface ActiveClaudeSubagent {
2908 // owning subagent's node for a subagent another subagent started.2946 // owning subagent's node for a subagent another subagent started.
2909 readonly rootNodeId: OrchestrationV2ExecutionNode["id"];2947 readonly rootNodeId: OrchestrationV2ExecutionNode["id"];
2910 readonly childThreadId: ThreadId;2948 readonly childThreadId: ThreadId;
2949 /**
2950 * The child thread object itself, not just its id: workflow members are
2951 * parented to it long after the launching turn has settled, and building a
2952 * child thread needs the whole parent.
2953 */
2954 childThread: OrchestrationV2AppThread | null;
2911 readonly childRootNodeId: OrchestrationV2ExecutionNode["id"];2955 readonly childRootNodeId: OrchestrationV2ExecutionNode["id"];
2912 readonly turnItemId: OrchestrationV2TurnItem["id"];2956 readonly turnItemId: OrchestrationV2TurnItem["id"];
2913 readonly turnItemOrdinal: number;2957 readonly turnItemOrdinal: number;
...@@ -2932,6 +2976,8 @@ interface ClaudeLiveQueryContext {...@@ -2932,6 +2976,8 @@ interface ClaudeLiveQueryContext {
2932 readonly queryPolicyKey: string;2976 readonly queryPolicyKey: string;
2933 readonly selectionKey: string;2977 readonly selectionKey: string;
2934 readonly closed: Deferred.Deferred<void, never>;2978 readonly closed: Deferred.Deferred<void, never>;
2979 // Retained for process-exit cleanup even when its last turn failed.
2980 lastTurn: ActiveClaudeTurnContext | null;
2935 // Whether this CLI process echoes a prompt's uuid on the first frame of2981 // Whether this CLI process echoes a prompt's uuid on the first frame of
2936 // the turn answering it ("early") or only on its result. Learned from the2982 // the turn answering it ("early") or only on its result. Learned from the
2937 // first prompt turn. "acknowledged": the CLI confirmed it took a prompt's2983 // first prompt turn. "acknowledged": the CLI confirmed it took a prompt's
...@@ -3334,6 +3380,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -3334,6 +3380,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
3334 });3380 });
3335 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();3381 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
3336 const activeTurn = yield* Ref.make<ActiveClaudeTurnContext | null>(null);3382 const activeTurn = yield* Ref.make<ActiveClaudeTurnContext | null>(null);
3383 const sdkMessageLock = yield* Semaphore.make(1);
3384 // Survives turn settle: members mostly finish after the launching
3385 // turn has ended, and each transition must be emitted exactly once.
3386 const workflowMemberStates = yield* Ref.make(
3387 new Map<
3388 string,
3389 {
3390 readonly status: OrchestrationV2Subagent["status"];
3391 readonly attempt: number;
3392 /** Digest of the last emitted transcript answer, not the text: it can run to the transcript cap. */
3393 readonly transcriptAnswerDigest: string | undefined;
3394 readonly transcriptReadAttempts: number;
3395 readonly finalTranscriptRead: boolean;
3396 readonly result: string | undefined;
3397 readonly label: string;
3398 readonly prompt: string;
3399 readonly model: string;
3400 readonly createdAt: DateTime.Utc;
3401 readonly startedAt: DateTime.Utc | null;
3402 readonly completedAt: DateTime.Utc | null;
3403 }
3404 >(),
3405 );
3337 const interruptedTurns = yield* Ref.make(new Set<OrchestrationV2ProviderTurn["id"]>());3406 const interruptedTurns = yield* Ref.make(new Set<OrchestrationV2ProviderTurn["id"]>());
3338 const steeredTurns = yield* Ref.make(new Set<OrchestrationV2ProviderTurn["id"]>());3407 const steeredTurns = yield* Ref.make(new Set<OrchestrationV2ProviderTurn["id"]>());
3339 const queryContext = yield* Ref.make<ClaudeLiveQueryContext | null>(null);3408 const queryContext = yield* Ref.make<ClaudeLiveQueryContext | null>(null);
...@@ -4335,6 +4404,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -4335,6 +4404,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
4335 const ids = claudeSubagentIds(resume.context, resume.taskId);4404 const ids = claudeSubagentIds(resume.context, resume.taskId);
4336 const now = yield* DateTime.now;4405 const now = yield* DateTime.now;
4337 const subagent: ActiveClaudeSubagent = {4406 const subagent: ActiveClaudeSubagent = {
4407 childThread: null,
4338 task: {4408 task: {
4339 id: ids.nodeId,4409 id: ids.nodeId,
4340 threadId: resume.context.input.threadId,4410 threadId: resume.context.input.threadId,
...@@ -4385,6 +4455,341 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -4385,6 +4455,341 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
4385 );4455 );
4386 });4456 });
43874457
4458 /**
4459 * Stamps each member with the thread it owns. Derived from the
4460 * coordinator's own child thread so the id inherits its provider-thread
4461 * scoping, and done here so the snapshot the clients read carries the
4462 * same id the projection emits.
4463 */
4464 const withWorkflowMemberThreadIds = (
4465 workflow: OrchestrationV2SubagentWorkflow,
4466 coordinatorChildThreadId: ThreadId,
4467 ): OrchestrationV2SubagentWorkflow => ({
4468 ...workflow,
4469 agents: workflow.agents.map((member) => ({
4470 ...member,
4471 childThreadId: idAllocator.derive.threadFromProviderThread({
4472 driver: CLAUDE_PROVIDER,
4473 nativeThreadId: `${coordinatorChildThreadId}:agent:${member.index}`,
4474 }),
4475 })),
4476 });
4477
4478 /** Emits one conversation message into a subagent's own thread. */
4479 const emitSubagentConversationMessage = Effect.fnUntraced(function* (input: {
4480 readonly nativeItemId: string;
4481 readonly threadId: ThreadId;
4482 readonly senderThreadId?: ThreadId;
4483 readonly rootNodeId: OrchestrationV2ExecutionNode["id"];
4484 readonly role: "user" | "assistant";
4485 readonly text: string;
4486 readonly ordinal: number;
4487 readonly now: DateTime.Utc;
4488 }) {
4489 const artifacts = makeSubagentConversationArtifacts({
4490 messageId: idAllocator.derive.messageFromProviderItem({
4491 driver: CLAUDE_PROVIDER,
4492 nativeItemId: input.nativeItemId,
4493 }),
4494 turnItemId: idAllocator.derive.turnItemFromProviderItem({
4495 driver: CLAUDE_PROVIDER,
4496 nativeItemId: input.nativeItemId,
4497 }),
4498 threadId: input.threadId,
4499 ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }),
4500 rootNodeId: input.rootNodeId,
4501 providerThreadId: null,
4502 providerTurnId: null,
4503 nativeItemRef: {
4504 driver: CLAUDE_PROVIDER,
4505 nativeId: input.nativeItemId,
4506 strength: "strong",
4507 },
4508 role: input.role,
4509 text: input.text,
4510 ordinal: input.ordinal,
4511 now: input.now,
4512 });
4513 yield* emitProviderEvent({
4514 type: "message.updated",
4515 driver: CLAUDE_PROVIDER,
4516 message: artifacts.message,
4517 });
4518 yield* emitProviderEvent({
4519 type: "turn_item.updated",
4520 driver: CLAUDE_PROVIDER,
4521 turnItem: artifacts.turnItem,
4522 });
4523 });
4524
4525 /**
4526 * Projects a workflow's members as ordinary subagents.
4527 *
4528 * A member is not a provider task — it exists only inside the
4529 * coordinator's progress snapshot — but everything the user expects
4530 * from a subagent (a thread to open, a prompt, an answer) is expressed
4531 * by the same events, so this emits exactly the sequence
4532 * updateClaudeSubagentNode emits rather than inventing a parallel
4533 * shape. Members hang off the coordinator's thread, not the run's, so
4534 * a fan-out of fifty does not bury the parent's lineage.
4535 */
4536 const projectClaudeWorkflowMembers = Effect.fnUntraced(function* (input: {
4537 readonly coordinator: ActiveClaudeSubagent;
4538 readonly workflow: OrchestrationV2SubagentWorkflow;
4539 }) {
4540 const parentThread = input.coordinator.childThread;
4541 const taskId = input.coordinator.task.nativeTaskRef?.nativeId;
4542 if (parentThread === null || taskId === undefined || taskId === null) return;
4543 const now = yield* DateTime.now;
4544 const seen = yield* Ref.get(workflowMemberStates);
4545 const transcriptDir = input.workflow.runHandles?.transcriptDir;
4546
4547 for (const member of input.workflow.agents) {
4548 if ((yield* Ref.get(sessionSubagentsByTaskId)).get(taskId) !== input.coordinator)
4549 return;
4550 const memberKey = `${input.coordinator.childThreadId}:agent:${member.index}`;
4551 const previous = seen.get(memberKey);
4552 const status =
4553 (member.state === "queued" || member.state === "running") &&
4554 input.coordinator.task.status !== "running"
4555 ? input.coordinator.task.status
4556 : member.state === "queued"
4557 ? "pending"
4558 : member.state;
4559 const settled = status !== "pending" && status !== "running";
4560 const prompt = member.prompt ?? member.label;
4561 const model = member.model ?? parentThread.modelSelection.model;
4562 const attempt = member.attempt ?? previous?.attempt ?? 1;
4563 const restarted = previous !== undefined && attempt > previous.attempt;
4564 const createdAt = previous?.createdAt ?? now;
4565 const startedAt =
4566 status === "pending"
4567 ? null
4568 : member.startedAt !== undefined
4569 ? DateTime.makeUnsafe(member.startedAt)
4570 : ((restarted ? null : previous?.startedAt) ??
4571 (status === "running" ? now : null));
4572 const completedAt = settled
4573 ? ((restarted ? null : previous?.completedAt) ?? now)
4574 : null;
4575 const changed =
4576 restarted ||
4577 previous?.status !== status ||
4578 !Equal.equals(previous?.startedAt, startedAt) ||
4579 previous?.result !== member.result ||
4580 previous?.label !== member.label ||
4581 previous?.prompt !== prompt ||
4582 previous?.model !== model;
4583 const agentId = member.agentId;
4584 const readTranscript =
4585 settled &&
4586 transcriptDir !== undefined &&
4587 agentId !== undefined &&
4588 (input.coordinator.task.status === "running"
4589 ? (restarted ? 0 : (previous?.transcriptReadAttempts ?? 0)) < 3
4590 : restarted || previous?.finalTranscriptRead !== true);
4591 if (!changed && !readTranscript) continue;
4592
4593 const nodeId = idAllocator.derive.nodeFromProviderItem({
4594 driver: CLAUDE_PROVIDER,
4595 nativeItemId: memberKey,
4596 });
4597 const childRootNodeId = idAllocator.derive.nodeFromProviderItem({
4598 driver: CLAUDE_PROVIDER,
4599 nativeItemId: `${memberKey}:thread-root`,
4600 });
4601 const childThreadId = member.childThreadId;
4602 if (childThreadId === undefined) continue;
4603
4604 if (
4605 previous === undefined ||
4606 previous.label !== member.label ||
4607 previous.model !== model
4608 ) {
4609 yield* emitProviderEvent({
4610 type: "app_thread.created",
4611 driver: CLAUDE_PROVIDER,
4612 appThread: makeSubagentChildThread({
4613 parentThread,
4614 childThreadId,
4615 parentNodeId: nodeId,
4616 activeProviderThreadId: null,
4617 providerInstanceId: parentThread.providerInstanceId,
4618 modelSelection: { instanceId: parentThread.providerInstanceId, model },
4619 title: member.label,
4620 now: createdAt,
4621 createdBy: "agent",
4622 creationSource: "provider",
4623 }),
4624 });
4625 }
4626 if (previous === undefined || previous.prompt !== prompt) {
4627 yield* emitSubagentConversationMessage({
4628 nativeItemId: `${memberKey}:prompt`,
4629 threadId: childThreadId,
4630 rootNodeId: childRootNodeId,
4631 role: "user",
4632 text: prompt,
4633 ordinal: 100,
4634 now,
4635 });
4636 }
4637 if (restarted && !settled) {
4638 yield* emitSubagentConversationMessage({
4639 nativeItemId: `${memberKey}:answer:200`,
4640 threadId: childThreadId,
4641 rootNodeId: childRootNodeId,
4642 role: "assistant",
4643 text: "Retry in progress.",
4644 ordinal: 200,
4645 now,
4646 });
4647 }
4648
4649 const task = {
4650 id: nodeId,
4651 threadId: parentThread.id,
4652 runId: null,
4653 parentNodeId: input.coordinator.childRootNodeId,
4654 origin: "provider_native" as const,
4655 createdBy: "agent" as const,
4656 driver: CLAUDE_PROVIDER,
4657 providerInstanceId: parentThread.providerInstanceId,
4658 providerThreadId: null,
4659 childThreadId,
4660 nativeTaskRef: {
4661 driver: CLAUDE_PROVIDER,
4662 nativeId: memberKey,
4663 strength: "strong" as const,
4664 },
4665 prompt,
4666 title: member.label,
4667 model,
4668 status,
4669 result: member.result ?? null,
4670 startedAt,
4671 completedAt,
4672 updatedAt: now,
4673 } satisfies OrchestrationV2Subagent;
4674
4675 const nodeBase = {
4676 runId: null,
4677 status,
4678 countsForRun: false,
4679 providerThreadId: null,
4680 providerTurnId: null,
4681 nativeItemRef: task.nativeTaskRef,
4682 runtimeRequestId: null,
4683 checkpointScopeId: null,
4684 startedAt,
4685 completedAt,
4686 };
4687 if (changed) {
4688 for (const node of [
4689 {
4690 ...nodeBase,
4691 id: nodeId,
4692 threadId: parentThread.id,
4693 parentNodeId: input.coordinator.childRootNodeId,
4694 rootNodeId: input.coordinator.childRootNodeId,
4695 kind: "subagent" as const,
4696 },
4697 {
4698 ...nodeBase,
4699 id: childRootNodeId,
4700 threadId: childThreadId,
4701 parentNodeId: null,
4702 rootNodeId: childRootNodeId,
4703 kind: "root_turn" as const,
4704 },
4705 ]) {
4706 yield* emitProviderEvent({ type: "node.updated", driver: CLAUDE_PROVIDER, node });
4707 }
4708 yield* emitProviderEvent({
4709 type: "subagent.updated",
4710 driver: CLAUDE_PROVIDER,
4711 subagent: task,
4712 });
4713 }
4714
4715 let transcriptAnswerDigest = restarted ? undefined : previous?.transcriptAnswerDigest;
4716 let transcript: ReadonlyArray<string> = [];
4717 if (settled) {
4718 // The member's own transcript, when the run left one: a missing or
4719 // unreadable file is expected (a run predating run-handle capture,
4720 // a member that never started) and falls back to the excerpt.
4721 transcript = readTranscript
4722 ? yield* readWorkflowAgentAnswers({
4723 transcriptDir,
4724 agentId,
4725 ...(adapterOptions.environment.CLAUDE_CONFIG_DIR === undefined
4726 ? {}
4727 : {
4728 configDir: path.resolve(
4729 session.cwd,
4730 adapterOptions.environment.CLAUDE_CONFIG_DIR,
4731 ),
4732 }),
4733 }).pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []))
4734 : [];
4735 const transcriptAnswer = transcript.length > 0 ? transcript.join("\n\n") : undefined;
4736 const answerDigest =
4737 transcriptAnswer === undefined
4738 ? undefined
4739 : yield* sha256Hex(crypto, transcriptAnswer);
4740 // A continuation may settle or resume this workflow during the read.
4741 if ((yield* Ref.get(sessionSubagentsByTaskId)).get(taskId) !== input.coordinator)
4742 return;
4743 const previousDigest = transcriptAnswerDigest;
4744 if (answerDigest !== undefined) transcriptAnswerDigest = answerDigest;
4745 const answer =
4746 transcriptAnswer ??
4747 (member.result !== undefined && changed && previousDigest === undefined
4748 ? `Partial answer from workflow progress:\n\n${member.result}`
4749 : attempt > 1 && changed && previousDigest === undefined
4750 ? "No answer from this workflow attempt."
4751 : undefined);
4752 if (
4753 answer !== undefined &&
4754 (transcriptAnswer === undefined || restarted || answerDigest !== previousDigest)
4755 ) {
4756 yield* emitSubagentConversationMessage({
4757 nativeItemId: `${memberKey}:answer:200`,
4758 threadId: childThreadId,
4759 rootNodeId: childRootNodeId,
4760 role: "assistant",
4761 text: answer,
4762 ordinal: 200,
4763 now,
4764 });
4765 }
4766 }
4767
4768 yield* Ref.update(workflowMemberStates, (current) =>
4769 new Map(current).set(memberKey, {
4770 status,
4771 attempt,
4772 result: member.result,
4773 label: member.label,
4774 prompt,
4775 model,
4776 transcriptAnswerDigest,
4777 transcriptReadAttempts:
4778 (restarted ? 0 : (previous?.transcriptReadAttempts ?? 0)) +
4779 (readTranscript ? 1 : 0),
4780 finalTranscriptRead:
4781 (!restarted && previous?.finalTranscriptRead === true) ||
4782 (readTranscript &&
4783 transcript.length > 0 &&
4784 input.coordinator.task.status !== "running"),
4785 createdAt,
4786 startedAt,
4787 completedAt,
4788 }),
4789 );
4790 }
4791 });
4792
4388 const updateClaudeSubagentNode = Effect.fnUntraced(function* (input: {4793 const updateClaudeSubagentNode = Effect.fnUntraced(function* (input: {
4389 readonly context: ActiveClaudeTurnContext;4794 readonly context: ActiveClaudeTurnContext;
4390 readonly taskId: string;4795 readonly taskId: string;
...@@ -4397,24 +4802,39 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -4397,24 +4802,39 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
4397 readonly effort?: string;4802 readonly effort?: string;
4398 readonly owner?: ActiveClaudeSubagent;4803 readonly owner?: ActiveClaudeSubagent;
4399 readonly progress?: string;4804 readonly progress?: string;
4805 /** Raw SDK frame, folded into the coordinator's workflow snapshot. */
4806 readonly workflowFrame?: unknown;
4807 readonly workflowName?: string;
4808 readonly workflowRunHandles?: OrchestrationV2WorkflowRunHandles;
4400 readonly result?: string;4809 readonly result?: string;
4401 readonly status: Extract<4810 readonly status: Extract<
4402 OrchestrationV2ExecutionNode["status"],4811 OrchestrationV2ExecutionNode["status"],
4403 "running" | "completed" | "failed" | "cancelled"4812 "running" | "completed" | "failed" | "cancelled"
4404 >;4813 >;
4405 readonly reopen?: boolean;4814 readonly reopen?: boolean;
4815 readonly onlyIfRunningInRun?: RunId | null;
4406 }) {4816 }) {
4407 // The session registry lets a wake-replay turn (fresh context maps)4817 // The session registry lets a wake-replay turn (fresh context maps)
4408 // hydrate a subagent that was created by an earlier, settled turn.4818 // hydrate a subagent that was created by an earlier, settled turn.
4819 const registeredSubagent = (yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId);
4820 // Terminal cleanup must include snapshots received after the turn settled.
4409 const existingSubagent =4821 const existingSubagent =
4410 input.context.subagentsByTaskId.get(input.taskId) ??4822 input.onlyIfRunningInRun !== undefined
4411 (input.toolUseId === undefined4823 ? registeredSubagent
4412 ? undefined4824 : (input.context.subagentsByTaskId.get(input.taskId) ??
4413 : input.context.subagentsByToolUseId.get(input.toolUseId)) ??4825 (input.toolUseId === undefined
4414 (yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId);4826 ? undefined
4827 : input.context.subagentsByToolUseId.get(input.toolUseId)) ??
4828 registeredSubagent);
4415 if (existingSubagent === undefined && input.status !== "running") {4829 if (existingSubagent === undefined && input.status !== "running") {
4416 return;4830 return;
4417 }4831 }
4832 if (
4833 input.onlyIfRunningInRun !== undefined &&
4834 (existingSubagent?.task.status !== "running" ||
4835 existingSubagent.task.runId !== input.onlyIfRunningInRun)
4836 )
4837 return;
4418 // Status is monotone with one exception: task_started for a known4838 // Status is monotone with one exception: task_started for a known
4419 // task id is an authoritative CLI lifecycle event (SendMessage to a4839 // task id is an authoritative CLI lifecycle event (SendMessage to a
4420 // completed subagent resumes it and re-emits task_started with the4840 // completed subagent resumes it and re-emits task_started with the
...@@ -4433,23 +4853,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -4433,23 +4853,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
4433 ) {4853 ) {
4434 return;4854 return;
4435 }4855 }
4436 // A call the subagent left open never returns once it ends. It ends
4437 // first, while the run that owns the child thread still takes its
4438 // events.
4439 if (existingSubagent !== undefined && input.status !== "running") {
4440 const openCalls = [...subagentToolCalls.values()].filter(
4441 (toolCall) => toolCall.threadId === existingSubagent.childThreadId,
4442 );
4443 yield* endToolCalls({
4444 context: input.context,
4445 toolCalls: openCalls,
4446 status: input.status === "cancelled" ? "interrupted" : "failed",
4447 completedAt: yield* DateTime.now,
4448 });
4449 for (const toolCall of openCalls) {
4450 subagentToolCalls.delete(toolCall.nativeItemId);
4451 }
4452 }
4453 // A task_started under a tool call other than the current run's is4856 // A task_started under a tool call other than the current run's is
4454 // a resume: SendMessage re-emits task_started for the same task id4857 // a resume: SendMessage re-emits task_started for the same task id
4455 // under its own tool_use_id, with the sent message as the prompt.4858 // under its own tool_use_id, with the sent message as the prompt.
...@@ -4493,6 +4896,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -4493,6 +4896,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
4493 existingSubagent.task,4896 existingSubagent.task,
4494 )4897 )
4495 : existingSubagent.task;4898 : existingSubagent.task;
4899 // Workflow telemetry is cumulative: fold this frame into whatever the
4900 // coordinator already carried, so a usage-only frame cannot blank the
4901 // member roster the user is watching.
4902 const workflow = mergeClaudeWorkflowProgress({
4903 previous: priorTask?.workflow,
4904 ...(input.workflowName === undefined ? {} : { name: input.workflowName }),
4905 ...(input.workflowRunHandles === undefined
4906 ? {}
4907 : { runHandles: input.workflowRunHandles }),
4908 message: input.workflowFrame,
4909 });
4910 const workflowWithThreads =
4911 workflow === undefined
4912 ? undefined
4913 : withWorkflowMemberThreadIds(workflow, childThreadId);
4496 const effort =4914 const effort =
4497 existingSubagent === undefined4915 existingSubagent === undefined
4498 ? (input.effort ??4916 ? (input.effort ??
...@@ -4552,6 +4970,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -4552,6 +4970,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
4552 }4970 }
4553 : {}),4971 : {}),
4554 ...(input.progress === undefined ? {} : { progress: input.progress }),4972 ...(input.progress === undefined ? {} : { progress: input.progress }),
4973 ...(workflowWithThreads === undefined ? {} : { workflow: workflowWithThreads }),
4555 ...(input.result === undefined ? {} : { result: input.result }),4974 ...(input.result === undefined ? {} : { result: input.result }),
4556 ...(isReopen ? { startedAt: now } : {}),4975 ...(isReopen ? { startedAt: now } : {}),
4557 completedAt: input.status === "running" ? null : (priorTask?.completedAt ?? now),4976 completedAt: input.status === "running" ? null : (priorTask?.completedAt ?? now),
...@@ -4568,6 +4987,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -4568,6 +4987,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
4568 existingSubagent === undefined4987 existingSubagent === undefined
4569 ? (input.toolUseId ?? null)4988 ? (input.toolUseId ?? null)
4570 : (resumeToolUseId ?? existingSubagent.runToolUseId),4989 : (resumeToolUseId ?? existingSubagent.runToolUseId),
4990 childThread: existingSubagent?.childThread ?? null,
4571 effort,4991 effort,
4572 nextChildItemOrdinal: existingSubagent?.nextChildItemOrdinal ?? 100,4992 nextChildItemOrdinal: existingSubagent?.nextChildItemOrdinal ?? 100,
4573 resultItemOrdinal: existingSubagent?.resultItemOrdinal ?? null,4993 resultItemOrdinal: existingSubagent?.resultItemOrdinal ?? null,
...@@ -4579,16 +4999,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -4579,16 +4999,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
4579 lastAssistantMessageId:4999 lastAssistantMessageId:
4580 input.reopen === true ? null : (existingSubagent?.lastAssistantMessageId ?? null),5000 input.reopen === true ? null : (existingSubagent?.lastAssistantMessageId ?? null),
4581 } satisfies ActiveClaudeSubagent;5001 } satisfies ActiveClaudeSubagent;
4582 input.context.subagentsByTaskId.set(input.taskId, subagent);
4583 if (input.toolUseId !== undefined) {
4584 input.context.subagentsByToolUseId.set(input.toolUseId, subagent);
4585 const toolUseId = input.toolUseId;
4586 yield* Ref.update(sessionSubagentTaskIdsByToolUseId, (current) =>
4587 current.get(toolUseId) === input.taskId
4588 ? current
4589 : new Map(current).set(toolUseId, input.taskId),
4590 );
4591 }
4592 // The same terminal protection, applied atomically: a concurrent5002 // The same terminal protection, applied atomically: a concurrent
4593 // fiber (live stream vs continuation drain) may have terminalized5003 // fiber (live stream vs continuation drain) may have terminalized
4594 // the registry entry after this update's lookup read it. A resume5004 // the registry entry after this update's lookup read it. A resume
...@@ -4596,20 +5006,51 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -4596,20 +5006,51 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
4596 // is still the terminal generation the lookup resolved; if a5006 // is still the terminal generation the lookup resolved; if a
4597 // concurrent fiber installed a newer terminal entry meanwhile, the5007 // concurrent fiber installed a newer terminal entry meanwhile, the
4598 // re-open must not clobber its result.5008 // re-open must not clobber its result.
4599 yield* Ref.update(sessionSubagentsByTaskId, (current) => {5009 const accepted = yield* Ref.modify(sessionSubagentsByTaskId, (current) => {
4600 const registered = current.get(input.taskId);5010 const registered = current.get(input.taskId);
4601 if (5011 if (
4602 registered !== undefined &&5012 (input.onlyIfRunningInRun !== undefined &&
4603 registered.task.status !== "running" &&5013 (registered?.task.status !== "running" ||
4604 input.status === "running" &&5014 registered.task.runId !== input.onlyIfRunningInRun)) ||
4605 !(isReopen && registered === existingSubagent)5015 (registered !== undefined &&
5016 registered.task.status !== "running" &&
5017 input.status === "running" &&
5018 !(isReopen && registered === existingSubagent))
4606 ) {5019 ) {
4607 return current;5020 return [false, current] as const;
4608 }5021 }
4609 return new Map(current).set(input.taskId, subagent);5022 return [true, new Map(current).set(input.taskId, subagent)] as const;
4610 });5023 });
5024 if (!accepted) return;
5025 // A call the subagent left open never returns once it ends. It ends
5026 // first, while the run that owns the child thread still takes its
5027 // events.
5028 if (existingSubagent !== undefined && input.status !== "running") {
5029 const openCalls = [...subagentToolCalls.values()].filter(
5030 (toolCall) => toolCall.threadId === existingSubagent.childThreadId,
5031 );
5032 yield* endToolCalls({
5033 context: input.context,
5034 toolCalls: openCalls,
5035 status: input.status === "cancelled" ? "interrupted" : "failed",
5036 completedAt: yield* DateTime.now,
5037 });
5038 for (const toolCall of openCalls) {
5039 subagentToolCalls.delete(toolCall.nativeItemId);
5040 }
5041 }
5042 input.context.subagentsByTaskId.set(input.taskId, subagent);
5043 if (input.toolUseId !== undefined) {
5044 input.context.subagentsByToolUseId.set(input.toolUseId, subagent);
5045 const toolUseId = input.toolUseId;
5046 yield* Ref.update(sessionSubagentTaskIdsByToolUseId, (current) =>
5047 current.get(toolUseId) === input.taskId
5048 ? current
5049 : new Map(current).set(toolUseId, input.taskId),
5050 );
5051 }
46115052
4612 if (existingSubagent === undefined) {5053 if (subagent.childThread === null) {
4613 const childThread = makeSubagentChildThread({5054 const childThread = makeSubagentChildThread({
4614 parentThread: input.context.input.appThread,5055 parentThread: input.context.input.appThread,
4615 childThreadId,5056 childThreadId,
...@@ -4620,18 +5061,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -4620,18 +5061,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
4620 title: subagentThreadTitle({5061 title: subagentThreadTitle({
4621 parentTitle: input.context.input.appThread.title,5062 parentTitle: input.context.input.appThread.title,
4622 prompt: task.prompt,5063 prompt: task.prompt,
4623 title: task.title,5064 title:
5065 workflowWithThreads === undefined
5066 ? task.title
5067 : `Workflow · ${workflowWithThreads.name ?? task.title ?? "Untitled"}`,
4624 ordinal: input.context.subagentsByTaskId.size,5068 ordinal: input.context.subagentsByTaskId.size,
4625 }),5069 }),
4626 now,5070 now,
4627 createdBy: "agent",5071 createdBy: "agent",
4628 creationSource: "provider",5072 creationSource: "provider",
4629 });5073 });
4630 yield* emitProviderEvent({5074 if (existingSubagent === undefined) {
4631 type: "app_thread.created",5075 yield* emitProviderEvent({
4632 driver: CLAUDE_PROVIDER,5076 type: "app_thread.created",
4633 appThread: childThread,5077 driver: CLAUDE_PROVIDER,
4634 });5078 appThread: childThread,
5079 });
5080 }
5081 subagent.childThread = childThread;
4635 }5082 }
46365083
4637 if (lifecycleChanged) {5084 if (lifecycleChanged) {
...@@ -4695,46 +5142,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -4695,46 +5142,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
4695 ? null5142 ? null
4696 : `${nativeItemId}:prompt:${resumeToolUseId}`;5143 : `${nativeItemId}:prompt:${resumeToolUseId}`;
4697 if (promptNativeItemId !== null) {5144 if (promptNativeItemId !== null) {
4698 const promptArtifacts = makeSubagentConversationArtifacts({5145 yield* emitSubagentConversationMessage({
5146 nativeItemId: promptNativeItemId,
4699 senderThreadId: input.context.input.threadId,5147 senderThreadId: input.context.input.threadId,
4700 messageId: idAllocator.derive.messageFromProviderItem({
4701 driver: CLAUDE_PROVIDER,
4702 nativeItemId: promptNativeItemId,
4703 }),
4704 turnItemId: idAllocator.derive.turnItemFromProviderItem({
4705 driver: CLAUDE_PROVIDER,
4706 nativeItemId: promptNativeItemId,
4707 }),
4708 threadId: childThreadId,5148 threadId: childThreadId,
4709 rootNodeId: childRootNodeId,5149 rootNodeId: childRootNodeId,
4710 providerThreadId: null,
4711 providerTurnId: null,
4712 nativeItemRef: {
4713 driver: CLAUDE_PROVIDER,
4714 nativeId: promptNativeItemId,
4715 strength: "strong",
4716 },
4717 role: "user",5150 role: "user",
4718 text: task.prompt,5151 text: task.prompt,
4719 ordinal: existingSubagent === undefined ? 100 : ++subagent.nextChildItemOrdinal,5152 ordinal: existingSubagent === undefined ? 100 : ++subagent.nextChildItemOrdinal,
4720 now,5153 now,
4721 });5154 });
4722 yield* emitProviderEvent({
4723 type: "message.updated",
4724 driver: CLAUDE_PROVIDER,
4725 message: promptArtifacts.message,
4726 });
4727 yield* emitProviderEvent({
4728 type: "turn_item.updated",
4729 driver: CLAUDE_PROVIDER,
4730 turnItem: promptArtifacts.turnItem,
4731 });
4732 }5155 }
4733 yield* emitProviderEvent({5156 yield* emitProviderEvent({
4734 type: "subagent.updated",5157 type: "subagent.updated",
4735 driver: CLAUDE_PROVIDER,5158 driver: CLAUDE_PROVIDER,
4736 subagent: task,5159 subagent: task,
4737 });5160 });
5161 if (workflowWithThreads !== undefined) {
5162 yield* projectClaudeWorkflowMembers({
5163 coordinator: subagent,
5164 workflow: workflowWithThreads,
5165 });
5166 if ((yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId) !== subagent) return;
5167 }
4738 yield* emitProviderEvent({5168 yield* emitProviderEvent({
4739 type: "turn_item.updated",5169 type: "turn_item.updated",
4740 driver: CLAUDE_PROVIDER,5170 driver: CLAUDE_PROVIDER,
...@@ -4782,39 +5212,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -4782,39 +5212,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
4782 const resultNativeItemId = `${nativeItemId}:result`;5212 const resultNativeItemId = `${nativeItemId}:result`;
4783 const resultItemOrdinal = subagent.resultItemOrdinal ?? ++subagent.nextChildItemOrdinal;5213 const resultItemOrdinal = subagent.resultItemOrdinal ?? ++subagent.nextChildItemOrdinal;
4784 subagent.resultItemOrdinal = resultItemOrdinal;5214 subagent.resultItemOrdinal = resultItemOrdinal;
4785 const resultArtifacts = makeSubagentConversationArtifacts({5215 yield* emitSubagentConversationMessage({
4786 messageId: idAllocator.derive.messageFromProviderItem({5216 nativeItemId: resultNativeItemId,
4787 driver: CLAUDE_PROVIDER,
4788 nativeItemId: resultNativeItemId,
4789 }),
4790 turnItemId: idAllocator.derive.turnItemFromProviderItem({
4791 driver: CLAUDE_PROVIDER,
4792 nativeItemId: resultNativeItemId,
4793 }),
4794 threadId: childThreadId,5217 threadId: childThreadId,
4795 rootNodeId: childRootNodeId,5218 rootNodeId: childRootNodeId,
4796 providerThreadId: null,
4797 providerTurnId: null,
4798 nativeItemRef: {
4799 driver: CLAUDE_PROVIDER,
4800 nativeId: resultNativeItemId,
4801 strength: "strong",
4802 },
4803 role: "assistant",5219 role: "assistant",
4804 text: input.result,5220 text: input.result,
4805 ordinal: resultItemOrdinal,5221 ordinal: resultItemOrdinal,
4806 now,5222 now,
4807 });5223 });
4808 yield* emitProviderEvent({
4809 type: "message.updated",
4810 driver: CLAUDE_PROVIDER,
4811 message: resultArtifacts.message,
4812 });
4813 yield* emitProviderEvent({
4814 type: "turn_item.updated",
4815 driver: CLAUDE_PROVIDER,
4816 turnItem: resultArtifacts.turnItem,
4817 });
4818 }5224 }
4819 });5225 });
48205226
...@@ -5281,6 +5687,33 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -5281,6 +5687,33 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
5281 readonly result?: SDKResultMessage;5687 readonly result?: SDKResultMessage;
5282 }) {5688 }) {
5283 yield* reasoningDeltas.flushTurn(input.context.nativeTurnId);5689 yield* reasoningDeltas.flushTurn(input.context.nativeTurnId);
5690 if (input.status !== "completed") {
5691 // The run stops following its subagents once its root turn fails or is
5692 // interrupted, so a workflow it follows is stopped rather than left
5693 // running where Lineage can no longer see or stop it.
5694 const liveQuery = yield* Ref.get(queryContext);
5695 for (const [taskId, subagent] of yield* Ref.get(sessionSubagentsByTaskId)) {
5696 if (
5697 subagent.task.workflow === undefined ||
5698 subagent.task.status !== "running" ||
5699 subagent.task.runId !== input.context.input.runId
5700 )
5701 continue;
5702 if (liveQuery !== null && !liveQuery.stopping) {
5703 // Best effort and off the terminal path: an unanswered stop must
5704 // not keep the turn from ending.
5705 yield* liveQuery.query
5706 .stopTask(taskId)
5707 .pipe(Effect.timeout("15 seconds"), Effect.ignore, Effect.forkIn(sessionScope));
5708 }
5709 yield* updateClaudeSubagentNode({
5710 context: input.context,
5711 taskId,
5712 status: input.status === "failed" ? "failed" : "cancelled",
5713 onlyIfRunningInRun: input.context.input.runId,
5714 });
5715 }
5716 }
5284 // A subagent still running in the background keeps its open calls:5717 // A subagent still running in the background keeps its open calls:
5285 // their results arrive after this turn. One left by an earlier CLI5718 // their results arrive after this turn. One left by an earlier CLI
5286 // process is gone and never reports them.5719 // process is gone and never reports them.
...@@ -5288,14 +5721,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -5288,14 +5721,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
5288 const runningChildThreadIds = new Set(5721 const runningChildThreadIds = new Set(
5289 [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].flatMap((subagent) =>5722 [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].flatMap((subagent) =>
5290 subagent.task.status === "running" &&5723 subagent.task.status === "running" &&
5724 (input.status === "completed" ||
5725 (liveQuery !== null && subagent.task.runId !== input.context.input.runId)) &&
5291 liveQuery?.subagentsFromEarlierProcesses.has(subagent) !== true5726 liveQuery?.subagentsFromEarlierProcesses.has(subagent) !== true
5292 ? [subagent.childThreadId]5727 ? [subagent.childThreadId]
5293 : [],5728 : [],
5294 ),5729 ),
5295 );5730 );
5296 const endingSubagentCalls = [...subagentToolCalls.values()].filter(5731 const endingSubagentCalls = [...subagentToolCalls.values()].filter(
5297 (toolCall) =>5732 (toolCall) => !runningChildThreadIds.has(toolCall.threadId),
5298 input.status !== "completed" || !runningChildThreadIds.has(toolCall.threadId),
5299 );5733 );
5300 yield* endToolCalls({5734 yield* endToolCalls({
5301 context: input.context,5735 context: input.context,
...@@ -5570,27 +6004,72 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -5570,27 +6004,72 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
5570 });6004 });
55716005
5572 const finalizeActiveTurnAfterQueryExit = Effect.fnUntraced(function* (6006 const finalizeActiveTurnAfterQueryExit = Effect.fnUntraced(function* (
6007 closedQuery: ClaudeLiveQueryContext,
5573 cause?: Cause.Cause<ClaudeAgentSdkQueryRunnerError>,6008 cause?: Cause.Cause<ClaudeAgentSdkQueryRunnerError>,
5574 ) {6009 ) {
5575 const context = yield* Ref.get(activeTurn);6010 const current = yield* Ref.get(activeTurn);
5576 if (context === null) {6011 const context =
5577 return;6012 current?.nativeThreadId === closedQuery.nativeThreadId ? current : closedQuery.lastTurn;
5578 }6013 if (context === null) return;
5579 const completedAt = yield* DateTime.now;6014 const interrupted =
5580 const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId);6015 closedQuery.stopping || (yield* Ref.get(interruptedTurns)).has(context.providerTurnId);
5581 yield* finalizeActiveTurn({6016 const subagents = yield* Ref.get(sessionSubagentsByTaskId);
5582 context,6017 const buffered = (yield* Ref.get(wakeBuffers)).get(closedQuery.nativeThreadId)?.messages;
5583 status: interrupted ? "interrupted" : "failed",6018 if (current === context) {
5584 completedAt,6019 yield* finalizeActiveTurn({
5585 ...(interrupted6020 context,
5586 ? {}6021 status: interrupted ? "interrupted" : "failed",
5587 : {6022 completedAt: yield* DateTime.now,
5588 failure: makeProviderFailure({6023 ...(interrupted
5589 cause: cause === undefined ? undefined : Cause.squash(cause),6024 ? {}
5590 class: "transport_error",6025 : {
6026 failure: makeProviderFailure({
6027 cause: cause === undefined ? undefined : Cause.squash(cause),
6028 class: "transport_error",
6029 }),
5591 }),6030 }),
5592 }),6031 });
5593 });6032 } else {
6033 yield* resetBackgroundTaskStateForNativeThreadProcess(closedQuery.nativeThreadId);
6034 }
6035 // A workflow can outlive its launching turn, but not its CLI process.
6036 // Apply buffered outcomes before Stop can discard their wake buffer.
6037 // Capture ownership before finalization, which may yield to a new process.
6038 for (const [taskId, subagent] of subagents) {
6039 if (
6040 subagent.task.workflow === undefined ||
6041 subagent.task.status !== "running" ||
6042 closedQuery.subagentsFromEarlierProcesses.has(subagent)
6043 )
6044 continue;
6045 const latestLifecycle = buffered?.findLast(
6046 (
6047 message,
6048 ): message is Extract<
6049 SDKMessage,
6050 { readonly subtype: "task_started" | "task_notification" }
6051 > =>
6052 message.type === "system" &&
6053 (message.subtype === "task_started" || message.subtype === "task_notification") &&
6054 message.task_id === taskId,
6055 );
6056 // A later start resumes the same task and invalidates its older outcome.
6057 const completion =
6058 latestLifecycle?.subtype === "task_notification" ? latestLifecycle : undefined;
6059 yield* updateClaudeSubagentNode({
6060 context,
6061 taskId,
6062 status: completion
6063 ? claudeTaskOutcome(completion.status)
6064 : interrupted
6065 ? "cancelled"
6066 : "failed",
6067 ...(completion === undefined
6068 ? {}
6069 : { workflowFrame: completion, result: completion.summary }),
6070 onlyIfRunningInRun: subagent.task.runId,
6071 });
6072 }
5594 yield* Ref.update(interruptedTurns, (current) => {6073 yield* Ref.update(interruptedTurns, (current) => {
5595 const next = new Set(current);6074 const next = new Set(current);
5596 next.delete(context.providerTurnId);6075 next.delete(context.providerTurnId);
...@@ -5606,6 +6085,64 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -5606,6 +6085,64 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
5606 }6085 }
5607 });6086 });
56086087
6088 /**
6089 * Refreshes a background workflow coordinator's entity between turns.
6090 * Deliberately narrow: only a registered coordinator that this frame
6091 * carries a snapshot for is touched, and only `subagent.updated` is
6092 * emitted, because node and turn-item ids need a turn context this path
6093 * does not have. Status is left alone — `task_notification` owns the
6094 * lifecycle, and it has its own turn-aware path.
6095 * These updates only reach the projection while the launching run is still
6096 * ingesting, which holds because a running coordinator keeps that run open.
6097 */
6098 const applyWorkflowProgressWithoutTurn = Effect.fnUntraced(function* (message: SDKMessage) {
6099 if (message.type !== "system" || message.subtype !== "task_progress") return;
6100 if (!hasClaudeWorkflowSnapshot(message)) return;
6101 const taskId = message.task_id;
6102 const registered = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId);
6103 if (
6104 registered === undefined ||
6105 registered.task.workflow === undefined ||
6106 registered.task.status !== "running"
6107 )
6108 return;
6109 const workflow = mergeClaudeWorkflowProgress({
6110 previous: registered.task.workflow,
6111 message,
6112 });
6113 if (workflow === undefined) return;
6114 const now = yield* DateTime.now;
6115 const progress = text(message.description);
6116 const workflowWithThreads = withWorkflowMemberThreadIds(
6117 workflow,
6118 registered.childThreadId,
6119 );
6120 const task = {
6121 ...registered.task,
6122 ...(progress === undefined ? {} : { progress }),
6123 workflow: workflowWithThreads,
6124 updatedAt: now,
6125 } satisfies OrchestrationV2Subagent;
6126 const coordinator = { ...registered, task };
6127 const accepted = yield* Ref.modify(sessionSubagentsByTaskId, (current) => {
6128 const latest = current.get(taskId);
6129 if (latest !== registered || latest.task.status !== "running") {
6130 return [false, current] as const;
6131 }
6132 return [true, new Map(current).set(taskId, coordinator)] as const;
6133 });
6134 if (!accepted) return;
6135 yield* emitProviderEvent({
6136 type: "subagent.updated",
6137 driver: CLAUDE_PROVIDER,
6138 subagent: task,
6139 });
6140 yield* projectClaudeWorkflowMembers({
6141 coordinator,
6142 workflow: workflowWithThreads,
6143 });
6144 });
6145
5609 const bufferWakeMessage = Effect.fnUntraced(function* (wakeInput: {6146 const bufferWakeMessage = Effect.fnUntraced(function* (wakeInput: {
5610 readonly nativeThreadId: string;6147 readonly nativeThreadId: string;
5611 readonly message: SDKMessage;6148 readonly message: SDKMessage;
...@@ -6131,6 +6668,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -6131,6 +6668,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
6131 (yield* Ref.get(activeTurn)) ??6668 (yield* Ref.get(activeTurn)) ??
6132 (yield* settledTurnForSubagentFrame(liveQuery.nativeThreadId, message));6669 (yield* settledTurnForSubagentFrame(liveQuery.nativeThreadId, message));
6133 if (context === null) {6670 if (context === null) {
6671 // A workflow is normally launched in the background, so most of its
6672 // run happens after the turn that started it has settled. Nothing
6673 // here can build turn items or nodes without a turn context, but the
6674 // coordinator entity is what Lineage renders, so refresh it from the
6675 // session registry and a live run keeps reporting its phases, members
6676 // and usage instead of freezing at turn end.
6677 yield* applyWorkflowProgressWithoutTurn(message);
6134 // task_notification must buffer wake evidence while still tracked6678 // task_notification must buffer wake evidence while still tracked
6135 // on the roster; clearing first would drop the wake pin.6679 // on the roster; clearing first would drop the wake pin.
6136 if (message.type === "system" && message.subtype === "task_notification") {6680 if (message.type === "system" && message.subtype === "task_notification") {
...@@ -6523,6 +7067,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -6523,6 +7067,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
6523 toolUseId: message.tool_use_id,7067 toolUseId: message.tool_use_id,
6524 title: message.description,7068 title: message.description,
6525 });7069 });
7070 // A workflow coordinator is marked as one the moment it starts, so
7071 // Lineage can expand it before the first member spawns rather than
7072 // after the first progress snapshot.
7073 const isWorkflow =
7074 claudeTaskTypeFromSdkMessage(message) === CLAUDE_WORKFLOW_TASK_TYPE;
7075 // `meta.name` from the workflow script; only workflow tasks carry it.
7076 const workflowName = isWorkflow ? text(field(message, "workflow_name")) : undefined;
7077
6526 yield* updateClaudeSubagentNode({7078 yield* updateClaudeSubagentNode({
6527 context,7079 context,
6528 taskId: message.task_id,7080 taskId: message.task_id,
...@@ -6531,10 +7083,37 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -6531,10 +7083,37 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
6531 ...(model === undefined ? {} : { model }),7083 ...(model === undefined ? {} : { model }),
6532 ...(launch?.effort === undefined ? {} : { effort: launch.effort }),7084 ...(launch?.effort === undefined ? {} : { effort: launch.effort }),
6533 ...(owner === undefined ? {} : { owner }),7085 ...(owner === undefined ? {} : { owner }),
7086 ...(isWorkflow
7087 ? {
7088 workflowFrame: message,
7089 ...(workflowName === undefined ? {} : { workflowName }),
7090 }
7091 : {}),
7092
6534 title: message.description,7093 title: message.description,
6535 status: "running",7094 status: "running",
6536 reopen: true,7095 reopen: true,
6537 });7096 });
7097 // A resume supersedes earlier buffered starts and outcomes for this task.
7098 // Replaying a start preserves the completion buffered after it.
7099 yield* Ref.update(wakeBuffers, (current) => {
7100 const entry = current.get(liveQuery.nativeThreadId);
7101 if (entry === undefined) return current;
7102 const replayIndex = entry.messages.indexOf(message);
7103 const messages = entry.messages.filter(
7104 (buffered, index) =>
7105 !(
7106 (replayIndex === -1 || index < replayIndex) &&
7107 buffered.type === "system" &&
7108 (buffered.subtype === "task_started" ||
7109 buffered.subtype === "task_notification") &&
7110 buffered.task_id === message.task_id
7111 ),
7112 );
7113 return messages.length === entry.messages.length
7114 ? current
7115 : new Map(current).set(liveQuery.nativeThreadId, { ...entry, messages });
7116 });
6538 }7117 }
6539 }7118 }
65407119
...@@ -6544,8 +7123,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -6544,8 +7123,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
6544 liveQuery.nativeThreadId,7123 liveQuery.nativeThreadId,
6545 message.task_id,7124 message.task_id,
6546 );7125 );
7126 // A workflow frame is worth an update even with an empty description:
7127 // it carries the phase plan, the member roster and the run's usage.
7128 const carriesWorkflowTelemetry = hasClaudeWorkflowSnapshot(message);
7129 const isWorkflowCoordinator =
7130 (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id)?.task.workflow !==
7131 undefined;
6547 if (7132 if (
6548 progress.length > 0 &&7133 (carriesWorkflowTelemetry || (progress.length > 0 && !isWorkflowCoordinator)) &&
6549 !context.ignoredTaskIds.has(message.task_id) &&7134 !context.ignoredTaskIds.has(message.task_id) &&
6550 !isBackgroundTask7135 !isBackgroundTask
6551 ) {7136 ) {
...@@ -6553,7 +7138,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -6553,7 +7138,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
6553 context,7138 context,
6554 taskId: message.task_id,7139 taskId: message.task_id,
6555 ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }),7140 ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }),
6556 progress,7141 ...(progress.length === 0 ? {} : { progress }),
7142 workflowFrame: message,
6557 status: "running",7143 status: "running",
6558 });7144 });
6559 }7145 }
...@@ -6607,6 +7193,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -6607,6 +7193,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
6607 context,7193 context,
6608 taskId: message.task_id,7194 taskId: message.task_id,
6609 ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }),7195 ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }),
7196 // Terminal usage totals arrive here, after the last snapshot.
7197 workflowFrame: message,
6610 result: message.summary,7198 result: message.summary,
6611 status:7199 status:
6612 message.status === "completed"7200 message.status === "completed"
...@@ -6673,10 +7261,25 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -6673,10 +7261,25 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
6673 }7261 }
66747262
6675 for (const { toolResult, output } of claudeToolResultEntriesFromMessage(message)) {7263 for (const { toolResult, output } of claudeToolResultEntriesFromMessage(message)) {
6676 // Session scope: while the root is idle, this context is the turn
6677 // that settled last, which never saw a subagent an earlier turn
6678 // launched (a nested one whose Agent call returns now).
6679 const subagent = yield* resolveSubagentByToolUseId(context, toolResult.tool_use_id);7264 const subagent = yield* resolveSubagentByToolUseId(context, toolResult.tool_use_id);
7265 const runHandles = parseClaudeWorkflowRunHandles(claudeNativeToolOutputValue(output));
7266 // Independent of the branches below: unlike the Agent tool, a
7267 // Workflow tool_use is an ordinary tool call and so lands in
7268 // toolCalls, which skips the subagent branch entirely. Its launch
7269 // acknowledgement is the only carrier of the run's filesystem
7270 // handles, so harvest them wherever the result arrives.
7271 if (subagent !== undefined && runHandles !== undefined) {
7272 yield* updateClaudeSubagentNode({
7273 context,
7274 taskId: subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id),
7275 toolUseId: toolResult.tool_use_id,
7276 workflowRunHandles: runHandles,
7277 // The acknowledgement arrives at launch, so the coordinator
7278 // is still running; a late one is dropped by the same
7279 // terminal protection that guards every other update.
7280 status: "running",
7281 });
7282 }
6680 // A resume task_started reuses the resuming tool call's7283 // A resume task_started reuses the resuming tool call's
6681 // tool_use_id (e.g. SendMessage), whose tool_result only7284 // tool_use_id (e.g. SendMessage), whose tool_result only
6682 // acknowledges delivery. Only the Agent launch's tool_result may7285 // acknowledges delivery. Only the Agent launch's tool_result may
...@@ -6689,7 +7292,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -6689,7 +7292,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
6689 // A background Agent launch resolves its tool_use immediately7292 // A background Agent launch resolves its tool_use immediately
6690 // with an async-launch ACK while the task keeps running; only7293 // with an async-launch ACK while the task keeps running; only
6691 // the eventual task_notification terminalizes the subagent.7294 // the eventual task_notification terminalizes the subagent.
6692 if (isClaudeSubagentAsyncLaunchAck(output)) {7295 if (
7296 !isClaudeToolResultError(toolResult) &&
7297 (runHandles !== undefined || isClaudeSubagentAsyncLaunchAck(output))
7298 ) {
6693 continue;7299 continue;
6694 }7300 }
6695 const result = claudeSubagentResultText(output);7301 const result = claudeSubagentResultText(output);
...@@ -6838,39 +7444,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -6838,39 +7444,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
6838 ? `${subagent.lastAssistantText ?? ""}\n${assistantText.text}`7444 ? `${subagent.lastAssistantText ?? ""}\n${assistantText.text}`
6839 : assistantText.text;7445 : assistantText.text;
6840 subagent.lastAssistantMessageId = nativeMessageId;7446 subagent.lastAssistantMessageId = nativeMessageId;
6841 const artifacts = makeSubagentConversationArtifacts({7447 yield* emitSubagentConversationMessage({
6842 messageId: idAllocator.derive.messageFromProviderItem({7448 nativeItemId: assistantText.nativeItemId,
6843 driver: CLAUDE_PROVIDER,
6844 nativeItemId: assistantText.nativeItemId,
6845 }),
6846 turnItemId: idAllocator.derive.turnItemFromProviderItem({
6847 driver: CLAUDE_PROVIDER,
6848 nativeItemId: assistantText.nativeItemId,
6849 }),
6850 threadId: subagent.childThreadId,7449 threadId: subagent.childThreadId,
6851 rootNodeId: subagent.childRootNodeId,7450 rootNodeId: subagent.childRootNodeId,
6852 providerThreadId: null,
6853 providerTurnId: null,
6854 nativeItemRef: {
6855 driver: CLAUDE_PROVIDER,
6856 nativeId: assistantText.nativeItemId,
6857 strength: "strong",
6858 },
6859 role: "assistant",7451 role: "assistant",
6860 text: assistantText.text,7452 text: assistantText.text,
6861 ordinal: ++subagent.nextChildItemOrdinal,7453 ordinal: ++subagent.nextChildItemOrdinal,
6862 now,7454 now,
6863 });7455 });
6864 yield* emitProviderEvent({
6865 type: "message.updated",
6866 driver: CLAUDE_PROVIDER,
6867 message: artifacts.message,
6868 });
6869 yield* emitProviderEvent({
6870 type: "turn_item.updated",
6871 driver: CLAUDE_PROVIDER,
6872 turnItem: artifacts.turnItem,
6873 });
6874 return;7456 return;
6875 }7457 }
6876 if (assistantText !== null && assistantText.text.length > 0) {7458 if (assistantText !== null && assistantText.text.length > 0) {
...@@ -7726,6 +8308,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -7726,6 +8308,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
7726 queryPolicyKey,8308 queryPolicyKey,
7727 selectionKey: compiledSelection.queryIdentity,8309 selectionKey: compiledSelection.queryIdentity,
7728 closed,8310 closed,
8311 lastTurn: null,
7729 promptEchoMode: "unknown",8312 promptEchoMode: "unknown",
7730 openedPermissionMode: queryOptions.permissionMode,8313 openedPermissionMode: queryOptions.permissionMode,
7731 permissionMode: queryOptions.permissionMode,8314 permissionMode: queryOptions.permissionMode,
...@@ -7738,16 +8321,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -7738,16 +8321,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
7738 };8321 };
7739 yield* Ref.set(queryContext, context);8322 yield* Ref.set(queryContext, context);
7740 yield* querySession.messages.pipe(8323 yield* querySession.messages.pipe(
7741 Stream.runForEach((message) => {8324 Stream.runForEach((message) =>
7742 if (8325 sdkMessageLock.withPermits(1)(
7743 message.type === "system" &&8326 Effect.suspend(() => {
7744 (message.subtype === "init" || message.subtype === "status") &&8327 if (
7745 message.permissionMode !== undefined8328 message.type === "system" &&
7746 ) {8329 (message.subtype === "init" || message.subtype === "status") &&
7747 context.permissionMode = message.permissionMode;8330 message.permissionMode !== undefined
7748 }8331 ) {
7749 return handleSdkMessage({ query: querySession, message });8332 context.permissionMode = message.permissionMode;
7750 }),8333 }
8334 return handleSdkMessage({ query: querySession, message });
8335 }),
8336 ),
8337 ),
7751 Effect.exit,8338 Effect.exit,
7752 Effect.flatMap(8339 Effect.flatMap(
7753 Effect.fnUntraced(function* (exit: ClaudeQueryStreamExit) {8340 Effect.fnUntraced(function* (exit: ClaudeQueryStreamExit) {
...@@ -7767,6 +8354,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -7767,6 +8354,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
7767 );8354 );
7768 if (ownsLiveQuery) {8355 if (ownsLiveQuery) {
7769 yield* finalizeActiveTurnAfterQueryExit(8356 yield* finalizeActiveTurnAfterQueryExit(
8357 context,
7770 exit._tag === "Failure" ? exit.cause : undefined,8358 exit._tag === "Failure" ? exit.cause : undefined,
7771 );8359 );
7772 yield* endSubagentCallsOfClosedQuery(8360 yield* endSubagentCallsOfClosedQuery(
...@@ -7885,17 +8473,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -7885,17 +8473,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
7885 uuid: promptUuid,8473 uuid: promptUuid,
7886 });8474 });
7887 const querySession = yield* openQuery(turnInput, nativeThreadId);8475 const querySession = yield* openQuery(turnInput, nativeThreadId);
7888 yield* Ref.set(activeTurn, context);8476 const attachTurn = Effect.gen(function* () {
7889 yield* emitProviderEvent({8477 querySession.lastTurn = context;
7890 type: "provider_turn.updated",8478 yield* Ref.set(activeTurn, context);
7891 driver: CLAUDE_PROVIDER,8479 yield* emitProviderEvent({
7892 providerTurn: providerTurnPayload({8480 type: "provider_turn.updated",
7893 context,8481 driver: CLAUDE_PROVIDER,
7894 status: "running",8482 providerTurn: providerTurnPayload({
7895 completedAt: null,8483 context,
7896 }),8484 status: "running",
8485 completedAt: null,
8486 }),
8487 });
7897 });8488 });
7898 if (userMessage !== null) {8489 if (userMessage !== null) {
8490 yield* attachTurn;
7899 // A user turn that races a wake leaves the buffer alone: the8491 // A user turn that races a wake leaves the buffer alone: the
7900 // continuation run the worker queued behind this run drains it8492 // continuation run the worker queued behind this run drains it
7901 // afterwards with correct attribution.8493 // afterwards with correct attribution.
...@@ -7904,64 +8496,95 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -7904,64 +8496,95 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
7904 yield* querySession.query.offer(userMessage);8496 yield* querySession.query.offer(userMessage);
7905 return;8497 return;
7906 }8498 }
7907 const drained = yield* Ref.modify(wakeBuffers, (current) => {8499 // Transcript reads suspend replay. Hold live SDK frames until the whole
7908 const entry = current.get(nativeThreadId);8500 // buffer has been applied so starts, completions and the root result stay ordered.
7909 if (entry === undefined) {8501 yield* sdkMessageLock.withPermits(1)(
7910 return [[] as ReadonlyArray<SDKMessage>, current] as const;8502 Effect.gen(function* () {
7911 }8503 if (
7912 const updated = new Map(current);8504 (yield* Ref.get(queryContext))?.query !== querySession.query ||
7913 updated.delete(nativeThreadId);8505 querySession.stopping
7914 return [entry.messages, updated] as const;8506 ) {
7915 });8507 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
7916 yield* Ref.update(requestedContinuations, (current) => {8508 driver: CLAUDE_PROVIDER,
7917 const updated = new Set(current);8509 detail: "Claude provider query ended before the continuation could start.",
7918 updated.delete(nativeThreadId);8510 });
7919 return updated;8511 }
7920 });8512 // Live frames can finish while this continuation waits for the permit.
7921 if (drained.length === 0) {8513 yield* attachTurn;
7922 // Spurious continuation (buffer already lost with a recycled8514 // Exit and interrupt cleanup can run during transcript reads.
7923 // session, or a duplicate request): settle immediately instead8515 // Keep pending outcomes visible until replay applies them.
7924 // of leaving a run waiting on a prompt that was never sent.8516 const drained = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? [];
7925 const completedAt = yield* DateTime.now;8517 yield* Ref.update(requestedContinuations, (current) => {
7926 yield* finalizeActiveTurn({ context, status: "completed", completedAt });8518 const updated = new Set(current);
7927 return;8519 updated.delete(nativeThreadId);
7928 }8520 return updated;
7929 // Replay any result message last: a result finalizes the turn, and8521 });
7930 // replaying it before the rest would drop them back into the wake8522 if (drained.length === 0) {
7931 // buffer and request another continuation.8523 // Spurious continuation (buffer already lost with a recycled
7932 const resultMessages = drained.filter((entry) => entry.type === "result");8524 // session, or a duplicate request): settle immediately instead
7933 const opaqueReplayTombstones = taskIdSetForNativeThread(8525 // of leaving a run waiting on a prompt that was never sent.
7934 yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread),8526 const completedAt = yield* DateTime.now;
7935 nativeThreadId,8527 yield* finalizeActiveTurn({ context, status: "completed", completedAt });
7936 );8528 return;
7937 const hasOpaqueTaskNotification = drained.some(8529 }
7938 (entry) =>8530 // Replay any result message last: a result finalizes the turn, and
7939 entry.type === "system" &&8531 // replaying it before the rest would drop them back into the wake
7940 entry.subtype === "task_notification" &&8532 // buffer and request another continuation.
7941 opaqueReplayTombstones.has(entry.task_id),8533 const resultMessages = drained.filter((entry) => entry.type === "result");
7942 );8534 const opaqueReplayTombstones = taskIdSetForNativeThread(
7943 for (const entry of drained) {8535 yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread),
7944 if (entry.type !== "result") {8536 nativeThreadId,
7945 yield* handleSdkMessage({ query: querySession.query, message: entry });8537 );
7946 }8538 const hasOpaqueTaskNotification = drained.some(
7947 }8539 (entry) =>
7948 const lastResult = resultMessages.at(-1);8540 entry.type === "system" &&
7949 if (lastResult !== undefined) {8541 entry.subtype === "task_notification" &&
7950 yield* handleSdkMessage({ query: querySession.query, message: lastResult });8542 opaqueReplayTombstones.has(entry.task_id),
7951 return;8543 );
7952 }8544 for (const entry of drained) {
7953 // A drained `init` means Claude began the wake turn, so its output8545 if (
7954 // may still be on the way: stay open for it.8546 !(yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages.includes(entry)
7955 const hasNativeWakeFrame = drained.some(8547 ) {
7956 (entry) =>8548 continue;
7957 entry.type === "user" ||8549 }
7958 entry.type === "assistant" ||8550 if (entry.type !== "result") {
7959 isClaudeTurnStartMessage(entry),8551 yield* handleSdkMessage({ query: querySession.query, message: entry });
8552 }
8553 yield* Ref.update(wakeBuffers, (current) => {
8554 const buffered = current.get(nativeThreadId);
8555 if (buffered === undefined) return current;
8556 const index = buffered.messages.indexOf(entry);
8557 if (index === -1) return current;
8558 // An idle handler may requeue this same frame for the next continuation.
8559 const messages = [
8560 ...buffered.messages.slice(0, index),
8561 ...buffered.messages.slice(index + 1),
8562 ];
8563 const next = new Map(current);
8564 if (messages.length === 0) next.delete(nativeThreadId);
8565 else next.set(nativeThreadId, { ...buffered, messages });
8566 return next;
8567 });
8568 }
8569 const lastResult = resultMessages.at(-1);
8570 if (lastResult !== undefined) {
8571 yield* handleSdkMessage({ query: querySession.query, message: lastResult });
8572 return;
8573 }
8574 // A drained `init` means Claude began the wake turn, so its output
8575 // may still be on the way: stay open for it.
8576 const hasNativeWakeFrame = drained.some(
8577 (entry) =>
8578 entry.type === "user" ||
8579 entry.type === "assistant" ||
8580 isClaudeTurnStartMessage(entry),
8581 );
8582 if (hasOpaqueTaskNotification && !hasNativeWakeFrame) {
8583 const completedAt = yield* DateTime.now;
8584 yield* finalizeActiveTurn({ context, status: "completed", completedAt });
8585 }
8586 }),
7960 );8587 );
7961 if (hasOpaqueTaskNotification && !hasNativeWakeFrame) {
7962 const completedAt = yield* DateTime.now;
7963 yield* finalizeActiveTurn({ context, status: "completed", completedAt });
7964 }
7965 },8588 },
7966 (effect, turnInput) =>8589 (effect, turnInput) =>
7967 effect.pipe(8590 effect.pipe(
...@@ -8042,14 +8665,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -8042,14 +8665,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
8042 providerThreadId: turnInput.providerThread.id,8665 providerThreadId: turnInput.providerThread.id,
8043 providerTurnId: turnInput.providerTurnId,8666 providerTurnId: turnInput.providerTurnId,
8044 });8667 });
8045 yield* Ref.update(queryContext, (current) =>8668 const ownedLiveQuery = yield* Ref.modify(queryContext, (current) =>
8046 current?.query === existing.query ? null : current,8669 current?.query === existing.query ? [true, null] : [false, current],
8047 );8670 );
8048 yield* finalizeActiveTurn({8671 if (ownedLiveQuery) {
8049 context: currentTurn,8672 existing.stopping = true;
8050 status: "interrupted",8673 yield* finalizeActiveTurnAfterQueryExit(existing);
8051 completedAt,8674 yield* endSubagentCallsOfClosedQuery(existing.nativeThreadId, "interrupted");
8052 });8675 } else {
8676 // The stream may already be finalizing while another process starts.
8677 yield* finalizeActiveTurn({
8678 context: currentTurn,
8679 status: "interrupted",
8680 completedAt,
8681 });
8682 }
8053 yield* Deferred.succeed(existing.closed, undefined);8683 yield* Deferred.succeed(existing.closed, undefined);
8054 },8684 },
8055 (effect, turnInput) =>8685 (effect, turnInput) =>
...@@ -8155,6 +8785,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -8155,6 +8785,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
8155 current?.query === existing.query ? [true, null] : [false, current],8785 current?.query === existing.query ? [true, null] : [false, current],
8156 );8786 );
8157 if (ownedLiveQuery) {8787 if (ownedLiveQuery) {
8788 yield* finalizeActiveTurnAfterQueryExit(existing);
8158 yield* endSubagentCallsOfClosedQuery(nativeThreadId, "interrupted");8789 yield* endSubagentCallsOfClosedQuery(nativeThreadId, "interrupted");
8159 }8790 }
8160 yield* Deferred.succeed(existing.closed, undefined);8791 yield* Deferred.succeed(existing.closed, undefined);
...@@ -8266,6 +8897,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -8266,6 +8897,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
8266 message: { ...turnInput.message, text: "/compact" },8897 message: { ...turnInput.message, text: "/compact" },
8267 }),8898 }),
8268 steerTurn,8899 steerTurn,
8900 stopTask: ({ providerThread, taskId }) =>
8901 Effect.gen(function* () {
8902 const liveQuery = yield* Ref.get(queryContext);
8903 const task = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId);
8904 if (
8905 liveQuery === null ||
8906 liveQuery.stopping ||
8907 liveQuery.nativeThreadId !== providerThread.nativeThreadRef?.nativeId ||
8908 task === undefined ||
8909 task.task.threadId !== providerThread.appThreadId ||
8910 task.task.workflow === undefined ||
8911 task.task.status !== "running" ||
8912 liveQuery.subagentsFromEarlierProcesses.has(task)
8913 ) {
8914 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
8915 driver: CLAUDE_PROVIDER,
8916 detail: "No running workflow task belongs to this provider thread",
8917 });
8918 }
8919 yield* liveQuery.query.stopTask(taskId).pipe(
8920 Effect.mapError(
8921 (cause) =>
8922 new ProviderAdapter.ProviderAdapterProtocolError({
8923 driver: CLAUDE_PROVIDER,
8924 detail: "Workflow stop request failed",
8925 cause,
8926 }),
8927 ),
8928 );
8929 }),
8269 interruptTurn,8930 interruptTurn,
8270 stopSubagent: Effect.fn("ClaudeAdapterV2.stopSubagent")(function* (input) {8931 stopSubagent: Effect.fn("ClaudeAdapterV2.stopSubagent")(function* (input) {
8271 const existing = yield* Ref.get(queryContext);8932 const existing = yield* Ref.get(queryContext);
apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts created+80
...@@ -0,0 +1,80 @@
1import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import { ProviderSessionId, ThreadId } from "@t3tools/contracts";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Layer from "effect/Layer";
8import { vi } from "vite-plus/test";
9
10import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers";
11import {
12 CLAUDE_AGENT_SDK_QUERY_PROTOCOL,
13 ClaudeAgentSdkQueryRunner,
14 layerQueryRunner,
15} from "./ClaudeAdapterV2.ts";
16
17vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });
18
19it.effect("logs successful task stops for replay and omits failed stops", () =>
20 Effect.gen(function* () {
21 const writes: unknown[] = [];
22 const stopTask = vi.fn(async (_taskId: string) => {});
23 const query = vi.spyOn(ClaudeSdk, "query").mockReturnValue({
24 stopTask,
25 } as unknown as ClaudeSdk.Query);
26 yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
27 const threadId = ThreadId.make("thread-stop");
28 const providerSessionId = ProviderSessionId.make("session-stop");
29 yield* Effect.gen(function* () {
30 const runner = yield* ClaudeAgentSdkQueryRunner;
31 const session = yield* runner.open({
32 threadId,
33 providerSessionId,
34 options: {
35 sessionId: "native-session-stop",
36 model: "claude-sonnet-4-6",
37 tools: [],
38 permissionMode: "default",
39 },
40 });
41 writes.length = 0;
42 yield* session.stopTask("workflow-task");
43 assert.deepEqual(stopTask.mock.calls, [["workflow-task"]]);
44 assert.deepEqual(writes, [
45 {
46 provider: "claudeAgent",
47 protocol: CLAUDE_AGENT_SDK_QUERY_PROTOCOL,
48 kind: "protocol",
49 providerSessionId,
50 event: {
51 direction: "outgoing",
52 stage: "decoded",
53 payload: { type: "query.stop_task", taskId: "workflow-task" },
54 },
55 },
56 ]);
57 stopTask.mockRejectedValueOnce(new Error("Stop failed"));
58 assert.isTrue(Exit.isFailure(yield* Effect.exit(session.stopTask("failed-task"))));
59 assert.equal(writes.length, 1);
60 }).pipe(
61 Effect.provide(
62 layerQueryRunner.pipe(
63 Layer.provide(
64 Layer.succeed(ProviderEventLoggers, {
65 native: {
66 filePath: "/tmp/events.log",
67 write: (event) =>
68 Effect.sync(() => {
69 writes.push(event);
70 }),
71 close: () => Effect.void,
72 },
73 canonical: undefined,
74 }),
75 ),
76 ),
77 ),
78 );
79 }).pipe(Effect.provide(NodeServices.layer)),
80);
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+40-40
...@@ -4976,15 +4976,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (...@@ -4976,15 +4976,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
4976 node: artifacts.node,4976 node: artifacts.node,
4977 });4977 });
4978 yield* emitProviderEvent({4978 yield* emitProviderEvent({
4979 type: "runtime_request.updated",4979 type: "turn_item.updated",
4980 driver: CODEX_PROVIDER,4980 driver: CODEX_PROVIDER,
4981 threadId: artifacts.node.threadId,4981 turnItem: artifacts.turnItem,
4982 runtimeRequest: artifacts.request,
4983 });4982 });
4984 yield* emitProviderEvent({4983 yield* emitProviderEvent({
4985 type: "turn_item.updated",4984 type: "runtime_request.updated",
4986 driver: CODEX_PROVIDER,4985 driver: CODEX_PROVIDER,
4987 turnItem: artifacts.turnItem,4986 threadId: artifacts.node.threadId,
4987 runtimeRequest: artifacts.request,
4988 });4988 });
4989 yield* Ref.update(finalAnswerItemIdsByTurn, (current) => {4989 yield* Ref.update(finalAnswerItemIdsByTurn, (current) => {
4990 const ids = current.get(payload.turnId);4990 const ids = current.get(payload.turnId);
...@@ -5083,15 +5083,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (...@@ -5083,15 +5083,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
5083 node: artifacts.node,5083 node: artifacts.node,
5084 });5084 });
5085 yield* emitProviderEvent({5085 yield* emitProviderEvent({
5086 type: "runtime_request.updated",5086 type: "turn_item.updated",
5087 driver: CODEX_PROVIDER,5087 driver: CODEX_PROVIDER,
5088 threadId: artifacts.node.threadId,5088 turnItem: artifacts.turnItem,
5089 runtimeRequest: artifacts.request,
5090 });5089 });
5091 yield* emitProviderEvent({5090 yield* emitProviderEvent({
5092 type: "turn_item.updated",5091 type: "runtime_request.updated",
5093 driver: CODEX_PROVIDER,5092 driver: CODEX_PROVIDER,
5094 turnItem: artifacts.turnItem,5093 threadId: artifacts.node.threadId,
5094 runtimeRequest: artifacts.request,
5095 });5095 });
50965096
5097 const resolved = yield* Deferred.await(decision).pipe(5097 const resolved = yield* Deferred.await(decision).pipe(
...@@ -5143,15 +5143,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (...@@ -5143,15 +5143,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
5143 node: artifacts.node,5143 node: artifacts.node,
5144 });5144 });
5145 yield* emitProviderEvent({5145 yield* emitProviderEvent({
5146 type: "runtime_request.updated",5146 type: "turn_item.updated",
5147 driver: CODEX_PROVIDER,5147 driver: CODEX_PROVIDER,
5148 threadId: artifacts.node.threadId,5148 turnItem: artifacts.turnItem,
5149 runtimeRequest: artifacts.request,
5150 });5149 });
5151 yield* emitProviderEvent({5150 yield* emitProviderEvent({
5152 type: "turn_item.updated",5151 type: "runtime_request.updated",
5153 driver: CODEX_PROVIDER,5152 driver: CODEX_PROVIDER,
5154 turnItem: artifacts.turnItem,5153 threadId: artifacts.node.threadId,
5154 runtimeRequest: artifacts.request,
5155 });5155 });
51565156
5157 const resolved = yield* Deferred.await(decision).pipe(5157 const resolved = yield* Deferred.await(decision).pipe(
...@@ -5204,15 +5204,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (...@@ -5204,15 +5204,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
5204 node: artifacts.node,5204 node: artifacts.node,
5205 });5205 });
5206 yield* emitProviderEvent({5206 yield* emitProviderEvent({
5207 type: "runtime_request.updated",5207 type: "turn_item.updated",
5208 driver: CODEX_PROVIDER,5208 driver: CODEX_PROVIDER,
5209 threadId: artifacts.node.threadId,5209 turnItem: artifacts.turnItem,
5210 runtimeRequest: artifacts.request,
5211 });5210 });
5212 yield* emitProviderEvent({5211 yield* emitProviderEvent({
5213 type: "turn_item.updated",5212 type: "runtime_request.updated",
5214 driver: CODEX_PROVIDER,5213 driver: CODEX_PROVIDER,
5215 turnItem: artifacts.turnItem,5214 threadId: artifacts.node.threadId,
5215 runtimeRequest: artifacts.request,
5216 });5216 });
52175217
5218 const resolved = yield* Deferred.await(decision).pipe(5218 const resolved = yield* Deferred.await(decision).pipe(
...@@ -5289,15 +5289,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (...@@ -5289,15 +5289,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
5289 node: artifacts.node,5289 node: artifacts.node,
5290 });5290 });
5291 yield* emitProviderEvent({5291 yield* emitProviderEvent({
5292 type: "runtime_request.updated",5292 type: "turn_item.updated",
5293 driver: CODEX_PROVIDER,5293 driver: CODEX_PROVIDER,
5294 threadId: artifacts.node.threadId,5294 turnItem: artifacts.turnItem,
5295 runtimeRequest: artifacts.request,
5296 });5295 });
5297 yield* emitProviderEvent({5296 yield* emitProviderEvent({
5298 type: "turn_item.updated",5297 type: "runtime_request.updated",
5299 driver: CODEX_PROVIDER,5298 driver: CODEX_PROVIDER,
5300 turnItem: artifacts.turnItem,5299 threadId: artifacts.node.threadId,
5300 runtimeRequest: artifacts.request,
5301 });5301 });
53025302
5303 const resolved = yield* Deferred.await(decision).pipe(5303 const resolved = yield* Deferred.await(decision).pipe(
...@@ -5348,15 +5348,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (...@@ -5348,15 +5348,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
5348 node: artifacts.node,5348 node: artifacts.node,
5349 });5349 });
5350 yield* emitProviderEvent({5350 yield* emitProviderEvent({
5351 type: "runtime_request.updated",5351 type: "turn_item.updated",
5352 driver: CODEX_PROVIDER,5352 driver: CODEX_PROVIDER,
5353 threadId: artifacts.node.threadId,5353 turnItem: artifacts.turnItem,
5354 runtimeRequest: artifacts.request,
5355 });5354 });
5356 yield* emitProviderEvent({5355 yield* emitProviderEvent({
5357 type: "turn_item.updated",5356 type: "runtime_request.updated",
5358 driver: CODEX_PROVIDER,5357 driver: CODEX_PROVIDER,
5359 turnItem: artifacts.turnItem,5358 threadId: artifacts.node.threadId,
5359 runtimeRequest: artifacts.request,
5360 });5360 });
53615361
5362 const resolved = yield* Deferred.await(decision).pipe(5362 const resolved = yield* Deferred.await(decision).pipe(
...@@ -5408,15 +5408,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (...@@ -5408,15 +5408,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
5408 node: artifacts.node,5408 node: artifacts.node,
5409 });5409 });
5410 yield* emitProviderEvent({5410 yield* emitProviderEvent({
5411 type: "runtime_request.updated",5411 type: "turn_item.updated",
5412 driver: CODEX_PROVIDER,5412 driver: CODEX_PROVIDER,
5413 threadId: artifacts.node.threadId,5413 turnItem: artifacts.turnItem,
5414 runtimeRequest: artifacts.request,
5415 });5414 });
5416 yield* emitProviderEvent({5415 yield* emitProviderEvent({
5417 type: "turn_item.updated",5416 type: "runtime_request.updated",
5418 driver: CODEX_PROVIDER,5417 driver: CODEX_PROVIDER,
5419 turnItem: artifacts.turnItem,5418 threadId: artifacts.node.threadId,
5419 runtimeRequest: artifacts.request,
5420 });5420 });
54215421
5422 const resolved = yield* Deferred.await(decision).pipe(5422 const resolved = yield* Deferred.await(decision).pipe(
...@@ -5466,15 +5466,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (...@@ -5466,15 +5466,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
5466 node: artifacts.node,5466 node: artifacts.node,
5467 });5467 });
5468 yield* emitProviderEvent({5468 yield* emitProviderEvent({
5469 type: "runtime_request.updated",5469 type: "turn_item.updated",
5470 driver: CODEX_PROVIDER,5470 driver: CODEX_PROVIDER,
5471 threadId: artifacts.node.threadId,5471 turnItem: artifacts.turnItem,
5472 runtimeRequest: artifacts.request,
5473 });5472 });
5474 yield* emitProviderEvent({5473 yield* emitProviderEvent({
5475 type: "turn_item.updated",5474 type: "runtime_request.updated",
5476 driver: CODEX_PROVIDER,5475 driver: CODEX_PROVIDER,
5477 turnItem: artifacts.turnItem,5476 threadId: artifacts.node.threadId,
5477 runtimeRequest: artifacts.request,
5478 });5478 });
54795479
5480 const resolved = yield* Deferred.await(answers).pipe(5480 const resolved = yield* Deferred.await(answers).pipe(
apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts created+318
...@@ -0,0 +1,318 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 mergeClaudeWorkflowProgress,
5 parseClaudeWorkflowRunHandles,
6} from "./claudeWorkflowProgress.ts";
7
8const startFrame = {
9 type: "system",
10 subtype: "task_started",
11 task_id: "w1",
12 task_type: "local_workflow",
13 workflow_name: "probe-wf",
14};
15
16const snapshotFrame = (
17 entries: ReadonlyArray<Record<string, unknown>>,
18 usage?: Record<string, number>,
19) => ({
20 type: "system",
21 subtype: "task_progress",
22 task_id: "w1",
23 description: "Alpha: alpha:one",
24 workflow_progress: entries,
25 ...(usage === undefined ? {} : { usage }),
26});
27
28const phase = (index: number, title: string) => ({ type: "workflow_phase", index, title });
29
30const agent = (overrides: Record<string, unknown>) => ({
31 type: "workflow_agent",
32 index: 1,
33 label: "alpha:one",
34 state: "start",
35 phaseIndex: 1,
36 phaseTitle: "Alpha",
37 ...overrides,
38});
39
40describe("mergeClaudeWorkflowProgress", () => {
41 it("marks a coordinator as a workflow before any member exists", () => {
42 const workflow = mergeClaudeWorkflowProgress({
43 previous: undefined,
44 name: "probe-wf",
45 message: startFrame,
46 });
47 expect(workflow).toEqual({ name: "probe-wf", phases: [], agents: [] });
48 });
49
50 it("leaves an ordinary subagent's frames alone", () => {
51 expect(
52 mergeClaudeWorkflowProgress({
53 previous: undefined,
54 message: {
55 type: "system",
56 subtype: "task_progress",
57 task_id: "t1",
58 description: "reading files",
59 usage: { total_tokens: 500, tool_uses: 2, duration_ms: 90 },
60 },
61 }),
62 ).toBeUndefined();
63 });
64
65 it("maps provider agent states and per-member metrics", () => {
66 const workflow = mergeClaudeWorkflowProgress({
67 previous: undefined,
68 message: snapshotFrame([
69 phase(1, "Alpha"),
70 agent({
71 state: "done",
72 agentId: "a1",
73 model: "claude-opus-5[1m]",
74 attempt: 2,
75 tokens: 71_141,
76 toolCalls: 3,
77 durationMs: 1277,
78 startedAt: 1_700_000_000_000,
79 queuedAt: 1_699_999_999_000,
80 promptPreview: "Reply with exactly: A1",
81 resultPreview: "A1",
82 }),
83 agent({ index: 2, label: "alpha:two", state: "queued" }),
84 ]),
85 });
86 expect(workflow?.phases).toEqual([{ index: 1, title: "Alpha" }]);
87 expect(workflow?.agents[0]).toEqual({
88 index: 1,
89 label: "alpha:one",
90 agentId: "a1",
91 state: "completed",
92 phaseIndex: 1,
93 phaseTitle: "Alpha",
94 model: "claude-opus-5[1m]",
95 attempt: 2,
96 totalTokens: 71_141,
97 toolCalls: 3,
98 durationMs: 1277,
99 queuedAt: 1_699_999_999_000,
100 startedAt: 1_700_000_000_000,
101 prompt: "Reply with exactly: A1",
102 result: "A1",
103 });
104 expect(workflow?.agents[1]?.state).toBe("queued");
105 });
106
107 it.each(["something-new", "toString", "constructor", "__proto__"])(
108 "treats unrecognised state %s as still running rather than settled",
109 (state) => {
110 const workflow = mergeClaudeWorkflowProgress({
111 previous: undefined,
112 message: snapshotFrame([agent({ state })]),
113 });
114 expect(workflow?.agents[0]?.state).toBe("running");
115 },
116 );
117
118 it("keeps the roster through a usage-only frame", () => {
119 const first = mergeClaudeWorkflowProgress({
120 previous: undefined,
121 name: "probe-wf",
122 message: snapshotFrame([phase(1, "Alpha"), agent({})], {
123 total_tokens: 100,
124 tool_uses: 1,
125 duration_ms: 40,
126 }),
127 });
128 const second = mergeClaudeWorkflowProgress({
129 previous: first,
130 message: snapshotFrame([], { total_tokens: 250, tool_uses: 2, duration_ms: 900 }),
131 });
132 expect(second?.agents).toHaveLength(1);
133 expect(second?.phases).toHaveLength(1);
134 expect(second?.name).toBe("probe-wf");
135 expect(second?.totalTokens).toBe(250);
136 });
137
138 it("harvests run handles from the launch acknowledgement", () => {
139 const workflow = mergeClaudeWorkflowProgress({
140 previous: undefined,
141 runHandles: parseClaudeWorkflowRunHandles({
142 status: "async_launched",
143 taskType: "local_workflow",
144 runId: "wf_abc",
145 transcriptDir: "/root/.claude/projects/p/s/subagents/workflows/wf_abc",
146 scriptPath: "/root/.claude/projects/p/s/workflows/scripts/x.js",
147 }),
148 message: startFrame,
149 });
150 expect(workflow?.runHandles?.transcriptDir).toBe(
151 "/root/.claude/projects/p/s/subagents/workflows/wf_abc",
152 );
153 expect(workflow?.runHandles?.runId).toBe("wf_abc");
154 });
155
156 it("ignores a non-workflow tool acknowledgement", () => {
157 expect(
158 parseClaudeWorkflowRunHandles({ status: "async_launched", taskType: "local_agent" }),
159 ).toBeUndefined();
160 });
161
162 it("keeps a phase the plan reached at runtime even if a later frame omits it", () => {
163 const first = mergeClaudeWorkflowProgress({
164 previous: undefined,
165 message: snapshotFrame([phase(1, "Alpha"), phase(2, "Beta"), phase(3, "Gamma")]),
166 });
167 const second = mergeClaudeWorkflowProgress({
168 previous: first,
169 message: snapshotFrame([phase(1, "Alpha")]),
170 });
171 expect(second?.phases.map((entry) => entry.title)).toEqual(["Alpha", "Beta", "Gamma"]);
172 });
173
174 it("keeps other members and phase links when a sparse frame starts a new attempt", () => {
175 const first = mergeClaudeWorkflowProgress({
176 previous: undefined,
177 message: snapshotFrame([
178 phase(1, "Alpha"),
179 agent({
180 state: "error",
181 attempt: 1,
182 promptPreview: "First prompt",
183 resultPreview: "First result",
184 tokens: 100,
185 toolCalls: 2,
186 durationMs: 50,
187 queuedAt: 1000,
188 startedAt: 1010,
189 }),
190 agent({ index: 2, state: "done" }),
191 ]),
192 });
193 const second = mergeClaudeWorkflowProgress({
194 previous: first,
195 message: snapshotFrame([
196 { type: "workflow_agent", index: 1, label: "alpha:one", state: "start", attempt: 2 },
197 ]),
198 });
199 expect(second?.agents).toEqual([
200 {
201 index: 1,
202 label: "alpha:one",
203 state: "running",
204 attempt: 2,
205 phaseIndex: 1,
206 phaseTitle: "Alpha",
207 },
208 first?.agents[1],
209 ]);
210 expect(second?.phases).toEqual(first?.phases);
211 });
212
213 it("ignores an older attempt after a retry completes", () => {
214 const first = mergeClaudeWorkflowProgress({
215 previous: undefined,
216 message: snapshotFrame([agent({ state: "done", attempt: 2, resultPreview: "New result" })]),
217 });
218 const second = mergeClaudeWorkflowProgress({
219 previous: first,
220 message: snapshotFrame([agent({ state: "error", attempt: 1, resultPreview: "Old result" })]),
221 });
222 expect(second?.agents).toEqual(first?.agents);
223 });
224
225 it.each([
226 ["done", "start"],
227 ["done", "queued"],
228 ["error", "start"],
229 ["error", "queued"],
230 ["start", "queued"],
231 ])("keeps member state when the same attempt regresses from %s to %s", (state, staleState) => {
232 const first = mergeClaudeWorkflowProgress({
233 previous: undefined,
234 message: snapshotFrame([agent({ state, attempt: 2, resultPreview: "Current result" })]),
235 });
236 const second = mergeClaudeWorkflowProgress({
237 previous: first,
238 message: snapshotFrame([
239 agent({ state: staleState, attempt: 2, resultPreview: "Old result" }),
240 ]),
241 });
242 expect(second?.agents).toEqual(first?.agents);
243 });
244
245 it("retains member fields omitted by a later frame in the same attempt", () => {
246 const first = mergeClaudeWorkflowProgress({
247 previous: undefined,
248 message: snapshotFrame([agent({ attempt: 2, promptPreview: "Current prompt", tokens: 100 })]),
249 });
250 const second = mergeClaudeWorkflowProgress({
251 previous: first,
252 message: snapshotFrame([agent({ state: "done", resultPreview: "Current result" })]),
253 });
254 expect(second?.agents[0]).toMatchObject({
255 state: "completed",
256 attempt: 2,
257 prompt: "Current prompt",
258 result: "Current result",
259 totalTokens: 100,
260 });
261 });
262
263 it("keeps existing run handles when a later acknowledgement supplies only a script path", () => {
264 const first = mergeClaudeWorkflowProgress({
265 previous: undefined,
266 runHandles: { runId: "wf_abc", transcriptDir: "/workflows/wf_abc" },
267 message: startFrame,
268 });
269 const second = mergeClaudeWorkflowProgress({
270 previous: first,
271 runHandles: { scriptPath: "/scripts/workflow.js" },
272 message: startFrame,
273 });
274 expect(second?.runHandles).toEqual({
275 runId: "wf_abc",
276 transcriptDir: "/workflows/wf_abc",
277 scriptPath: "/scripts/workflow.js",
278 });
279 });
280
281 it("preserves prompt and result whitespace", () => {
282 const workflow = mergeClaudeWorkflowProgress({
283 previous: undefined,
284 message: snapshotFrame([
285 agent({ promptPreview: " indented\n", resultPreview: "\n answer\n" }),
286 ]),
287 });
288 expect(workflow?.agents[0]?.prompt).toBe(" indented\n");
289 expect(workflow?.agents[0]?.result).toBe("\n answer\n");
290 });
291
292 it("drops timestamps outside the DateTime range", () => {
293 const workflow = mergeClaudeWorkflowProgress({
294 previous: undefined,
295 message: snapshotFrame([
296 agent({ startedAt: 8_640_000_000_000_001, queuedAt: Number.MAX_SAFE_INTEGER }),
297 agent({ index: 2, startedAt: 8_640_000_000_000_000, queuedAt: 0 }),
298 ]),
299 });
300 expect(workflow?.agents[0]).not.toHaveProperty("startedAt");
301 expect(workflow?.agents[0]).not.toHaveProperty("queuedAt");
302 expect(workflow?.agents[1]?.startedAt).toBe(8_640_000_000_000_000);
303 expect(workflow?.agents[1]?.queuedAt).toBe(0);
304 });
305
306 it("drops malformed entries instead of failing the frame", () => {
307 const workflow = mergeClaudeWorkflowProgress({
308 previous: undefined,
309 message: snapshotFrame([
310 { type: "workflow_phase", title: "No index" },
311 { type: "workflow_agent", index: 4 },
312 { type: "something_else", index: 1, title: "Ignored" },
313 phase(1, "Alpha"),
314 ]),
315 });
316 expect(workflow).toEqual({ phases: [{ index: 1, title: "Alpha" }], agents: [] });
317 });
318});
apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts created+192
...@@ -0,0 +1,192 @@
1import type {
2 OrchestrationV2SubagentWorkflow,
3 OrchestrationV2WorkflowAgent,
4 OrchestrationV2WorkflowPhase,
5 OrchestrationV2WorkflowRunHandles,
6} from "@t3tools/contracts";
7
8import { field, text } from "../unknownField.ts";
9
10export const CLAUDE_WORKFLOW_TASK_TYPE = "local_workflow";
11
12export function hasClaudeWorkflowSnapshot(
13 message: unknown,
14): message is { readonly workflow_progress: ReadonlyArray<unknown> } {
15 const entries = field(message, "workflow_progress");
16 return Array.isArray(entries) && entries.length > 0;
17}
18
19const AGENT_STATES: Record<string, OrchestrationV2WorkflowAgent["state"] | undefined> = {
20 queued: "queued",
21 start: "running",
22 done: "completed",
23 error: "failed",
24};
25
26function count(value: unknown): number | undefined {
27 return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
28}
29
30function timestamp(value: unknown): number | undefined {
31 const milliseconds = count(value);
32 return milliseconds !== undefined && milliseconds <= 8_640_000_000_000_000
33 ? milliseconds
34 : undefined;
35}
36
37function preview(value: unknown): string | undefined {
38 return typeof value === "string" && value.length > 0 ? value : undefined;
39}
40
41function optional<T>(key: string, value: T | undefined): Record<string, T> {
42 return value === undefined ? {} : ({ [key]: value } as Record<string, T>);
43}
44
45function parsePhase(entry: unknown): OrchestrationV2WorkflowPhase | null {
46 const index = count(field(entry, "index"));
47 const title = text(field(entry, "title"));
48 return index === undefined || title === undefined ? null : { index, title };
49}
50
51function parseAgent(entry: unknown): OrchestrationV2WorkflowAgent | null {
52 const index = count(field(entry, "index"));
53 const label = text(field(entry, "label"));
54 if (index === undefined || label === undefined) return null;
55 // An unknown state is in-flight, not settled: guessing "completed" would
56 // freeze a live row, while guessing "running" self-corrects on the next frame.
57 const reportedState = text(field(entry, "state")) ?? "";
58 const state = Object.hasOwn(AGENT_STATES, reportedState)
59 ? (AGENT_STATES[reportedState] ?? "running")
60 : "running";
61 return {
62 index,
63 label,
64 state,
65 ...optional("agentId", text(field(entry, "agentId"))),
66 ...optional("phaseIndex", count(field(entry, "phaseIndex"))),
67 ...optional("phaseTitle", text(field(entry, "phaseTitle"))),
68 ...optional("model", text(field(entry, "model"))),
69 ...optional("attempt", count(field(entry, "attempt"))),
70 ...optional("totalTokens", count(field(entry, "tokens"))),
71 ...optional("toolCalls", count(field(entry, "toolCalls"))),
72 ...optional("durationMs", count(field(entry, "durationMs"))),
73 ...optional("queuedAt", timestamp(field(entry, "queuedAt"))),
74 ...optional("startedAt", timestamp(field(entry, "startedAt"))),
75 ...optional("prompt", preview(field(entry, "promptPreview"))),
76 ...optional("result", preview(field(entry, "resultPreview"))),
77 };
78}
79
80/**
81 * Reads the Workflow tool's structured result. A background launch answers the
82 * model with an acknowledgement rather than the run's output, and that
83 * acknowledgement is the only place the run's filesystem handles appear —
84 * nothing later on the event stream repeats them.
85 */
86export function parseClaudeWorkflowRunHandles(
87 toolUseResult: unknown,
88): OrchestrationV2WorkflowRunHandles | undefined {
89 if (text(field(toolUseResult, "taskType")) !== CLAUDE_WORKFLOW_TASK_TYPE) return undefined;
90 const handles = {
91 ...optional("runId", text(field(toolUseResult, "runId"))),
92 ...optional("transcriptDir", text(field(toolUseResult, "transcriptDir"))),
93 ...optional("scriptPath", text(field(toolUseResult, "scriptPath"))),
94 };
95 return Object.keys(handles).length === 0 ? undefined : handles;
96}
97
98function parseClaudeWorkflowProgress(message: unknown): {
99 readonly phases: ReadonlyArray<OrchestrationV2WorkflowPhase>;
100 readonly agents: ReadonlyArray<OrchestrationV2WorkflowAgent>;
101} | null {
102 if (!hasClaudeWorkflowSnapshot(message)) return null;
103 const phases: OrchestrationV2WorkflowPhase[] = [];
104 const agents: OrchestrationV2WorkflowAgent[] = [];
105 for (const entry of message.workflow_progress) {
106 const type = text(field(entry, "type"));
107 if (type === "workflow_phase") {
108 const phase = parsePhase(entry);
109 if (phase !== null) phases.push(phase);
110 } else if (type === "workflow_agent") {
111 const agent = parseAgent(entry);
112 if (agent !== null) agents.push(agent);
113 }
114 }
115 return phases.length === 0 && agents.length === 0 ? null : { phases, agents };
116}
117
118/**
119 * Folds one frame into the coordinator's workflow state. A frame without a
120 * snapshot still carries usage, so totals keep climbing between snapshots.
121 * A phase only ever joins the plan — the provider re-sends the full plan on
122 * every snapshot, but a late frame that omits an already-seen phase must not
123 * shorten the arc the user is looking at.
124 *
125 * Returns undefined when nothing identifies the task as a workflow. Progress
126 * frames do not repeat `task_type`, so this is the single place that decides
127 * whether a task is a coordinator; without it every ordinary subagent's usage
128 * frame would mint an empty workflow and render as a coordinator.
129 */
130export function mergeClaudeWorkflowProgress(input: {
131 readonly previous: OrchestrationV2SubagentWorkflow | undefined;
132 readonly name?: string | undefined;
133 readonly runHandles?: OrchestrationV2WorkflowRunHandles | undefined;
134 readonly message: unknown;
135}): OrchestrationV2SubagentWorkflow | undefined {
136 const { previous } = input;
137 const snapshot = parseClaudeWorkflowProgress(input.message);
138 if (
139 previous === undefined &&
140 input.name === undefined &&
141 input.runHandles === undefined &&
142 snapshot === null
143 ) {
144 return undefined;
145 }
146 const reported = field(input.message, "usage");
147 const phasesByIndex = new Map(
148 (previous?.phases ?? []).map((phase) => [phase.index, phase] as const),
149 );
150 for (const phase of snapshot?.phases ?? []) phasesByIndex.set(phase.index, phase);
151 const agentsByIndex = new Map(
152 (previous?.agents ?? []).map((agent) => [agent.index, agent] as const),
153 );
154 for (const agent of snapshot?.agents ?? []) {
155 const prior = agentsByIndex.get(agent.index);
156 const attempt = agent.attempt ?? prior?.attempt ?? 1;
157 if (prior !== undefined && attempt < (prior.attempt ?? 1)) continue;
158 const restarted = prior !== undefined && attempt > (prior.attempt ?? 1);
159 const regressed =
160 !restarted &&
161 (((prior?.state === "completed" || prior?.state === "failed") &&
162 agent.state !== prior.state) ||
163 (prior?.state === "running" && agent.state === "queued"));
164 agentsByIndex.set(
165 agent.index,
166 regressed
167 ? { ...agent, ...prior }
168 : {
169 ...(restarted
170 ? {
171 ...optional("phaseIndex", prior.phaseIndex),
172 ...optional("phaseTitle", prior.phaseTitle),
173 }
174 : prior),
175 ...agent,
176 },
177 );
178 }
179 const runHandles =
180 input.runHandles === undefined
181 ? previous?.runHandles
182 : { ...previous?.runHandles, ...input.runHandles };
183 return {
184 ...optional("name", input.name ?? previous?.name),
185 ...optional("runHandles", runHandles),
186 phases: Array.from(phasesByIndex.values()).sort((left, right) => left.index - right.index),
187 agents: Array.from(agentsByIndex.values()).sort((left, right) => left.index - right.index),
188 ...optional("totalTokens", count(field(reported, "total_tokens")) ?? previous?.totalTokens),
189 ...optional("toolCalls", count(field(reported, "tool_uses")) ?? previous?.toolCalls),
190 ...optional("durationMs", count(field(reported, "duration_ms")) ?? previous?.durationMs),
191 };
192}
apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts+64-22
...@@ -42,10 +42,12 @@ const stopEarlierBackgroundWork = ({...@@ -42,10 +42,12 @@ const stopEarlierBackgroundWork = ({
42 stopWithQueue,42 stopWithQueue,
43 olderStart = false,43 olderStart = false,
44 stalledRun,44 stalledRun,
45 workflowMember = false,
45}: {46}: {
46 readonly failedStart?: boolean;47 readonly failedStart?: boolean;
47 readonly stopWithQueue?: "thread.stop" | "run.interrupt";48 readonly stopWithQueue?: "thread.stop" | "run.interrupt";
48 readonly olderStart?: boolean;49 readonly olderStart?: boolean;
50 readonly workflowMember?: boolean;
49 readonly stalledRun?:51 readonly stalledRun?:
50 | "missing-session"52 | "missing-session"
51 | "missing-session-terminal"53 | "missing-session-terminal"
...@@ -488,23 +490,27 @@ const stopEarlierBackgroundWork = ({...@@ -488,23 +490,27 @@ const stopEarlierBackgroundWork = ({
488 events: [490 events: [
489 // A subagent the reviewer started, which the provider records on the491 // A subagent the reviewer started, which the provider records on the
490 // reviewer's own thread.492 // reviewer's own thread.
491 {493 ...(workflowMember
492 id: EventId.make("nested-subagent-item"),494 ? []
493 type: "turn-item.updated",495 : [
494 threadId: reviewerThreadId,496 {
495 occurredAt: now,497 id: EventId.make("nested-subagent-item"),
496 payload: {498 type: "turn-item.updated",
497 ...nested,499 threadId: reviewerThreadId,
498 id: nestedItemId,500 occurredAt: now,
499 nodeId: nestedSubagentId,501 payload: {
500 providerTurnId: null,502 ...nested,
501 nativeItemRef: null,503 id: nestedItemId,
502 parentItemId: null,504 nodeId: nestedSubagentId,
503 ordinal: 1,505 providerTurnId: null,
504 type: "subagent",506 nativeItemRef: null,
505 subagentId: nestedSubagentId,507 parentItemId: null,
506 },508 ordinal: 1,
507 },509 type: "subagent",
510 subagentId: nestedSubagentId,
511 },
512 } as const,
513 ]),
508 {514 {
509 id: EventId.make("nested-subagent"),515 id: EventId.make("nested-subagent"),
510 type: "subagent.updated",516 type: "subagent.updated",
...@@ -515,12 +521,36 @@ const stopEarlierBackgroundWork = ({...@@ -515,12 +521,36 @@ const stopEarlierBackgroundWork = ({
515 id: nestedSubagentId,521 id: nestedSubagentId,
516 parentNodeId: reviewerRootId,522 parentNodeId: reviewerRootId,
517 createdBy: "agent",523 createdBy: "agent",
518 nativeTaskRef: null,524 nativeTaskRef: workflowMember
525 ? { driver, nativeId: "workflow:member", strength: "strong" }
526 : null,
519 model: null,527 model: null,
520 },528 },
521 },529 },
522 ],530 ],
523 });531 });
532 if (workflowMember) {
533 // Workflow members have execution nodes and child roots, but no subagent turn item.
534 const memberRoot = (yield* orchestrator.getThreadProjection(tester.threadId)).nodes[0]!;
535 yield* sink.write({
536 events: [
537 {
538 id: EventId.make("workflow-member-node"),
539 type: "node.updated",
540 threadId: reviewerThreadId,
541 occurredAt: now,
542 payload: {
543 ...memberRoot,
544 id: nestedSubagentId,
545 threadId: reviewerThreadId,
546 parentNodeId: reviewerRootId,
547 rootNodeId: reviewerRootId,
548 kind: "subagent",
549 },
550 },
551 ],
552 });
553 }
524 // A settled run with its root node, attempt, provider turn and,554 // A settled run with its root node, attempt, provider turn and,
525 // optionally, a command or native subagent it left running.555 // optionally, a command or native subagent it left running.
526 const settledRun = (input: {556 const settledRun = (input: {
...@@ -883,10 +913,18 @@ const stopEarlierBackgroundWork = ({...@@ -883,10 +913,18 @@ const stopEarlierBackgroundWork = ({
883 const reviewerThread = yield* orchestrator.getThreadProjection(reviewerThreadId);913 const reviewerThread = yield* orchestrator.getThreadProjection(reviewerThreadId);
884 assert.equal(reviewerThread.nodes[0]?.status, "interrupted");914 assert.equal(reviewerThread.nodes[0]?.status, "interrupted");
885 assert.equal(reviewerThread.subagents[0]?.status, "interrupted");915 assert.equal(reviewerThread.subagents[0]?.status, "interrupted");
886 assert.equal(916 if (workflowMember) {
887 reviewerThread.turnItems.find((item) => item.id === nestedItemId)?.status,917 assert.isUndefined(reviewerThread.turnItems.find((item) => item.id === nestedItemId));
888 "interrupted",918 assert.equal(
889 );919 reviewerThread.nodes.find((node) => node.id === nestedSubagentId)?.status,
920 "interrupted",
921 );
922 } else {
923 assert.equal(
924 reviewerThread.turnItems.find((item) => item.id === nestedItemId)?.status,
925 "interrupted",
926 );
927 }
890 const testerThread = yield* orchestrator.getThreadProjection(tester.threadId);928 const testerThread = yield* orchestrator.getThreadProjection(tester.threadId);
891 assert.equal(testerThread.nodes[0]?.status, "interrupted");929 assert.equal(testerThread.nodes[0]?.status, "interrupted");
892 }).pipe(930 }).pipe(
...@@ -905,6 +943,10 @@ it.effect("Stop reaches background work an earlier provider thread still runs",...@@ -905,6 +943,10 @@ it.effect("Stop reaches background work an earlier provider thread still runs",
905 stopEarlierBackgroundWork({}),943 stopEarlierBackgroundWork({}),
906);944);
907945
946it.effect("Stop settles workflow members without subagent turn items", () =>
947 stopEarlierBackgroundWork({ workflowMember: true }),
948);
949
908it.effect(950it.effect(
909 "Stop reaches earlier background work after the newest run fails before provider start",951 "Stop reaches earlier background work after the newest run fails before provider start",
910 () => stopEarlierBackgroundWork({ failedStart: true }),952 () => stopEarlierBackgroundWork({ failedStart: true }),
apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts+1
...@@ -158,6 +158,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con...@@ -158,6 +158,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con
158 }),158 }),
159 setModel: () => Effect.void,159 setModel: () => Effect.void,
160 setPermissionMode: () => Effect.void,160 setPermissionMode: () => Effect.void,
161 stopTask: () => Effect.void,
161 interrupt: Effect.die("automatic delivery must never interrupt"),162 interrupt: Effect.die("automatic delivery must never interrupt"),
162 close: Effect.void,163 close: Effect.void,
163 }),164 }),
apps/server/src/orchestration-v2/Orchestrator.ts+18-1
...@@ -8421,7 +8421,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -8421,7 +8421,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
8421 OrchestrationV2ThreadProjection,8421 OrchestrationV2ThreadProjection,
8422 "nodes" | "subagents" | "turnItems"8422 "nodes" | "subagents" | "turnItems"
8423 >;8423 >;
8424 readonly subagent: Extract<OrchestrationV2TurnItem, { readonly type: "subagent" }>;8424 readonly subagent: Pick<
8425 Extract<OrchestrationV2TurnItem, { readonly type: "subagent" }>,
8426 "subagentId" | "origin" | "childThreadId" | "providerInstanceId"
8427 >;
8425 }> = [{ projection: input.projection, subagent: item }];8428 }> = [{ projection: input.projection, subagent: item }];
8426 for (const { projection, subagent } of dying) {8429 for (const { projection, subagent } of dying) {
8427 const ended = yield* endOrphanedNativeSubagent({8430 const ended = yield* endOrphanedNativeSubagent({
...@@ -8466,6 +8469,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -8466,6 +8469,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
8466 for (const nested of child.turnItems) {8469 for (const nested of child.turnItems) {
8467 if (nested.type === "subagent") dying.push({ projection: child, subagent: nested });8470 if (nested.type === "subagent") dying.push({ projection: child, subagent: nested });
8468 }8471 }
8472 // Workflow members have records and child roots without subagent turn items.
8473 for (const nested of child.subagents) {
8474 if (nested.origin === "provider_native" && nested.runId === null) {
8475 dying.push({
8476 projection: child,
8477 subagent: {
8478 subagentId: nested.id,
8479 origin: nested.origin,
8480 childThreadId: nested.childThreadId,
8481 providerInstanceId: nested.providerInstanceId,
8482 },
8483 });
8484 }
8485 }
8469 }8486 }
8470 }8487 }
8471 for (const providerThread of input.projection.providerThreads) {8488 for (const providerThread of input.projection.providerThreads) {
apps/server/src/orchestration-v2/ProjectionStore.test.ts+163-1
...@@ -349,6 +349,54 @@ it.effect("memory recovery selection includes unfinished items from missing runs...@@ -349,6 +349,54 @@ it.effect("memory recovery selection includes unfinished items from missing runs
349 }).pipe(Effect.provide(ProjectionStore.layerMemory)),349 }).pipe(Effect.provide(ProjectionStore.layerMemory)),
350);350);
351351
352const runlessNativeSubagentNeedsRecovery = Effect.gen(function* () {
353 const store = yield* ProjectionStore.ProjectionStoreV2;
354 const threadId = yield* addRolledBackRecoveryCandidate("runless-native-recovery");
355 const now = yield* DateTime.now;
356 const id = NodeId.make("node:runless-native-recovery");
357 assert.notInclude(yield* store.getRecoveryThreadIds("runtime"), threadId);
358 for (const status of ["pending", "running", "waiting", "completed"] as const) {
359 yield* store.apply({
360 id: EventId.make(`event:runless-native-recovery:${status}`),
361 type: "subagent.updated",
362 threadId,
363 nodeId: id,
364 driver,
365 providerInstanceId,
366 occurredAt: now,
367 payload: {
368 id,
369 threadId,
370 runId: null,
371 parentNodeId: NodeId.make("node:runless-native-parent"),
372 origin: "provider_native",
373 createdBy: "agent",
374 driver,
375 providerInstanceId,
376 providerThreadId: null,
377 childThreadId: null,
378 nativeTaskRef: { driver, nativeId: "runless-native", strength: "strong" },
379 prompt: "Do the work",
380 title: "Workflow member",
381 model: null,
382 status,
383 result: null,
384 startedAt: now,
385 completedAt: status === "completed" ? now : null,
386 updatedAt: now,
387 },
388 });
389 assert.equal(
390 (yield* store.getRecoveryThreadIds("runtime")).includes(threadId),
391 status !== "completed",
392 );
393 }
394});
395
396it.effect("memory recovery selects unfinished runless native subagents", () =>
397 runlessNativeSubagentNeedsRecovery.pipe(Effect.provide(ProjectionStore.layerMemory)),
398);
399
352// Project A holds an active thread, an archived thread and a fork of a run in400// Project A holds an active thread, an archived thread and a fork of a run in
353// an archived project B thread, so a project-scoped snapshot still has to load401// an archived project B thread, so a project-scoped snapshot still has to load
354// a fork source that is outside the project and archived.402// a fork source that is outside the project and archived.
...@@ -501,6 +549,11 @@ it.effect("memory shell snapshots scope to one project", () =>...@@ -501,6 +549,11 @@ it.effect("memory shell snapshots scope to one project", () =>
501);549);
502550
503it.layer(layerTest)("ProjectionStoreV2", (it) => {551it.layer(layerTest)("ProjectionStoreV2", (it) => {
552 it.effect(
553 "SQL recovery selects unfinished runless native subagents",
554 () => runlessNativeSubagentNeedsRecovery,
555 );
556
504 it.effect(557 it.effect(
505 "keeps restart-cancelled work through a stale run.updated",558 "keeps restart-cancelled work through a stale run.updated",
506 () => restartCancelledWorkSurvivesStaleRunUpdate,559 () => restartCancelledWorkSurvivesStaleRunUpdate,
...@@ -2520,6 +2573,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {...@@ -2520,6 +2573,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
2520 const now = yield* DateTime.now;2573 const now = yield* DateTime.now;
2521 const settledThreadId = ThreadId.make("thread:recovery-candidates:settled");2574 const settledThreadId = ThreadId.make("thread:recovery-candidates:settled");
2522 const runningThreadId = ThreadId.make("thread:recovery-candidates:running");2575 const runningThreadId = ThreadId.make("thread:recovery-candidates:running");
2576 const memberThreadId = ThreadId.make("thread:recovery-candidates:member");
2577 const childThreadId = ThreadId.make("thread:recovery-candidates:child");
2523 const rolledBackThreadId = yield* addRolledBackRecoveryCandidate("recovery-candidates");2578 const rolledBackThreadId = yield* addRolledBackRecoveryCandidate("recovery-candidates");
2524 const orphanedThreadId = yield* addOrphanedRecoveryCandidate("recovery-candidates");2579 const orphanedThreadId = yield* addOrphanedRecoveryCandidate("recovery-candidates");
2525 const projectId = ProjectId.make("project:recovery-candidates");2580 const projectId = ProjectId.make("project:recovery-candidates");
...@@ -2551,7 +2606,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {...@@ -2551,7 +2606,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
2551 deletedAt: null,2606 deletedAt: null,
2552 });2607 });
25532608
2554 for (const threadId of [settledThreadId, runningThreadId]) {2609 for (const threadId of [settledThreadId, runningThreadId, memberThreadId, childThreadId]) {
2555 yield* projectionStore.apply({2610 yield* projectionStore.apply({
2556 id: EventId.make(`event:recovery-candidates:${threadId}:created`),2611 id: EventId.make(`event:recovery-candidates:${threadId}:created`),
2557 type: "thread.created",2612 type: "thread.created",
...@@ -2591,11 +2646,118 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {...@@ -2591,11 +2646,118 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
2591 },2646 },
2592 });2647 });
25932648
2649 const memberId = NodeId.make("node:recovery-candidates:member");
2650 const childRootNodeId = NodeId.make("node:recovery-candidates:child-root");
2651 const nativeTaskRef = { driver, nativeId: "workflow:agent:1", strength: "strong" as const };
2652 const memberNode = {
2653 id: memberId,
2654 threadId: memberThreadId,
2655 runId: null,
2656 parentNodeId: NodeId.make("node:recovery-candidates:coordinator-root"),
2657 rootNodeId: NodeId.make("node:recovery-candidates:coordinator-root"),
2658 kind: "subagent" as const,
2659 status: "running" as const,
2660 countsForRun: false,
2661 providerThreadId: null,
2662 providerTurnId: null,
2663 nativeItemRef: nativeTaskRef,
2664 runtimeRequestId: null,
2665 checkpointScopeId: null,
2666 startedAt: now,
2667 completedAt: null,
2668 };
2669 yield* projectionStore.apply({
2670 id: EventId.make("event:recovery-candidates:member"),
2671 type: "subagent.updated",
2672 threadId: memberThreadId,
2673 nodeId: memberId,
2674 driver,
2675 providerInstanceId,
2676 occurredAt: now,
2677 payload: {
2678 id: memberId,
2679 threadId: memberThreadId,
2680 runId: null,
2681 parentNodeId: memberNode.parentNodeId,
2682 origin: "provider_native",
2683 createdBy: "agent",
2684 driver,
2685 providerInstanceId,
2686 providerThreadId: null,
2687 childThreadId,
2688 nativeTaskRef,
2689 prompt: "Do the work",
2690 title: "Member",
2691 model: null,
2692 status: "running",
2693 result: null,
2694 startedAt: now,
2695 completedAt: null,
2696 updatedAt: now,
2697 },
2698 });
2699 for (const [threadId, node] of [
2700 [memberThreadId, memberNode],
2701 [
2702 childThreadId,
2703 {
2704 ...memberNode,
2705 id: childRootNodeId,
2706 threadId: childThreadId,
2707 parentNodeId: null,
2708 rootNodeId: childRootNodeId,
2709 kind: "root_turn" as const,
2710 },
2711 ],
2712 ] as const) {
2713 yield* projectionStore.apply({
2714 id: EventId.make(`event:recovery-candidates:node:${node.id}`),
2715 type: "node.updated",
2716 threadId,
2717 nodeId: node.id,
2718 driver,
2719 occurredAt: now,
2720 payload: node,
2721 });
2722 }
2723
2594 const recoveryThreadIds = yield* projectionStore.getRecoveryThreadIds("runtime");2724 const recoveryThreadIds = yield* projectionStore.getRecoveryThreadIds("runtime");
2595 assert.include(recoveryThreadIds, runningThreadId);2725 assert.include(recoveryThreadIds, runningThreadId);
2596 assert.include(recoveryThreadIds, orphanedThreadId);2726 assert.include(recoveryThreadIds, orphanedThreadId);
2727 assert.include(recoveryThreadIds, memberThreadId);
2728 assert.include(recoveryThreadIds, childThreadId);
2597 assert.notInclude(recoveryThreadIds, settledThreadId);2729 assert.notInclude(recoveryThreadIds, settledThreadId);
2598 assert.notInclude(recoveryThreadIds, rolledBackThreadId);2730 assert.notInclude(recoveryThreadIds, rolledBackThreadId);
2731 const memberRecovery = yield* projectionStore.getRuntimeRecoveryProjection(memberThreadId);
2732 const childRecovery = yield* projectionStore.getRuntimeRecoveryProjection(childThreadId);
2733 assert.deepEqual(
2734 memberRecovery.subagents.map((subagent) => subagent.id),
2735 [memberId],
2736 );
2737 assert.deepEqual(
2738 memberRecovery.nodes.map((node) => node.id),
2739 [memberId],
2740 );
2741 assert.deepEqual(
2742 childRecovery.nodes.map((node) => node.id),
2743 [childRootNodeId],
2744 );
2745 yield* projectionStore.apply({
2746 id: EventId.make("event:recovery-candidates:member-cancelled"),
2747 type: "subagent.updated",
2748 threadId: memberThreadId,
2749 nodeId: memberId,
2750 driver,
2751 providerInstanceId,
2752 occurredAt: now,
2753 payload: {
2754 ...memberRecovery.subagents[0]!,
2755 status: "cancelled",
2756 completedAt: now,
2757 updatedAt: now,
2758 },
2759 });
2760 assert.include(yield* projectionStore.getRecoveryThreadIds("runtime"), childThreadId);
2599 }),2761 }),
2600 );2762 );
26012763
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts+84
...@@ -1589,6 +1589,90 @@ layer("ProviderEventIngestorV2", (it) => {...@@ -1589,6 +1589,90 @@ layer("ProviderEventIngestorV2", (it) => {
1589 }),1589 }),
1590 );1590 );
15911591
1592 it.effect("updates provider child metadata without recreating its saved thread", () =>
1593 Effect.gen(function* () {
1594 const now = yield* DateTime.now;
1595 const eventSink = yield* EventSink.EventSinkV2;
1596 const projectionStore = yield* ProjectionStore.ProjectionStoreV2;
1597 const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2;
1598 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1599 const rootEvent = yield* threadCreatedEvent(now);
1600 if (rootEvent.type !== "thread.created") throw new Error("Expected thread fixture");
1601 const childThreadId = idAllocator.derive.threadFromProviderThread({
1602 driver: CODEX_DRIVER,
1603 nativeThreadId: "workflow-member-metadata",
1604 });
1605 const providerSessionId = yield* idAllocator.allocate.providerSession({
1606 providerInstanceId: modelSelection.instanceId,
1607 threadId: rootEvent.threadId,
1608 });
1609 const offered = {
1610 ...rootEvent.payload,
1611 id: childThreadId,
1612 title: "Inspect checkout",
1613 activeProviderThreadId: null,
1614 lineage: {
1615 parentThreadId: rootEvent.threadId,
1616 relationshipToParent: "subagent" as const,
1617 rootThreadId: rootEvent.threadId,
1618 },
1619 };
1620 const ingest = (appThread: OrchestrationV2AppThread) =>
1621 ingestor.ingestNormalized({
1622 providerSessionId,
1623 providerInstanceId: modelSelection.instanceId,
1624 threadId: rootEvent.threadId,
1625 event: { type: "app_thread.created", driver: CODEX_DRIVER, appThread },
1626 });
1627 yield* eventSink.write({ events: [rootEvent] });
1628 const created = yield* Effect.all([ingest(offered), ingest(offered)], {
1629 concurrency: "unbounded",
1630 });
1631 assert.deepEqual(
1632 created.flat().map((stored) => stored.event.type),
1633 ["thread.created"],
1634 );
1635 const saved = {
1636 ...offered,
1637 archivedAt: now,
1638 deletedAt: now,
1639 activeProviderThreadId: rootEvent.payload.activeProviderThreadId,
1640 branch: "user/checkout",
1641 };
1642 yield* eventSink.write({
1643 events: [
1644 {
1645 ...rootEvent,
1646 id: yield* idAllocator.allocate.event({ threadId: childThreadId }),
1647 threadId: childThreadId,
1648 type: "thread.deleted",
1649 payload: saved,
1650 },
1651 ],
1652 });
1653 for (const metadata of [
1654 { title: "Review checkout", modelSelection },
1655 { title: "Review checkout", modelSelection: { ...modelSelection, model: "gpt-6.1-sol" } },
1656 ]) {
1657 const updated = yield* ingest({ ...offered, ...metadata });
1658 assert.deepEqual(
1659 updated.map((stored) => stored.event.type),
1660 ["thread.metadata-updated"],
1661 );
1662 const thread = yield* projectionStore.getThread(childThreadId);
1663 assert.deepEqual(thread, { ...saved, ...metadata, updatedAt: thread.updatedAt });
1664 }
1665 assert.deepEqual(
1666 yield* ingest({
1667 ...offered,
1668 title: "Review checkout",
1669 modelSelection: { ...modelSelection, model: "gpt-6.1-sol" },
1670 }),
1671 [],
1672 );
1673 }),
1674 );
1675
1592 it.effect("moves a native subagent's thread to the model its provider reports later", () =>1676 it.effect("moves a native subagent's thread to the model its provider reports later", () =>
1593 Effect.gen(function* () {1677 Effect.gen(function* () {
1594 const now = yield* DateTime.now;1678 const now = yield* DateTime.now;
apps/server/src/orchestration-v2/ProviderEventIngestor.ts+31-4
...@@ -535,14 +535,37 @@ export const layer: Layer.Layer<...@@ -535,14 +535,37 @@ export const layer: Layer.Layer<
535 const normalize: ProviderEventIngestorV2Shape["normalize"] = (input) =>535 const normalize: ProviderEventIngestorV2Shape["normalize"] = (input) =>
536 Effect.gen(function* () {536 Effect.gen(function* () {
537 switch (input.event.type) {537 switch (input.event.type) {
538 case "app_thread.created":538 case "app_thread.created": {
539 const offered = input.event.appThread;
540 const existing = yield* projections.getThread(offered.id).pipe(
541 Effect.catchTags({
542 ProjectionStoreThreadNotFoundError: () => Effect.succeed(null),
543 }),
544 );
545 if (
546 existing !== null &&
547 existing.title === offered.title &&
548 modelSelectionsEqual(existing.modelSelection, offered.modelSelection)
549 )
550 return [];
551 const now = yield* DateTime.now;
539 return [552 return [
540 yield* makeDomainEvent(input, {553 yield* makeDomainEvent(input, {
541 type: "thread.created",554 type: existing === null ? "thread.created" : "thread.metadata-updated",
542 threadId: input.event.appThread.id,555 threadId: offered.id,
543 payload: input.event.appThread,556 payload:
557 existing === null
558 ? offered
559 : {
560 ...existing,
561 title: offered.title,
562 modelSelection: offered.modelSelection,
563 updatedAt: now,
564 },
565 occurredAt: now,
544 }),566 }),
545 ];567 ];
568 }
546 case "provider_session.updated":569 case "provider_session.updated":
547 return [570 return [
548 yield* makeDomainEvent(input, {571 yield* makeDomainEvent(input, {
...@@ -795,6 +818,10 @@ export const layer: Layer.Layer<...@@ -795,6 +818,10 @@ export const layer: Layer.Layer<
795 );818 );
796 }),819 }),
797 ),820 ),
821 (effect) =>
822 input.event.type === "app_thread.created"
823 ? threadCommands.withLock(input.event.appThread.id, effect)
824 : effect,
798 ),825 ),
799 });826 });
800 }),827 }),
apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts+91
...@@ -854,6 +854,97 @@ it.effect(...@@ -854,6 +854,97 @@ it.effect(
854 },854 },
855);855);
856856
857it.effect("cancels workflow members and child roots without run ids after restart", () => {
858 const parentThreadId = ThreadId.make("thread_recovery_workflow_members");
859 const childThreadId = ThreadId.make("thread_recovery_workflow_member_child");
860 const memberId = NodeId.make("node_recovery_workflow_member");
861 const childRootId = NodeId.make("node_recovery_workflow_member_root");
862 const providerInstanceId = ProviderInstanceId.make("claudeAgent");
863 const driver = ProviderDriverKind.make("claudeAgent");
864 const member = {
865 id: memberId,
866 threadId: parentThreadId,
867 runId: null,
868 origin: "provider_native",
869 status: "running",
870 driver,
871 providerInstanceId,
872 };
873 const parentProjection = {
874 thread: { id: parentThreadId, providerInstanceId },
875 runs: [],
876 attempts: [],
877 nodes: [
878 { id: memberId, threadId: parentThreadId, runId: null, kind: "subagent", status: "running" },
879 ],
880 subagents: [member],
881 runtimeRequests: [],
882 providerSessions: [],
883 providerThreads: [],
884 providerTurns: [],
885 messages: [],
886 turnItems: [],
887 } as unknown as OrchestrationV2ThreadProjection;
888 const childProjection = {
889 ...parentProjection,
890 thread: { id: childThreadId, providerInstanceId },
891 nodes: [
892 {
893 id: childRootId,
894 threadId: childThreadId,
895 runId: null,
896 kind: "root_turn",
897 status: "running",
898 },
899 ],
900 subagents: [],
901 } as unknown as OrchestrationV2ThreadProjection;
902 const committed: Array<Parameters<EventSink.EventSinkV2["Service"]["commitCommand"]>[0]> = [];
903 const layer = ProviderRuntimeRecovery.layer.pipe(
904 Layer.provide(ServerSettings.layerTest()),
905 Layer.provide(
906 Layer.mergeAll(
907 Layer.mock(ProjectionStore.ProjectionStoreV2)({
908 getRecoveryThreadIds: () => Effect.succeed([parentThreadId, childThreadId]),
909 getRuntimeRecoveryProjection: (threadId) =>
910 Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection),
911 }),
912 Layer.mock(EventSink.EventSinkV2)({
913 commitCommand: (input) => {
914 committed.push(input);
915 return Effect.succeed({ committed: true, cancelledEffectCount: 0 } as never);
916 },
917 }),
918 IdAllocator.layer,
919 Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({
920 runRecoveryOnce: Effect.succeed(false),
921 }),
922 Layer.mock(EffectOutbox.EffectOutboxV2)({
923 reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }),
924 }),
925 ),
926 ),
927 );
928
929 return Effect.gen(function* () {
930 yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).reconcile("startup");
931 assert.deepEqual(
932 committed.flatMap((command) =>
933 command.events.map((event) => [
934 command.threadId,
935 event.type,
936 "status" in event.payload ? event.payload.status : null,
937 ]),
938 ),
939 [
940 [parentThreadId, "subagent.updated", "cancelled"],
941 [parentThreadId, "node.updated", "cancelled"],
942 [childThreadId, "node.updated", "cancelled"],
943 ],
944 );
945 }).pipe(Effect.provide(layer));
946});
947
857it.effect(948it.effect(
858 "clears persisted pendingBackgroundTasks and terminalizes stale background items on settled runs",949 "clears persisted pendingBackgroundTasks and terminalizes stale background items on settled runs",
859 () => {950 () => {
apps/server/src/orchestration-v2/RunExecutionService.test.ts+207-16
...@@ -165,7 +165,7 @@ it.effect("routes shared-runtime events only to their owning root run", () =>...@@ -165,7 +165,7 @@ it.effect("routes shared-runtime events only to their owning root run", () =>
165 }),165 }),
166);166);
167167
168it("leaves a child thread created after the root turn ended to the run that is live then", () => {168it("routes only enrolled workflow members after the root turn ends", () => {
169 const threadId = ThreadId.make("thread:late-child");169 const threadId = ThreadId.make("thread:late-child");
170 const rootProviderTurnId = ProviderTurnId.make("provider-turn:late-child");170 const rootProviderTurnId = ProviderTurnId.make("provider-turn:late-child");
171 const identity: RunExecutionService.ProviderEventRouteIdentity = {171 const identity: RunExecutionService.ProviderEventRouteIdentity = {
...@@ -174,14 +174,17 @@ it("leaves a child thread created after the root turn ended to the run that is l...@@ -174,14 +174,17 @@ it("leaves a child thread created after the root turn ended to the run that is l
174 attemptId: RunAttemptId.make("attempt:late-child"),174 attemptId: RunAttemptId.make("attempt:late-child"),
175 providerThreadId: ProviderThreadId.make("provider-thread:late-child"),175 providerThreadId: ProviderThreadId.make("provider-thread:late-child"),
176 };176 };
177 const childCreated = (childThreadId: ThreadId): ProviderAdapter.ProviderAdapterV2Event =>177 const childCreated = (
178 childThreadId: ThreadId,
179 parentThreadId = threadId,
180 ): ProviderAdapter.ProviderAdapterV2Event =>
178 ({181 ({
179 type: "app_thread.created",182 type: "app_thread.created",
180 driver,183 driver,
181 appThread: {184 appThread: {
182 id: childThreadId,185 id: childThreadId,
183 lineage: {186 lineage: {
184 parentThreadId: threadId,187 parentThreadId,
185 relationshipToParent: "subagent",188 relationshipToParent: "subagent",
186 rootThreadId: threadId,189 rootThreadId: threadId,
187 },190 },
...@@ -200,6 +203,13 @@ it("leaves a child thread created after the root turn ended to the run that is l...@@ -200,6 +203,13 @@ it("leaves a child thread created after the root turn ended to the run that is l
200 initial,203 initial,
201 );204 );
202 assert.isTrue(earlyAccepted);205 assert.isTrue(earlyAccepted);
206 const existingDescendant = ThreadId.make("thread:late-child:existing-descendant");
207 const [descendantAccepted, withDescendant] = RunExecutionService.routeProviderEvent(
208 childCreated(existingDescendant, earlyChild),
209 identity,
210 live,
211 );
212 assert.isTrue(descendantAccepted);
203 const [terminalAccepted, ended] = RunExecutionService.routeProviderEvent(213 const [terminalAccepted, ended] = RunExecutionService.routeProviderEvent(
204 {214 {
205 type: "turn.terminal",215 type: "turn.terminal",
...@@ -212,7 +222,7 @@ it("leaves a child thread created after the root turn ended to the run that is l...@@ -212,7 +222,7 @@ it("leaves a child thread created after the root turn ended to the run that is l
212 threadDisposition: "reusable",222 threadDisposition: "reusable",
213 },223 },
214 identity,224 identity,
215 live,225 withDescendant,
216 );226 );
217 assert.isTrue(terminalAccepted);227 assert.isTrue(terminalAccepted);
218 // A child the root launched before it ended stays with this run.228 // A child the root launched before it ended stays with this run.
...@@ -224,6 +234,92 @@ it("leaves a child thread created after the root turn ended to the run that is l...@@ -224,6 +234,92 @@ it("leaves a child thread created after the root turn ended to the run that is l
224 );234 );
225 assert.isFalse(lateAccepted);235 assert.isFalse(lateAccepted);
226 assert.isFalse(afterLate.ownedThreadIds.has(lateChild));236 assert.isFalse(afterLate.ownedThreadIds.has(lateChild));
237
238 const memberId = ThreadId.make("thread:late-child:workflow-member");
239 assert.isFalse(
240 RunExecutionService.routeProviderEvent(childCreated(memberId, earlyChild), identity, ended)[0],
241 );
242 const coordinator: OrchestrationV2Subagent = {
243 id: NodeId.make("node:late-child:workflow"),
244 threadId,
245 runId: identity.runId,
246 parentNodeId: NodeId.make("node:late-child"),
247 origin: "provider_native",
248 createdBy: "agent",
249 driver,
250 providerInstanceId: ProviderInstanceId.make("codex"),
251 providerThreadId: null,
252 childThreadId: earlyChild,
253 nativeTaskRef: null,
254 prompt: "Review the code",
255 title: "Review",
256 model: null,
257 status: "running",
258 result: null,
259 startedAt: null,
260 completedAt: null,
261 updatedAt: DateTime.makeUnsafe(0),
262 workflow: {
263 phases: [],
264 agents: [{ index: 0, label: "Reviewer", state: "running", childThreadId: memberId }],
265 },
266 };
267 const [foreignAccepted, afterForeign] = RunExecutionService.routeProviderEvent(
268 {
269 type: "subagent.updated",
270 driver,
271 subagent: {
272 ...coordinator,
273 threadId: earlyChild,
274 childThreadId: existingDescendant,
275 runId: RunId.make("run:late-child:new"),
276 },
277 },
278 identity,
279 ended,
280 );
281 assert.isTrue(foreignAccepted);
282 assert.isFalse(
283 RunExecutionService.routeProviderEvent(
284 childCreated(memberId, existingDescendant),
285 identity,
286 afterForeign,
287 )[0],
288 );
289 const [workflowAccepted, afterWorkflow] = RunExecutionService.routeProviderEvent(
290 { type: "subagent.updated", driver, subagent: coordinator },
291 identity,
292 ended,
293 );
294 assert.isTrue(workflowAccepted);
295 assert.isFalse(
296 RunExecutionService.routeProviderEvent(childCreated(memberId), identity, afterWorkflow)[0],
297 );
298 const [memberAccepted, afterMember] = RunExecutionService.routeProviderEvent(
299 childCreated(memberId, earlyChild),
300 identity,
301 afterWorkflow,
302 );
303 assert.isTrue(memberAccepted);
304 assert.isTrue(afterMember.ownedThreadIds.has(memberId));
305 const [unrelatedAccepted] = RunExecutionService.routeProviderEvent(
306 childCreated(ThreadId.make("thread:late-child:unrelated-member"), lateChild),
307 identity,
308 ended,
309 );
310 assert.isFalse(unrelatedAccepted);
311 const [unrelatedDescendantAccepted, afterUnrelatedDescendant] =
312 RunExecutionService.routeProviderEvent(
313 childCreated(ThreadId.make("thread:late-child:unrelated-descendant"), earlyChild),
314 identity,
315 afterWorkflow,
316 );
317 assert.isFalse(unrelatedDescendantAccepted);
318 assert.isFalse(
319 afterUnrelatedDescendant.ownedThreadIds.has(
320 ThreadId.make("thread:late-child:unrelated-descendant"),
321 ),
322 );
227});323});
228324
229it("does not route a superseded attempt through a reused provider thread", () => {325it("does not route a superseded attempt through a reused provider thread", () => {
...@@ -1225,10 +1321,11 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co...@@ -1225,10 +1321,11 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co
1225 }),1321 }),
1226);1322);
12271323
1228it.effect("keeps ingesting owned child events after the root turn terminalizes", () =>1324it.effect("ingests new member threads of an owned child after the root turn terminalizes", () =>
1229 Effect.gen(function* () {1325 Effect.gen(function* () {
1230 const threadId = ThreadId.make("thread:run-execution-late-child");1326 const threadId = ThreadId.make("thread:run-execution-late-child");
1231 const childThreadId = ThreadId.make("thread:run-execution-late-child:child");1327 const childThreadId = ThreadId.make("thread:run-execution-late-child:child");
1328 const memberThreadId = ThreadId.make("thread:run-execution-late-child:member");
1232 const runId = RunId.make("run:run-execution-late-child");1329 const runId = RunId.make("run:run-execution-late-child");
1233 const attemptId = RunAttemptId.make("attempt:run-execution-late-child");1330 const attemptId = RunAttemptId.make("attempt:run-execution-late-child");
1234 const providerInstanceId = ProviderInstanceId.make("codex");1331 const providerInstanceId = ProviderInstanceId.make("codex");
...@@ -1268,9 +1365,15 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes",...@@ -1268,9 +1365,15 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes",
1268 Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({1365 Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({
1269 ingestNormalized: (input) =>1366 ingestNormalized: (input) =>
1270 Effect.gen(function* () {1367 Effect.gen(function* () {
1368 if (
1369 input.event.type === "app_thread.created" &&
1370 input.event.appThread.id === memberThreadId
1371 ) {
1372 yield* Ref.update(order, (current) => [...current, "member-thread"]);
1373 }
1271 if (1374 if (
1272 input.event.type === "message.updated" &&1375 input.event.type === "message.updated" &&
1273 input.event.message.threadId === childThreadId1376 input.event.message.threadId === memberThreadId
1274 ) {1377 ) {
1275 yield* Ref.update(order, (current) => [...current, "child-message"]);1378 yield* Ref.update(order, (current) => [...current, "child-message"]);
1276 yield* Deferred.succeed(childMessageIngested, undefined).pipe(Effect.ignore);1379 yield* Deferred.succeed(childMessageIngested, undefined).pipe(Effect.ignore);
...@@ -1307,12 +1410,21 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes",...@@ -1307,12 +1410,21 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes",
1307 type: "subagent.updated",1410 type: "subagent.updated",
1308 driver,1411 driver,
1309 subagent: {1412 subagent: {
1310 id: subagentNodeId,1413 ...makeRunOwnedSubagentFixture({
1311 threadId,1414 ids: backgroundScenarioIds("run-execution-late-child"),
1312 runId,1415 providerInstanceId,
1313 status: "running",1416 childThreadId,
1417 driver,
1418 status: "running",
1419 }),
1420 workflow: {
1421 phases: [],
1422 agents: [
1423 { index: 0, label: "Reviewer", state: "running", childThreadId: memberThreadId },
1424 ],
1425 },
1314 },1426 },
1315 } as ProviderAdapter.ProviderAdapterV2Event,1427 },
1316 {1428 {
1317 type: "provider_turn.updated",1429 type: "provider_turn.updated",
1318 driver,1430 driver,
...@@ -1335,12 +1447,24 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes",...@@ -1335,12 +1447,24 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes",
1335 failure: null,1447 failure: null,
1336 threadDisposition: "reusable",1448 threadDisposition: "reusable",
1337 },1449 },
1450 {
1451 type: "app_thread.created",
1452 driver,
1453 appThread: {
1454 id: memberThreadId,
1455 lineage: {
1456 parentThreadId: childThreadId,
1457 relationshipToParent: "subagent",
1458 rootThreadId: threadId,
1459 },
1460 },
1461 } as ProviderAdapter.ProviderAdapterV2Event,
1338 {1462 {
1339 type: "message.updated",1463 type: "message.updated",
1340 driver,1464 driver,
1341 message: {1465 message: {
1342 id: MessageId.make("message:run-execution-late-child:child"),1466 id: MessageId.make("message:run-execution-late-child:child"),
1343 threadId: childThreadId,1467 threadId: memberThreadId,
1344 runId: null,1468 runId: null,
1345 nodeId: childNodeId,1469 nodeId: childNodeId,
1346 role: "assistant",1470 role: "assistant",
...@@ -1428,7 +1552,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes",...@@ -1428,7 +1552,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes",
1428 Effect.timeoutOption("2 seconds"),1552 Effect.timeoutOption("2 seconds"),
1429 );1553 );
1430 assert.isTrue(Option.isSome(observed), "child message was not ingested after root terminal");1554 assert.isTrue(Option.isSome(observed), "child message was not ingested after root terminal");
1431 assert.deepEqual(yield* Ref.get(order), ["root-finalized", "child-message"]);1555 assert.deepEqual(yield* Ref.get(order), ["root-finalized", "member-thread", "child-message"]);
1432 }),1556 }),
1433);1557);
14341558
...@@ -2614,11 +2738,42 @@ it.effect(...@@ -2614,11 +2738,42 @@ it.effect(
2614 ids,2738 ids,
2615 status: "running",2739 status: "running",
2616 });2740 });
2741 const memberThreadId = ThreadId.make("thread:subagent-interrupt-cascade:member");
2742 const runningMember = {
2743 ...runningSubagent,
2744 id: NodeId.make("node:subagent-interrupt-cascade:member"),
2745 threadId: childThreadId,
2746 runId: null,
2747 parentNodeId: runningChildNode.id,
2748 childThreadId: memberThreadId,
2749 };
2750 const foreignSubagent = {
2751 ...runningMember,
2752 id: NodeId.make("node:subagent-interrupt-cascade:foreign-subagent"),
2753 runId: RunId.make("run:subagent-interrupt-cascade:foreign"),
2754 childThreadId: unrelatedChildThreadId,
2755 };
2756 const runningMemberRoot = {
2757 ...runningChildNode,
2758 id: NodeId.make("node:subagent-interrupt-cascade:member-root"),
2759 threadId: memberThreadId,
2760 rootNodeId: NodeId.make("node:subagent-interrupt-cascade:member-root"),
2761 };
2762 const foreignChildNode = {
2763 ...runningChildNode,
2764 id: NodeId.make("node:subagent-interrupt-cascade:foreign-child"),
2765 runId: foreignSubagent.runId,
2766 };
2617 const runningChildTurnItem = makeLinkedChildTurnItemFixture({2767 const runningChildTurnItem = makeLinkedChildTurnItemFixture({
2618 ids,2768 ids,
2619 driver,2769 driver,
2620 type: "command_execution",2770 type: "command_execution",
2621 });2771 });
2772 const foreignChildTurnItem = {
2773 ...runningChildTurnItem,
2774 id: TurnItemId.make("turn-item:subagent-interrupt-cascade:foreign-child"),
2775 runId: foreignSubagent.runId,
2776 };
2622 const suppressedChildAssistantTurnItem = makeLinkedChildTurnItemFixture({2777 const suppressedChildAssistantTurnItem = makeLinkedChildTurnItemFixture({
2623 ids: {2778 ids: {
2624 ...ids,2779 ...ids,
...@@ -2692,6 +2847,11 @@ it.effect(...@@ -2692,6 +2847,11 @@ it.effect(
2692 driver,2847 driver,
2693 turnItem: unrelatedChildTurnItem,2848 turnItem: unrelatedChildTurnItem,
2694 },2849 },
2850 { type: "subagent.updated", driver, subagent: runningMember },
2851 { type: "subagent.updated", driver, subagent: foreignSubagent },
2852 { type: "node.updated", driver, node: runningMemberRoot },
2853 { type: "node.updated", driver, node: foreignChildNode },
2854 { type: "turn_item.updated", driver, turnItem: foreignChildTurnItem },
2695 rootTerminalEvent(ids, "interrupted"),2855 rootTerminalEvent(ids, "interrupted"),
2696 // Late provider completion after interrupt must not be ingested.2856 // Late provider completion after interrupt must not be ingested.
2697 {2857 {
...@@ -2758,7 +2918,7 @@ it.effect(...@@ -2758,7 +2918,7 @@ it.effect(
2758 providerTurnId: ids.rootProviderTurnId,2918 providerTurnId: ids.rootProviderTurnId,
2759 } as OrchestrationV2RunAttempt,2919 } as OrchestrationV2RunAttempt,
2760 attemptId: ids.attemptId,2920 attemptId: ids.attemptId,
2761 relatedThreadIds: [unrelatedChildThreadId],2921 relatedThreadIds: [unrelatedChildThreadId, memberThreadId],
2762 providerTurnOrdinal: 1,2922 providerTurnOrdinal: 1,
2763 message: {2923 message: {
2764 messageId: MessageId.make("message:subagent-interrupt-cascade:user"),2924 messageId: MessageId.make("message:subagent-interrupt-cascade:user"),
...@@ -2810,8 +2970,39 @@ it.effect(...@@ -2810,8 +2970,39 @@ it.effect(
2810 const runUpdatedIndex = events.findIndex((event) => event.type === "run.updated");2970 const runUpdatedIndex = events.findIndex((event) => event.type === "run.updated");
2811 assert.isAtLeast(runUpdatedIndex, 0, "root run.updated must be written");2971 assert.isAtLeast(runUpdatedIndex, 0, "root run.updated must be written");
28122972
2813 assert.lengthOf(subagentEvents, 1);2973 assert.lengthOf(subagentEvents, 2);
2814 const terminalSubagent = subagentEvents[0];2974 const terminalSubagent = subagentEvents.find(
2975 (event) => event.payload.id === runningSubagent.id,
2976 );
2977 const terminalMember = subagentEvents.find((event) => event.payload.id === runningMember.id);
2978 assert.isDefined(terminalMember);
2979 assert.equal(terminalMember.payload.status, "interrupted");
2980 assert.equal(terminalMember.payload.runId, null);
2981 assert.equal(terminalMember.payload.childThreadId, memberThreadId);
2982 assert.isTrue(
2983 events.some(
2984 (event) =>
2985 event.type === "node.updated" &&
2986 event.payload.id === runningMemberRoot.id &&
2987 event.payload.status === "interrupted",
2988 ),
2989 );
2990 assert.isFalse(
2991 events.some(
2992 (event) => event.type === "subagent.updated" && event.payload.id === foreignSubagent.id,
2993 ),
2994 );
2995 assert.isFalse(
2996 events.some(
2997 (event) => event.type === "node.updated" && event.payload.id === foreignChildNode.id,
2998 ),
2999 );
3000 assert.isFalse(
3001 events.some(
3002 (event) =>
3003 event.type === "turn-item.updated" && event.payload.id === foreignChildTurnItem.id,
3004 ),
3005 );
2815 assert.isDefined(terminalSubagent);3006 assert.isDefined(terminalSubagent);
2816 assert.equal(terminalSubagent.payload.status, "interrupted");3007 assert.equal(terminalSubagent.payload.status, "interrupted");
2817 assert.equal(terminalSubagent.payload.childThreadId, childThreadId);3008 assert.equal(terminalSubagent.payload.childThreadId, childThreadId);
apps/server/src/orchestration-v2/RunExecutionService.ts+40-7
...@@ -53,8 +53,12 @@ import * as RunFinalizationService from "./RunFinalizationService.ts";...@@ -53,8 +53,12 @@ import * as RunFinalizationService from "./RunFinalizationService.ts";
53export interface ProviderEventRoutingState {53export interface ProviderEventRoutingState {
54 readonly ownedThreadIds: ReadonlySet<ThreadId>;54 readonly ownedThreadIds: ReadonlySet<ThreadId>;
55 // Set once this run's root turn ended. A child thread created after that55 // Set once this run's root turn ended. A child thread created after that
56 // belongs to the run that is live then, so this one no longer adopts it.56 // belongs to the run that is live then, so this one no longer adopts it,
57 // except a workflow member enrolled below: its coordinator keeps this run
58 // open, so the member is this run's to follow.
57 readonly rootTurnEnded: boolean;59 readonly rootTurnEnded: boolean;
60 /** Member thread id to the coordinator child thread that spawned it. */
61 readonly workflowMemberParents: ReadonlyMap<ThreadId, ThreadId>;
58 readonly ownedProviderThreadIds: ReadonlySet<ProviderThreadId>;62 readonly ownedProviderThreadIds: ReadonlySet<ProviderThreadId>;
59 readonly ownedProviderTurnIds: ReadonlySet<ProviderTurnId>;63 readonly ownedProviderTurnIds: ReadonlySet<ProviderTurnId>;
60 readonly inheritedBackgroundTurnItems: ReadonlyMap<TurnItemId, OrchestrationV2Run["id"]>;64 readonly inheritedBackgroundTurnItems: ReadonlyMap<TurnItemId, OrchestrationV2Run["id"]>;
...@@ -334,6 +338,7 @@ export function makeProviderEventRoutingState(input: {...@@ -334,6 +338,7 @@ export function makeProviderEventRoutingState(input: {
334 return {338 return {
335 ownedThreadIds: new Set([input.identity.threadId, ...(input.relatedThreadIds ?? [])]),339 ownedThreadIds: new Set([input.identity.threadId, ...(input.relatedThreadIds ?? [])]),
336 rootTurnEnded: false,340 rootTurnEnded: false,
341 workflowMemberParents: new Map(),
337 ownedProviderThreadIds: new Set([342 ownedProviderThreadIds: new Set([
338 input.identity.providerThreadId,343 input.identity.providerThreadId,
339 ...(input.relatedProviderThreadIds ?? []),344 ...(input.relatedProviderThreadIds ?? []),
...@@ -379,10 +384,12 @@ export function routeProviderEvent(...@@ -379,10 +384,12 @@ export function routeProviderEvent(
379 return [true, state];384 return [true, state];
380 }385 }
381 const isOwnedSubagent =386 const isOwnedSubagent =
382 !state.rootTurnEnded &&
383 event.appThread.lineage.relationshipToParent === "subagent" &&387 event.appThread.lineage.relationshipToParent === "subagent" &&
384 event.appThread.lineage.parentThreadId !== null &&388 event.appThread.lineage.parentThreadId !== null &&
385 ownsThread(event.appThread.lineage.parentThreadId);389 ownsThread(event.appThread.lineage.parentThreadId) &&
390 (!state.rootTurnEnded ||
391 state.workflowMemberParents.get(event.appThread.id) ===
392 event.appThread.lineage.parentThreadId);
386 if (!isOwnedSubagent) {393 if (!isOwnedSubagent) {
387 return [false, state];394 return [false, state];
388 }395 }
...@@ -417,8 +424,24 @@ export function routeProviderEvent(...@@ -417,8 +424,24 @@ export function routeProviderEvent(
417 }424 }
418 return [true, addProviderThread(event.node.providerThreadId)];425 return [true, addProviderThread(event.node.providerThreadId)];
419 }426 }
420 case "subagent.updated":427 case "subagent.updated": {
421 return [ownsRun(event.subagent.runId) || ownsChildThread(event.subagent.threadId), state];428 const belongs = ownsRun(event.subagent.runId) || ownsChildThread(event.subagent.threadId);
429 const coordinator = event.subagent;
430 if (
431 !ownsRun(coordinator.runId) ||
432 coordinator.workflow === undefined ||
433 coordinator.childThreadId === null
434 ) {
435 return [belongs, state];
436 }
437 const workflowMemberParents = new Map(state.workflowMemberParents);
438 for (const member of coordinator.workflow.agents) {
439 if (member.childThreadId !== undefined) {
440 workflowMemberParents.set(member.childThreadId, coordinator.childThreadId);
441 }
442 }
443 return [true, { ...state, workflowMemberParents }];
444 }
422 case "message.updated":445 case "message.updated":
423 return [ownsRun(event.message.runId) || ownsChildThread(event.message.threadId), state];446 return [ownsRun(event.message.runId) || ownsChildThread(event.message.threadId), state];
424 case "turn_item.updated": {447 case "turn_item.updated": {
...@@ -1070,7 +1093,13 @@ export const layer: Layer.Layer<...@@ -1070,7 +1093,13 @@ export const layer: Layer.Layer<
1070 const belongsToOwnedChildThread =1093 const belongsToOwnedChildThread =
1071 event.node.threadId !== input.run.threadId &&1094 event.node.threadId !== input.run.threadId &&
1072 routing.ownedThreadIds.has(event.node.threadId);1095 routing.ownedThreadIds.has(event.node.threadId);
1073 if (!belongsToRootSubagent && !belongsToOwnedChildThread) {1096 if (
1097 !belongsToRootSubagent &&
1098 !(
1099 belongsToOwnedChildThread &&
1100 (event.node.runId === null || event.node.runId === input.run.id)
1101 )
1102 ) {
1074 return;1103 return;
1075 }1104 }
1076 yield* Ref.update(openRunOwnedSubagents, (current) => {1105 yield* Ref.update(openRunOwnedSubagents, (current) => {
...@@ -1108,7 +1137,11 @@ export const layer: Layer.Layer<...@@ -1108,7 +1137,11 @@ export const layer: Layer.Layer<
1108 return next;1137 return next;
1109 });1138 });
1110 }1139 }
1111 if (belongsToOwnedChildThread && deliverable) {1140 if (
1141 belongsToOwnedChildThread &&
1142 (event.turnItem.runId === null || belongsToRootRun) &&
1143 deliverable
1144 ) {
1112 yield* Ref.update(openRunOwnedSubagents, (current) => {1145 yield* Ref.update(openRunOwnedSubagents, (current) => {
1113 const childTurnItems = new Map(current.childTurnItems);1146 const childTurnItems = new Map(current.childTurnItems);
1114 if (isSettledTurnItemStatus(event.turnItem.status)) {1147 if (isSettledTurnItemStatus(event.turnItem.status)) {
apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts+43-28
...@@ -388,7 +388,7 @@ function makeCompletingHandoffAdapter(...@@ -388,7 +388,7 @@ function makeCompletingHandoffAdapter(
388 };388 };
389}389}
390390
391it.live("restarts selection as a new attempt and retries after old-session cleanup", () =>391it.live("retries a failed selection restart session open before completing", () =>
392 Effect.scoped(392 Effect.scoped(
393 Effect.gen(function* () {393 Effect.gen(function* () {
394 const cwd = yield* checkpointWorkspace("selection-restart-lifecycle");394 const cwd = yield* checkpointWorkspace("selection-restart-lifecycle");
...@@ -404,6 +404,9 @@ it.live("restarts selection as a new attempt and retries after old-session clean...@@ -404,6 +404,9 @@ it.live("restarts selection as a new attempt and retries after old-session clean
404404
405 const result = yield* Effect.gen(function* () {405 const result = yield* Effect.gen(function* () {
406 const orchestrator = yield* Orchestrator.OrchestratorV2;406 const orchestrator = yield* Orchestrator.OrchestratorV2;
407 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
408 const eventSink = yield* EventSink.EventSinkV2;
409
407 yield* orchestrator.dispatch({410 yield* orchestrator.dispatch({
408 type: "thread.create",411 type: "thread.create",
409 createdBy: "user",412 createdBy: "user",
...@@ -418,6 +421,21 @@ it.live("restarts selection as a new attempt and retries after old-session clean...@@ -418,6 +421,21 @@ it.live("restarts selection as a new attempt and retries after old-session clean
418 branch: null,421 branch: null,
419 worktreePath: cwd,422 worktreePath: cwd,
420 });423 });
424 const running = yield* eventSink
425 .stream({
426 threadId,
427 afterSequence: yield* eventSink.latestSequence({ threadId }),
428 })
429 .pipe(
430 Stream.map((stored) => stored.event),
431 Stream.filter(
432 (event) =>
433 event.type === "provider-turn.updated" && event.payload.status === "running",
434 ),
435 Stream.take(1),
436 Stream.runDrain,
437 Effect.forkScoped,
438 );
421 yield* orchestrator.dispatch({439 yield* orchestrator.dispatch({
422 type: "message.dispatch",440 type: "message.dispatch",
423 createdBy: "user",441 createdBy: "user",
...@@ -430,11 +448,8 @@ it.live("restarts selection as a new attempt and retries after old-session clean...@@ -430,11 +448,8 @@ it.live("restarts selection as a new attempt and retries after old-session clean
430 modelSelection: initialSelection,448 modelSelection: initialSelection,
431 dispatchMode: { type: "start_immediately" },449 dispatchMode: { type: "start_immediately" },
432 });450 });
433 for (let index = 0; index < 1_000; index += 1) {451 yield* Fiber.join(running);
434 const current = yield* orchestrator.getThreadProjection(threadId);452 yield* worker.drain();
435 if (current.providerTurns.some((turn) => turn.status === "running")) break;
436 yield* Effect.sleep("5 millis");
437 }
438 const activeProjection = yield* orchestrator.getThreadProjection(threadId);453 const activeProjection = yield* orchestrator.getThreadProjection(threadId);
439 assert.isTrue(activeProjection.providerTurns.some((turn) => turn.status === "running"));454 assert.isTrue(activeProjection.providerTurns.some((turn) => turn.status === "running"));
440 const activeRunId = activeProjection.runs[0]?.id;455 const activeRunId = activeProjection.runs[0]?.id;
...@@ -442,6 +457,20 @@ it.live("restarts selection as a new attempt and retries after old-session clean...@@ -442,6 +457,20 @@ it.live("restarts selection as a new attempt and retries after old-session clean
442 return yield* Effect.die("active restart test run is missing");457 return yield* Effect.die("active restart test run is missing");
443 }458 }
444459
460 const completed = yield* eventSink
461 .stream({
462 threadId,
463 afterSequence: yield* eventSink.latestSequence({ threadId }),
464 })
465 .pipe(
466 Stream.map((stored) => stored.event),
467 Stream.filter(
468 (event) => event.type === "run.updated" && event.payload.status === "completed",
469 ),
470 Stream.take(1),
471 Stream.runDrain,
472 Effect.forkScoped,
473 );
445 yield* orchestrator.dispatch({474 yield* orchestrator.dispatch({
446 type: "message.dispatch",475 type: "message.dispatch",
447 createdBy: "user",476 createdBy: "user",
...@@ -454,27 +483,12 @@ it.live("restarts selection as a new attempt and retries after old-session clean...@@ -454,27 +483,12 @@ it.live("restarts selection as a new attempt and retries after old-session clean
454 modelSelection: replacementSelection,483 modelSelection: replacementSelection,
455 dispatchMode: { type: "restart_active", targetRunId: activeRunId },484 dispatchMode: { type: "restart_active", targetRunId: activeRunId },
456 });485 });
457 for (let index = 0; index < 1_000; index += 1) {486 yield* Fiber.join(completed);
458 const current = yield* orchestrator.getThreadProjection(threadId);487 yield* worker.drain();
459 if (current.attempts.length === 2 && current.attempts[1]?.status === "completed") {488 return {
460 const captured = yield* Ref.get(state);489 projection: yield* orchestrator.getThreadProjection(threadId),
461 return { projection: current, captured };490 captured: yield* Ref.get(state),
462 }491 };
463 yield* Effect.sleep("5 millis");
464 }
465 const current = yield* orchestrator.getThreadProjection(threadId);
466 const adapterState = yield* Ref.get(state);
467 yield* Effect.logError("selection restart did not complete", {
468 runs: current.runs.map((run) => [run.status, run.activeAttemptId]),
469 attempts: current.attempts.map((attempt) => [attempt.id, attempt.status]),
470 providerTurns: current.providerTurns.map((turn) => [turn.id, turn.status]),
471 providerThreads: current.providerThreads.map((thread) => [
472 thread.providerSessionId,
473 thread.status,
474 ]),
475 adapterState,
476 });
477 return yield* Effect.die("selection restart did not complete");
478 }).pipe(492 }).pipe(
479 Effect.provide(493 Effect.provide(
480 ProviderReplayHarness.layerWithRegistry(494 ProviderReplayHarness.layerWithRegistry(
...@@ -501,7 +515,8 @@ it.live("restarts selection as a new attempt and retries after old-session clean...@@ -501,7 +515,8 @@ it.live("restarts selection as a new attempt and retries after old-session clean
501 ),515 ),
502 "selection restart supersede must not project hard-Stop interrupt items",516 "selection restart supersede must not project hard-Stop interrupt items",
503 );517 );
504 assert.equal(projection.runs[0]?.modelSelection.model, replacementSelection.model);518 assert.equal(projection.runs.at(-1)?.modelSelection.model, replacementSelection.model);
519 assert.equal(projection.runs.at(-1)?.status, "completed");
505 assert.isTrue(captured.failedReplacementOpen);520 assert.isTrue(captured.failedReplacementOpen);
506 // The old pooled process remains available to its other threads; this521 // The old pooled process remains available to its other threads; this
507 // thread moved to a freshly allocated replacement session.522 // thread moved to a freshly allocated replacement session.
apps/server/src/orchestration-v2/ThreadManagementService.test.ts+235
...@@ -5,25 +5,38 @@ import {...@@ -5,25 +5,38 @@ import {
5 NodeId,5 NodeId,
6 type OrchestrationV2Command,6 type OrchestrationV2Command,
7 type OrchestrationV2Run,7 type OrchestrationV2Run,
8 type OrchestrationV2Subagent,
9 type OrchestrationV2ProviderThread,
10 ProviderThreadId,
11 ProviderDriverKind,
12 ProviderSessionId,
8 type OrchestrationV2StoredEvent,13 type OrchestrationV2StoredEvent,
9 type OrchestrationV2ThreadProjection,14 type OrchestrationV2ThreadProjection,
15 type OrchestrationV2ThreadShell,
10 ProjectId,16 ProjectId,
11 ProviderInstanceId,17 ProviderInstanceId,
12 RunId,18 RunId,
13 ThreadId,19 ThreadId,
14} from "@t3tools/contracts";20} from "@t3tools/contracts";
15import * as Deferred from "effect/Deferred";21import * as Deferred from "effect/Deferred";
22import * as DateTime from "effect/DateTime";
23import * as Option from "effect/Option";
16import * as Duration from "effect/Duration";24import * as Duration from "effect/Duration";
17import * as Effect from "effect/Effect";25import * as Effect from "effect/Effect";
18import * as Fiber from "effect/Fiber";26import * as Fiber from "effect/Fiber";
19import * as Layer from "effect/Layer";27import * as Layer from "effect/Layer";
20import * as Queue from "effect/Queue";28import * as Queue from "effect/Queue";
29import * as Ref from "effect/Ref";
21import * as Stream from "effect/Stream";30import * as Stream from "effect/Stream";
22import * as TestClock from "effect/testing/TestClock";31import * as TestClock from "effect/testing/TestClock";
2332
24import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";33import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";
25import * as Orchestrator from "./Orchestrator.ts";34import * as Orchestrator from "./Orchestrator.ts";
35import * as ProviderSessionManager from "./ProviderSessionManager.ts";
36import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
26import * as ThreadManagementService from "./ThreadManagementService.ts";37import * as ThreadManagementService from "./ThreadManagementService.ts";
38import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
39import { DispatchModeLimit, type DispatchModeRefusal } from "./DispatchModeLimit.ts";
2740
28it("stamps authoritative provenance on commands that create threads or messages", () => {41it("stamps authoritative provenance on commands that create threads or messages", () => {
29 const command: OrchestrationV2Command = {42 const command: OrchestrationV2Command = {
...@@ -269,6 +282,7 @@ it.effect("classifies projection infrastructure failures separately from a missi...@@ -269,6 +282,7 @@ it.effect("classifies projection infrastructure failures separately from a missi
269 cause: infrastructureCause,282 cause: infrastructureCause,
270 });283 });
271 const layerTest = ThreadManagementService.layer.pipe(284 const layerTest = ThreadManagementService.layer.pipe(
285 Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
272 Layer.provide(286 Layer.provide(
273 Layer.mock(Orchestrator.OrchestratorV2)({287 Layer.mock(Orchestrator.OrchestratorV2)({
274 getThreadProjection: () => Effect.fail(projectionError),288 getThreadProjection: () => Effect.fail(projectionError),
...@@ -302,6 +316,7 @@ it.effect("uses thread-not-found only after a projection loads outside the proje...@@ -302,6 +316,7 @@ it.effect("uses thread-not-found only after a projection loads outside the proje
302 },316 },
303 } as OrchestrationV2ThreadProjection;317 } as OrchestrationV2ThreadProjection;
304 const layerTest = ThreadManagementService.layer.pipe(318 const layerTest = ThreadManagementService.layer.pipe(
319 Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
305 Layer.provide(320 Layer.provide(
306 Layer.mock(Orchestrator.OrchestratorV2)({321 Layer.mock(Orchestrator.OrchestratorV2)({
307 getThreadProjection: () => Effect.succeed(projection),322 getThreadProjection: () => Effect.succeed(projection),
...@@ -327,6 +342,7 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte...@@ -327,6 +342,7 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte
327 cause: new Error("checkpoint import failed"),342 cause: new Error("checkpoint import failed"),
328 });343 });
329 const layerTest = ThreadManagementService.layerWithLegacyImporter.pipe(344 const layerTest = ThreadManagementService.layerWithLegacyImporter.pipe(
345 Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
330 Layer.provide(346 Layer.provide(
331 Layer.mergeAll(347 Layer.mergeAll(
332 Layer.mock(Orchestrator.OrchestratorV2)({348 Layer.mock(Orchestrator.OrchestratorV2)({
...@@ -368,6 +384,7 @@ it.effect.each([...@@ -368,6 +384,7 @@ it.effect.each([
368 runs: status === "missing" ? [] : [{ id: runId, status }],384 runs: status === "missing" ? [] : [{ id: runId, status }],
369 }) as unknown as OrchestrationV2ThreadProjection;385 }) as unknown as OrchestrationV2ThreadProjection;
370 const layerTest = ThreadManagementService.layer.pipe(386 const layerTest = ThreadManagementService.layer.pipe(
387 Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
371 Layer.provide(388 Layer.provide(
372 Layer.mock(Orchestrator.OrchestratorV2)({389 Layer.mock(Orchestrator.OrchestratorV2)({
373 getThreadEventSequence: () => Effect.succeed(0),390 getThreadEventSequence: () => Effect.succeed(0),
...@@ -434,6 +451,7 @@ it.effect("waitForThread reads the run again only when the run updates", () =>...@@ -434,6 +451,7 @@ it.effect("waitForThread reads the run again only when the run updates", () =>
434 const stored = (sequence: number, event: object) =>451 const stored = (sequence: number, event: object) =>
435 ({ sequence, event: { threadId, ...event } }) as unknown as OrchestrationV2StoredEvent;452 ({ sequence, event: { threadId, ...event } }) as unknown as OrchestrationV2StoredEvent;
436 const layerTest = ThreadManagementService.layer.pipe(453 const layerTest = ThreadManagementService.layer.pipe(
454 Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
437 Layer.provide(455 Layer.provide(
438 Layer.mock(Orchestrator.OrchestratorV2)({456 Layer.mock(Orchestrator.OrchestratorV2)({
439 getThreadEventSequence: () => Effect.succeed(0),457 getThreadEventSequence: () => Effect.succeed(0),
...@@ -474,6 +492,222 @@ it.effect("waitForThread reads the run again only when the run updates", () =>...@@ -474,6 +492,222 @@ it.effect("waitForThread reads the run again only when the run updates", () =>
474 }),492 }),
475);493);
476494
495it.effect.each([
496 "running",
497 "missing",
498 "completed",
499 "unsupported",
500 "missing-native-id",
501 "wrong-thread",
502 "no-session",
503 "unsupported-runtime",
504 "provider-failed",
505 "provider-timeout",
506 "within-limit",
507 "runtime-raised",
508 "interaction-raised",
509] as const)("stops a persisted workflow with state %s", (state) =>
510 Effect.gen(function* () {
511 const threadId = ThreadId.make("thread:workflow-stop");
512 const subagentId = NodeId.make("node:workflow-stop");
513 const runId = RunId.make("run:workflow-stop");
514 const providerThreadId = ProviderThreadId.make("provider-thread:workflow-stop");
515 const providerSessionId = ProviderSessionId.make("provider-session:workflow-stop");
516 const driver = ProviderDriverKind.make("claudeAgent");
517 const instanceId = ProviderInstanceId.make("claudeAgent");
518 const now = yield* DateTime.now;
519 let targetModes: Pick<OrchestrationV2ThreadShell, "runtimeMode" | "interactionMode"> = {
520 runtimeMode: "approval-required",
521 interactionMode: "plan",
522 };
523 const task: OrchestrationV2Subagent = {
524 id: subagentId,
525 threadId,
526 runId,
527 parentNodeId: NodeId.make("node:workflow-parent"),
528 origin: "provider_native",
529 createdBy: "agent",
530 driver,
531 providerInstanceId: instanceId,
532 providerThreadId: null,
533 childThreadId: null,
534 nativeTaskRef:
535 state === "missing-native-id"
536 ? null
537 : {
538 driver,
539 nativeId: "native-workflow-id",
540 strength: "strong",
541 },
542 workflow: { phases: [], agents: [] },
543 prompt: "Run the workflow",
544 title: null,
545 model: null,
546 status: state === "completed" ? "completed" : "running",
547 result: null,
548 startedAt: now,
549 completedAt: null,
550 updatedAt: now,
551 ...(state === "unsupported" ? { driver: ProviderDriverKind.make("codex") } : {}),
552 };
553 const providerThread: OrchestrationV2ProviderThread = {
554 id: providerThreadId,
555 driver,
556 providerInstanceId: instanceId,
557 providerSessionId,
558 appThreadId: state === "wrong-thread" ? ThreadId.make("other-thread") : threadId,
559 ownerNodeId: null,
560 nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" },
561 nativeConversationHeadRef: null,
562 status: "idle",
563 firstRunOrdinal: 1,
564 lastRunOrdinal: 1,
565 handoffIds: [],
566 forkedFrom: null,
567 pendingBackgroundTasks: [],
568 contextUsage: null,
569 nativeMetadata: null,
570 createdAt: now,
571 updatedAt: now,
572 };
573 const calls: Array<{ taskId: string; providerThread: OrchestrationV2ProviderThread }> = [];
574 const stopStarted = yield* Deferred.make<void>();
575 const runtime = {
576 driver,
577 providerSessionId,
578 instanceId,
579 ...(state === "unsupported-runtime"
580 ? {}
581 : {
582 stopTask: (input: (typeof calls)[number]) =>
583 state === "provider-failed"
584 ? Effect.fail(
585 new ProviderAdapter.ProviderAdapterProtocolError({
586 driver,
587 detail: "private provider detail",
588 }),
589 )
590 : state === "provider-timeout"
591 ? Deferred.succeed(stopStarted, undefined).pipe(Effect.andThen(Effect.never))
592 : Effect.sync(() => {
593 calls.push(input);
594 }),
595 }),
596 } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime;
597 const layerTest = ThreadManagementService.layer.pipe(
598 Layer.provide(
599 Layer.mergeAll(
600 Layer.mock(Orchestrator.OrchestratorV2)({
601 getThreadRecords: () =>
602 Effect.sync(
603 () =>
604 ({
605 thread: { id: threadId, deletedAt: null, ...targetModes },
606 subagents: state === "missing" ? [] : [task],
607 runs: [{ id: runId, providerThreadId }],
608 providerThreads: [providerThread],
609 }) as unknown as OrchestrationV2ThreadProjection,
610 ),
611 }),
612 Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({
613 get: (id) => {
614 expect(id).toBe(providerSessionId);
615 return Effect.succeed(state === "no-session" ? Option.none() : Option.some(runtime));
616 },
617 }),
618 ),
619 ),
620 );
621 yield* Effect.gen(function* () {
622 const service = yield* ThreadManagementService.ThreadManagementService;
623 const refused = yield* Ref.make<DispatchModeRefusal | undefined>(undefined);
624 const stop = service
625 .stopWorkflow({ threadId, subagentId })
626 .pipe(
627 Effect.provideService(
628 DispatchModeLimit,
629 state === "within-limit" || state === "runtime-raised" || state === "interaction-raised"
630 ? { runtimeMode: "approval-required", interactionMode: "plan", refused }
631 : undefined,
632 ),
633 Effect.result,
634 );
635 const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor;
636 const result =
637 state === "runtime-raised" || state === "interaction-raised"
638 ? yield* Effect.gen(function* () {
639 const started = yield* Deferred.make<void>();
640 const pending = yield* executor.withLock(
641 threadId,
642 Effect.gen(function* () {
643 const fiber = yield* Deferred.succeed(started, undefined).pipe(
644 Effect.andThen(stop),
645 Effect.forkChild,
646 );
647 yield* Deferred.await(started);
648 targetModes =
649 state === "runtime-raised"
650 ? { ...targetModes, runtimeMode: "full-access" }
651 : { ...targetModes, interactionMode: "default" };
652 return fiber;
653 }),
654 );
655 return yield* Fiber.join(pending);
656 })
657 : state === "provider-timeout"
658 ? yield* Effect.gen(function* () {
659 const finished = yield* Ref.make(false);
660 const pending = yield* stop.pipe(
661 Effect.tap(() => Ref.set(finished, true)),
662 Effect.forkChild,
663 );
664 yield* Deferred.await(stopStarted);
665 yield* TestClock.adjust("15 seconds");
666 expect(yield* Ref.get(finished)).toBe(true);
667 const result = yield* Fiber.join(pending);
668 expect(yield* executor.withLock(threadId, Effect.succeed("lock-released"))).toBe(
669 "lock-released",
670 );
671 return result;
672 })
673 : yield* stop;
674 if (state === "running" || state === "within-limit") {
675 expect(result).toMatchObject({ _tag: "Success" });
676 expect(calls).toEqual([{ taskId: "native-workflow-id", providerThread }]);
677 expect(task.status).toBe("running");
678 } else {
679 expect(result).toMatchObject({
680 _tag: "Failure",
681 failure: {
682 _tag: "OrchestrationV2StopWorkflowError",
683 threadId,
684 subagentId,
685 reason:
686 state === "missing" || state === "completed"
687 ? "not-running"
688 : state === "unsupported" || state === "unsupported-runtime"
689 ? "unsupported"
690 : state === "provider-failed" || state === "provider-timeout"
691 ? "stop-failed"
692 : "unavailable",
693 },
694 });
695 expect(calls).toEqual([]);
696 if (state === "provider-timeout") {
697 expect(result).toMatchObject({ failure: { cause: { _tag: "TimeoutError" } } });
698 }
699 if (state === "runtime-raised" || state === "interaction-raised") {
700 expect(yield* Ref.get(refused)).toEqual({
701 threadId,
702 ...targetModes,
703 mode: state === "runtime-raised" ? "runtime" : "interaction",
704 });
705 }
706 }
707 }).pipe(Effect.provide(Layer.merge(layerTest, ThreadCommandExecutor.layer)));
708 }),
709);
710
477it.effect.each([711it.effect.each([
478 { status: "completed" as const, settles: true },712 { status: "completed" as const, settles: true },
479 { status: "failed" as const, settles: false },713 { status: "failed" as const, settles: false },
...@@ -485,6 +719,7 @@ it.effect.each([...@@ -485,6 +719,7 @@ it.effect.each([
485 const runId = RunId.make("run:thread-management:settle-after-run");719 const runId = RunId.make("run:thread-management:settle-after-run");
486 const dispatched: Array<string> = [];720 const dispatched: Array<string> = [];
487 const layerTest = ThreadManagementService.layer.pipe(721 const layerTest = ThreadManagementService.layer.pipe(
722 Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
488 Layer.provide(723 Layer.provide(
489 Layer.mock(Orchestrator.OrchestratorV2)({724 Layer.mock(Orchestrator.OrchestratorV2)({
490 getThreadEventSequence: () => Effect.succeed(0),725 getThreadEventSequence: () => Effect.succeed(0),
apps/server/src/orchestration-v2/ThreadManagementService.ts+112-4
...@@ -11,6 +11,8 @@ import {...@@ -11,6 +11,8 @@ import {
11 type ChatAttachment,11 type ChatAttachment,
12 CommandId,12 CommandId,
13 MessageId,13 MessageId,
14 OrchestrationV2StopWorkflowError,
15 type OrchestrationV2StopWorkflowInput,
14 type ModelSelection,16 type ModelSelection,
15 type OrchestrationV2Actor,17 type OrchestrationV2Actor,
16 type OrchestrationV2Command,18 type OrchestrationV2Command,
...@@ -35,10 +37,14 @@ import * as Duration from "effect/Duration";...@@ -35,10 +37,14 @@ import * as Duration from "effect/Duration";
35import * as Effect from "effect/Effect";37import * as Effect from "effect/Effect";
36import * as Layer from "effect/Layer";38import * as Layer from "effect/Layer";
37import * as Option from "effect/Option";39import * as Option from "effect/Option";
40import * as Ref from "effect/Ref";
38import * as Schema from "effect/Schema";41import * as Schema from "effect/Schema";
39import * as Stream from "effect/Stream";42import * as Stream from "effect/Stream";
4043
41import * as Orchestrator from "./Orchestrator.ts";44import * as Orchestrator from "./Orchestrator.ts";
45import * as ProviderSessionManager from "./ProviderSessionManager.ts";
46import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
47import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts";
42import { projectTurnItemForDetail } from "./WireProjection.ts";48import { projectTurnItemForDetail } from "./WireProjection.ts";
43import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";49import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";
4450
...@@ -364,6 +370,13 @@ export interface ThreadManagementServiceShape {...@@ -364,6 +370,13 @@ export interface ThreadManagementServiceShape {
364 readonly interruptThread: (370 readonly interruptThread: (
365 input: ThreadManagementInterruptInput,371 input: ThreadManagementInterruptInput,
366 ) => Effect.Effect<ThreadManagementInterruptResult, ThreadManagementFailure>;372 ) => Effect.Effect<ThreadManagementInterruptResult, ThreadManagementFailure>;
373 /**
374 * Asks the provider to stop a running workflow coordinator. The provider's
375 * own terminal notification then settles the coordinator and its members.
376 */
377 readonly stopWorkflow: (
378 input: OrchestrationV2StopWorkflowInput,
379 ) => Effect.Effect<void, OrchestrationV2StopWorkflowError>;
367 /**380 /**
368 * Sends `thread.stop` to every delegated task under a thread, depth first. Their command IDs381 * Sends `thread.stop` to every delegated task under a thread, depth first. Their command IDs
369 * derive from `commandId`, so a retry repeats nothing that already stopped. A task that382 * derive from `commandId`, so a retry repeats nothing that already stopped. A task that
...@@ -440,7 +453,9 @@ function latestSteerableRun(...@@ -440,7 +453,9 @@ function latestSteerableRun(
440const SETTLE_AFTER_RUN_WAIT_MS = 24 * 60 * 60 * 1_000;453const SETTLE_AFTER_RUN_WAIT_MS = 24 * 60 * 60 * 1_000;
441454
442const make = Effect.gen(function* () {455const make = Effect.gen(function* () {
456 const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor;
443 const orchestrator = yield* Orchestrator.OrchestratorV2;457 const orchestrator = yield* Orchestrator.OrchestratorV2;
458 const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2;
444 const layerScope = yield* Effect.scope;459 const layerScope = yield* Effect.scope;
445 const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter;460 const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter;
446461
...@@ -841,6 +856,90 @@ const make = Effect.gen(function* () {...@@ -841,6 +856,90 @@ const make = Effect.gen(function* () {
841 return { type: "interrupt_requested", run: interruptibleRun, dispatch } as const;856 return { type: "interrupt_requested", run: interruptibleRun, dispatch } as const;
842 });857 });
843858
859 const stopWorkflow: ThreadManagementServiceShape["stopWorkflow"] = (input) =>
860 Effect.gen(function* () {
861 const records = yield* orchestrator
862 .getThreadRecords(input.threadId, ["subagents", "runs", "providerThreads"])
863 .pipe(
864 Effect.mapError(
865 (cause) =>
866 new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable", cause }),
867 ),
868 );
869 if (records.thread.deletedAt !== null) {
870 return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" });
871 }
872 // A limited caller (an agent over MCP) may not stop work on a thread
873 // running above its own modes. Checked under the thread lock, where the
874 // thread's user cannot raise the modes between this read and the stop.
875 const limit = yield* DispatchModeLimit;
876 const exceeded =
877 limit === undefined ? undefined : exceededDispatchModeLimit(limit, records.thread);
878 if (limit !== undefined && exceeded !== undefined) {
879 if (limit.refused !== undefined) {
880 yield* Ref.set(limit.refused, {
881 threadId: input.threadId,
882 runtimeMode: records.thread.runtimeMode,
883 interactionMode: records.thread.interactionMode,
884 mode: exceeded,
885 });
886 }
887 return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" });
888 }
889 const task = records.subagents.find((candidate) => candidate.id === input.subagentId);
890 if (
891 task === undefined ||
892 task.threadId !== input.threadId ||
893 task.workflow === undefined ||
894 task.status !== "running"
895 ) {
896 return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "not-running" });
897 }
898 if (task.origin !== "provider_native" || task.driver !== "claudeAgent") {
899 return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unsupported" });
900 }
901 const providerThreadId =
902 task.providerThreadId ??
903 records.runs.find((run) => run.id === task.runId)?.providerThreadId;
904 const providerThread = records.providerThreads.find(
905 (candidate) =>
906 candidate.id === providerThreadId &&
907 candidate.appThreadId === input.threadId &&
908 candidate.providerInstanceId === task.providerInstanceId &&
909 candidate.driver === task.driver,
910 );
911 const taskId = task.nativeTaskRef?.nativeId;
912 if (providerThread?.providerSessionId == null || taskId == null || taskId.length === 0) {
913 return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" });
914 }
915 const runtime = yield* sessions
916 .get(providerThread.providerSessionId)
917 .pipe(
918 Effect.mapError(
919 (cause) =>
920 new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable", cause }),
921 ),
922 );
923 if (Option.isNone(runtime)) {
924 return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" });
925 }
926 if (runtime.value.stopTask === undefined) {
927 return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unsupported" });
928 }
929 // Bounded so a provider that never answers cannot hold the thread lock.
930 yield* runtime.value.stopTask({ providerThread, taskId }).pipe(
931 Effect.timeout("15 seconds"),
932 Effect.mapError(
933 (cause) =>
934 new OrchestrationV2StopWorkflowError({
935 ...input,
936 reason: "stop-failed",
937 cause,
938 }),
939 ),
940 );
941 }).pipe((effect) => threadDispatch.withLock(input.threadId, effect));
942
844 const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = (input) =>943 const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = (input) =>
845 Effect.gen(function* () {944 Effect.gen(function* () {
846 const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]);945 const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]);
...@@ -919,6 +1018,7 @@ const make = Effect.gen(function* () {...@@ -919,6 +1018,7 @@ const make = Effect.gen(function* () {
919 settleThread,1018 settleThread,
920 interruptThread,1019 interruptThread,
921 stopDelegatedTasks,1020 stopDelegatedTasks,
1021 stopWorkflow,
922 getThreadEventSequence: orchestrator.getThreadEventSequence,1022 getThreadEventSequence: orchestrator.getThreadEventSequence,
923 recoverDelegatedTask: orchestrator.recoverDelegatedTask,1023 recoverDelegatedTask: orchestrator.recoverDelegatedTask,
924 delegatedTaskResultPending: orchestrator.delegatedTaskResultPending,1024 delegatedTaskResultPending: orchestrator.delegatedTaskResultPending,
...@@ -938,11 +1038,19 @@ const layerLegacyV1ThreadImporterNoop = Layer.succeed(...@@ -938,11 +1038,19 @@ const layerLegacyV1ThreadImporterNoop = Layer.succeed(
938 }),1038 }),
939);1039);
9401040
941export const layer: Layer.Layer<ThreadManagementService, never, Orchestrator.OrchestratorV2> =1041export const layer: Layer.Layer<
942 Layer.effect(ThreadManagementService, make).pipe(Layer.provide(layerLegacyV1ThreadImporterNoop));1042 ThreadManagementService,
1043 never,
1044 Orchestrator.OrchestratorV2 | ProviderSessionManager.ProviderSessionManagerV2
1045> = Layer.effect(ThreadManagementService, make).pipe(
1046 Layer.provide(layerLegacyV1ThreadImporterNoop),
1047 Layer.provide(ThreadCommandExecutor.layer),
1048);
9431049
944export const layerWithLegacyImporter: Layer.Layer<1050export const layerWithLegacyImporter: Layer.Layer<
945 ThreadManagementService,1051 ThreadManagementService,
946 never,1052 never,
947 LegacyV1ThreadImporter.LegacyV1ThreadImporter | Orchestrator.OrchestratorV21053 | LegacyV1ThreadImporter.LegacyV1ThreadImporter
948> = Layer.effect(ThreadManagementService, make);1054 | Orchestrator.OrchestratorV2
1055 | ProviderSessionManager.ProviderSessionManagerV2
1056> = Layer.effect(ThreadManagementService, make).pipe(Layer.provide(ThreadCommandExecutor.layer));
apps/server/src/orchestration-v2/runtimeLayer.ts+7-1
...@@ -240,7 +240,13 @@ const layerAgentSessionImporterProvided = AgentSessionImporter.layer.pipe(...@@ -240,7 +240,13 @@ const layerAgentSessionImporterProvided = AgentSessionImporter.layer.pipe(
240);240);
241241
242const layerThreadManagementProvided = ThreadManagementService.layerWithLegacyImporter.pipe(242const layerThreadManagementProvided = ThreadManagementService.layerWithLegacyImporter.pipe(
243 Layer.provide(Layer.merge(layerOrchestratorProvided, layerLegacyV1ThreadImporterProvided)),243 Layer.provide(
244 Layer.mergeAll(
245 layerOrchestratorProvided,
246 layerLegacyV1ThreadImporterProvided,
247 layerProviderSessionManagerProvided,
248 ),
249 ),
244);250);
245export const layerProjectSetupScriptRunner = ProjectSetupScriptRunner.layer.pipe(251export const layerProjectSetupScriptRunner = ProjectSetupScriptRunner.layer.pipe(
246 Layer.provide(layerProjectService),252 Layer.provide(layerProjectService),
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+4-1
...@@ -242,7 +242,10 @@ export function layerProviderReplay<Transcript extends ProviderReplayTranscript,...@@ -242,7 +242,10 @@ export function layerProviderReplay<Transcript extends ProviderReplayTranscript,
242 readonly replayGate?: ProviderReplayGate;242 readonly replayGate?: ProviderReplayGate;
243 } = {},243 } = {},
244): Layer.Layer<244): Layer.Layer<
245 Orchestrator.OrchestratorV2 | EffectWorker.OrchestrationEffectWorkerV2 | EventSink.EventSinkV2,245 | Orchestrator.OrchestratorV2
246 | EffectWorker.OrchestrationEffectWorkerV2
247 | EventSink.EventSinkV2
248 | ProviderSessionManager.ProviderSessionManagerV2,
246 Error | MigrationError | PlatformError.PlatformError | SqlError249 Error | MigrationError | PlatformError.PlatformError | SqlError
247> {250> {
248 const layerRegistry = harness.makeProviderAdapterRegistryLayer(251 const layerRegistry = harness.makeProviderAdapterRegistryLayer(
apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_output.ts+14
...@@ -59,6 +59,20 @@ export function assertSubagentV2ApprovalOutput(...@@ -59,6 +59,20 @@ export function assertSubagentV2ApprovalOutput(
59 const approval = approvalItems[0]!;59 const approval = approvalItems[0]!;
60 assert.equal(approval.runId, run.id);60 assert.equal(approval.runId, run.id);
61 assert.equal(approval.status, "completed");61 assert.equal(approval.status, "completed");
62 const pendingRequestIndex = result.domainEvents.findIndex(
63 (event) =>
64 event.type === "runtime-request.updated" &&
65 event.payload.id === request.id &&
66 event.payload.status === "pending",
67 );
68 const waitingItemIndex = result.domainEvents.findIndex(
69 (event) =>
70 event.type === "turn-item.updated" &&
71 event.payload.id === approval.id &&
72 event.payload.status === "waiting",
73 );
74 assert.isAtLeast(waitingItemIndex, 0);
75 assert.isAbove(pendingRequestIndex, waitingItemIndex);
62 if (approval.type !== "approval_request") throw new Error("expected an approval item");76 if (approval.type !== "approval_request") throw new Error("expected an approval item");
63 assert.equal(approval.requestId, request.id);77 assert.equal(approval.requestId, request.id);
64 assert.include(approval.prompt ?? "", "subagent-approval.txt");78 assert.include(approval.prompt ?? "", "subagent-approval.txt");
apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_output.ts+14
...@@ -72,6 +72,20 @@ export function assertSubagentV2NestedApprovalOutput(...@@ -72,6 +72,20 @@ export function assertSubagentV2NestedApprovalOutput(
72 const approval = approvalItems[0]!;72 const approval = approvalItems[0]!;
73 assert.equal(approval.runId, run.id);73 assert.equal(approval.runId, run.id);
74 assert.equal(approval.status, "completed");74 assert.equal(approval.status, "completed");
75 const pendingRequestIndex = result.domainEvents.findIndex(
76 (event) =>
77 event.type === "runtime-request.updated" &&
78 event.payload.id === request.id &&
79 event.payload.status === "pending",
80 );
81 const waitingItemIndex = result.domainEvents.findIndex(
82 (event) =>
83 event.type === "turn-item.updated" &&
84 event.payload.id === approval.id &&
85 event.payload.status === "waiting",
86 );
87 assert.isAtLeast(waitingItemIndex, 0);
88 assert.isAbove(pendingRequestIndex, waitingItemIndex);
75 if (approval.type !== "approval_request") throw new Error("expected an approval item");89 if (approval.type !== "approval_request") throw new Error("expected an approval item");
76 assert.equal(approval.requestId, request.id);90 assert.equal(approval.requestId, request.id);
77 assert.include(approval.prompt ?? "", "nested-approval.txt");91 assert.include(approval.prompt ?? "", "nested-approval.txt");
apps/server/src/orchestration-v2/unknownField.ts created+10
...@@ -0,0 +1,10 @@
1/** Readers for decoding undeclared provider payloads without trusting shapes. */
2export function field(source: unknown, key: string): unknown {
3 return typeof source === "object" && source !== null ? Reflect.get(source, key) : undefined;
4}
5
6export function text(value: unknown): string | undefined {
7 if (typeof value !== "string") return undefined;
8 const trimmed = value.trim();
9 return trimmed.length === 0 ? undefined : trimmed;
10}
apps/server/src/orchestration-v2/workflowAgentAnswers.test.ts created+121
...@@ -0,0 +1,121 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import { it as effectIt } from "@effect/vitest";
6import * as Effect from "effect/Effect";
7import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
8
9import { parseWorkflowAgentAnswers, readWorkflowAgentAnswers } from "./workflowAgentAnswers.ts";
10
11const line = (value: unknown) => `${JSON.stringify(value)}\n`;
12
13const assistant = (id: string, content: ReadonlyArray<unknown>) =>
14 line({ type: "assistant", message: { role: "assistant", id, content } });
15
16describe("parseWorkflowAgentAnswers", () => {
17 it("returns one entry per assistant turn", () => {
18 expect(
19 parseWorkflowAgentAnswers(
20 assistant("a", [{ type: "text", text: "first" }]) +
21 assistant("b", [{ type: "text", text: "second" }]),
22 ),
23 ).toEqual(["first", "second"]);
24 });
25
26 it("joins the lines of one split assistant message into a single turn", () => {
27 expect(
28 parseWorkflowAgentAnswers(
29 assistant("a", [{ type: "thinking", thinking: "weighing it up" }]) +
30 assistant("a", [{ type: "text", text: "the answer" }]) +
31 assistant("a", [{ type: "text", text: "and more" }]),
32 ),
33 ).toEqual(["the answer\n\nand more"]);
34 });
35
36 it("ignores harness bookkeeping and the member's own prompt", () => {
37 expect(
38 parseWorkflowAgentAnswers(
39 line({ type: "attachment", attachment: { type: "skill_listing", content: "- adhd" } }) +
40 line({ type: "user", message: { role: "user", content: "the prompt" } }) +
41 line({
42 type: "assistant",
43 isMeta: true,
44 message: { role: "assistant", id: "m", content: [{ type: "text", text: "injected" }] },
45 }) +
46 assistant("a", [{ type: "text", text: "real answer" }]),
47 ),
48 ).toEqual(["real answer"]);
49 });
50
51 it("skips a turn that produced no text", () => {
52 expect(
53 parseWorkflowAgentAnswers(
54 assistant("a", [{ type: "tool_use", id: "t", name: "Bash", input: {} }]),
55 ),
56 ).toEqual([]);
57 });
58
59 it("preserves answer indentation and ignores blank text", () => {
60 expect(
61 parseWorkflowAgentAnswers(
62 assistant("a", [{ type: "text", text: " indented code" }]) +
63 assistant("b", [{ type: "text", text: " " }]),
64 ),
65 ).toEqual([" indented code"]);
66 });
67
68 it("survives a trailing line the byte cap cut in half", () => {
69 expect(
70 parseWorkflowAgentAnswers(
71 assistant("a", [{ type: "text", text: "complete" }]) + '{"type":"assistant","mess',
72 ),
73 ).toEqual(["complete"]);
74 });
75});
76
77// Transcripts must sit under <configDir>/projects: readContainedWorkflowFile
78// rejects any path outside it. A temporary configDir keeps the real
79// ~/.claude untouched.
80let configDir: string;
81let transcriptDir: string;
82beforeAll(() => {
83 configDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "wf-config-"));
84 transcriptDir = NodePath.join(configDir, "projects", "test");
85 NodeFS.mkdirSync(transcriptDir, { recursive: true });
86});
87
88afterAll(() => {
89 NodeFS.rmSync(configDir, { recursive: true, force: true });
90});
91
92describe("readWorkflowAgentAnswers", () => {
93 // The under-cap case is the control: without it a read stuck on [] would pass.
94 effectIt.effect("reads final answers from both small and capped transcripts", () =>
95 Effect.gen(function* () {
96 const turn = assistant("a", [{ type: "text", text: "the answer" }]);
97 const filler = line({ type: "user", message: { role: "user", content: "x".repeat(4096) } });
98 const write = (agentId: string, contents: string) =>
99 NodeFS.writeFileSync(NodePath.join(transcriptDir, `agent-${agentId}.jsonl`), contents);
100 const read = (agentId: string) =>
101 readWorkflowAgentAnswers({ transcriptDir, agentId, configDir });
102 write("small", turn);
103 const edgeAnswer = "x".repeat(
104 512 * 1024 + 1 - Buffer.byteLength(assistant("edge", [{ type: "text", text: "" }])),
105 );
106 write("edge", assistant("edge", [{ type: "text", text: edgeAnswer }]));
107 write(
108 "boundary",
109 filler.repeat(160) + turn + " ".repeat(512 * 1024 - Buffer.byteLength(turn)),
110 );
111 write(
112 "huge",
113 turn + filler.repeat(160) + assistant("final", [{ type: "text", text: "final answer" }]),
114 );
115 expect(yield* read("small")).toEqual(["the answer"]);
116 expect(yield* read("edge")).toEqual([edgeAnswer]);
117 expect(yield* read("huge")).toEqual(["final answer"]);
118 expect(yield* read("boundary")).toEqual(["the answer"]);
119 }),
120 );
121});
apps/server/src/orchestration-v2/workflowAgentAnswers.ts created+97
...@@ -0,0 +1,97 @@
1// Path joining only; the filesystem access lives in readContainedWorkflowFile.
2// @effect-diagnostics nodeBuiltinImport:off
3/**
4 * Reads what a workflow member actually answered.
5 *
6 * A member agent never becomes a provider task and emits nothing on the event
7 * stream, so the transcript the harness writes is the only full record of its
8 * turn — the progress snapshot carries a capped excerpt. The path is derived
9 * here from the run's transcript directory and the member's provider id rather
10 * than accepted from the caller, and the read itself is contained by
11 * readContainedWorkflowFile.
12 */
13import * as NodePath from "node:path";
14
15import { OrchestrationWorkflowFileError } from "@t3tools/contracts";
16import * as Effect from "effect/Effect";
17
18import { field, text } from "./unknownField.ts";
19import { readContainedWorkflowFile } from "./workflowFileRead.ts";
20
21const TRANSCRIPT_BYTE_CAP = 512 * 1024;
22
23/** A member id names a file, so anything path-shaped is rejected outright. */
24const AGENT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
25
26/**
27 * Collects the assistant text of each turn in the harness's JSONL.
28 *
29 * Two shapes have to be handled: `attachment` lines and `isMeta` user lines are
30 * harness bookkeeping (hook output, skill listings, injected context) and are
31 * never conversation, and one assistant API message is split across several
32 * lines sharing a `message.id`, so those are joined into a single turn rather
33 * than read as separate ones.
34 */
35export function parseWorkflowAgentAnswers(contents: string): ReadonlyArray<string> {
36 const turns: string[] = [];
37 const turnIndexById = new Map<string, number>();
38
39 for (const line of contents.split("\n")) {
40 if (line.trim().length === 0) continue;
41 let entry: unknown;
42 try {
43 entry = JSON.parse(line);
44 } catch {
45 // A capped read can cut the final line mid-object; drop it and keep the rest.
46 continue;
47 }
48 if (text(field(entry, "type")) !== "assistant") continue;
49 if (field(entry, "isMeta") === true) continue;
50
51 const message = field(entry, "message");
52 const content = field(message, "content");
53 if (!Array.isArray(content)) continue;
54 const body = content
55 .filter((block) => text(field(block, "type")) === "text")
56 .map((block) => field(block, "text"))
57 .filter((value): value is string => typeof value === "string" && value.trim().length > 0)
58 .join("\n\n");
59 if (body.length === 0) continue;
60
61 const messageId = text(field(message, "id"));
62 const existing = messageId === undefined ? undefined : turnIndexById.get(messageId);
63 if (existing !== undefined) {
64 turns[existing] = `${turns[existing]}\n\n${body}`;
65 continue;
66 }
67 if (messageId !== undefined) turnIndexById.set(messageId, turns.length);
68 turns.push(body);
69 }
70
71 return turns;
72}
73
74export const readWorkflowAgentAnswers = Effect.fn("orchestration.readWorkflowAgentAnswers")(
75 function* (input: {
76 readonly transcriptDir: string;
77 readonly agentId: string;
78 readonly configDir?: string;
79 }) {
80 if (!AGENT_ID_PATTERN.test(input.agentId)) {
81 return yield* new OrchestrationWorkflowFileError({
82 reason: "invalid-path",
83 path: input.agentId,
84 });
85 }
86 const file = yield* readContainedWorkflowFile({
87 path: NodePath.join(input.transcriptDir, `agent-${input.agentId}.jsonl`),
88 extension: ".jsonl",
89 byteCap: TRANSCRIPT_BYTE_CAP,
90 tail: true,
91 ...(input.configDir === undefined ? {} : { configDir: input.configDir }),
92 });
93 return parseWorkflowAgentAnswers(
94 file.truncated ? file.contents.slice(file.contents.indexOf("\n") + 1) : file.contents,
95 );
96 },
97);
apps/server/src/orchestration-v2/workflowFileRead.ts created+112
...@@ -0,0 +1,112 @@
1// @effect-diagnostics nodeBuiltinImport:off
2/**
3 * Contained reads of files the Claude harness persisted for a workflow run.
4 *
5 * Containment rules (lifted from the reviewed #3650 inspection service):
6 * - the resolved realpath must live under the configured Claude projects
7 * directory (where the harness persists workflow scripts and transcripts) —
8 * realpath re-containment defeats symlink escapes, including a symlinked
9 * leaf file;
10 * - only the caller's expected extension is served;
11 * - reads are size-capped rather than failed, with a truncation marker.
12 *
13 * The client-supplied path is a hint from the workflow's runHandles; it is
14 * never trusted beyond these checks.
15 */
16import * as NodeFS from "node:fs";
17import * as NodeFSP from "node:fs/promises";
18import * as NodePath from "node:path";
19
20import { OrchestrationWorkflowFileError } from "@t3tools/contracts";
21import * as Effect from "effect/Effect";
22import * as HostProcess from "@t3tools/shared/HostProcess";
23
24function workflowFilesRoot(home: string, configDir?: string): string {
25 return NodePath.join(configDir?.trim() || NodePath.join(home, ".claude"), "projects");
26}
27
28export const readContainedWorkflowFile = Effect.fn("orchestration.readContainedWorkflowFile")(
29 function* (input: {
30 readonly path: string;
31 readonly extension: string;
32 readonly byteCap: number;
33 readonly tail?: boolean;
34 readonly configDir?: string;
35 }) {
36 if (!NodePath.isAbsolute(input.path) || NodePath.extname(input.path) !== input.extension) {
37 return yield* new OrchestrationWorkflowFileError({
38 reason: "invalid-path",
39 path: input.path,
40 });
41 }
42
43 const home = yield* HostProcess.HomeDirectory;
44 const root = yield* Effect.tryPromise({
45 try: () => NodeFSP.realpath(workflowFilesRoot(home, input.configDir)),
46 catch: (cause) =>
47 new OrchestrationWorkflowFileError({
48 reason: "root-unavailable",
49 path: input.path,
50 cause,
51 }),
52 });
53
54 // Realpath the FILE itself (not just its directory): a symlink named
55 // like a transcript inside a contained directory must not escape.
56 const resolved = yield* Effect.tryPromise({
57 try: () => NodeFSP.realpath(input.path),
58 catch: (cause) =>
59 new OrchestrationWorkflowFileError({ reason: "not-found", path: input.path, cause }),
60 });
61
62 if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) {
63 return yield* new OrchestrationWorkflowFileError({ reason: "outside-root", path: resolved });
64 }
65 if (NodePath.extname(resolved) !== input.extension) {
66 return yield* new OrchestrationWorkflowFileError({
67 reason: "wrong-extension",
68 path: resolved,
69 });
70 }
71
72 // fd inode vs path inode catches a swapped leaf, not a swapped intermediate
73 // dir; root is 0700 ~/.claude, so that already needs the uid we run as.
74 // Both containment checks fail with their own tagged reason; "read-failed"
75 // is reserved for genuine platform failures with the real cause attached.
76 const read = yield* Effect.tryPromise({
77 try: async () => {
78 const handle = await NodeFSP.open(
79 resolved,
80 NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NONBLOCK ?? 0),
81 );
82 try {
83 const stat = await handle.stat();
84 if (!stat.isFile()) {
85 return { failure: "not-regular-file" as const };
86 }
87 const pathStat = await NodeFSP.lstat(resolved);
88 if (stat.ino !== pathStat.ino || stat.dev !== pathStat.dev) {
89 return { failure: "changed-during-read" as const };
90 }
91 const buffer = Buffer.alloc(Math.min(stat.size, input.byteCap + (input.tail ? 1 : 0)));
92 const truncated = stat.size > buffer.length;
93 const offset = input.tail ? stat.size - buffer.length : 0;
94 const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
95 return {
96 contents: buffer.subarray(0, bytesRead).toString("utf8"),
97 truncated,
98 };
99 } finally {
100 await handle.close();
101 }
102 },
103 catch: (cause) =>
104 new OrchestrationWorkflowFileError({ reason: "read-failed", path: resolved, cause }),
105 });
106 if ("failure" in read) {
107 return yield* new OrchestrationWorkflowFileError({ reason: read.failure, path: resolved });
108 }
109
110 return { path: resolved, contents: read.contents, truncated: read.truncated };
111 },
112);
apps/server/src/orchestration-v2/workflowScriptQuery.test.ts+21-1
...@@ -2,6 +2,9 @@...@@ -2,6 +2,9 @@
2import * as NodeFS from "node:fs";2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";4import * as NodePath from "node:path";
5import * as NodeChildProcess from "node:child_process";
6import { OrchestrationWorkflowFileError } from "@t3tools/contracts";
7import * as HostProcess from "@t3tools/shared/HostProcess";
5import { it as effectIt } from "@effect/vitest";8import { it as effectIt } from "@effect/vitest";
6import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
7import { afterAll, assert, describe } from "vite-plus/test";10import { afterAll, assert, describe } from "vite-plus/test";
...@@ -30,7 +33,18 @@ afterAll(() => {...@@ -30,7 +33,18 @@ afterAll(() => {
30 NodeFS.rmSync(outside, { force: true });33 NodeFS.rmSync(outside, { force: true });
31});34});
3235
33describe("readWorkflowScript containment", () => {36describe("workflow script containment", () => {
37 effectIt.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
38 "rejects a FIFO without waiting for a writer",
39 () =>
40 Effect.gen(function* () {
41 const fifo = NodePath.join(root, "pipe.js");
42 NodeChildProcess.execFileSync("mkfifo", [fifo]);
43 const error = yield* Effect.flip(readWorkflowScript({ scriptPath: fifo }));
44 assert.equal(error.reason, "not-regular-file");
45 }),
46 );
47
34 effectIt.effect("serves a real script under the projects root", () =>48 effectIt.effect("serves a real script under the projects root", () =>
35 Effect.gen(function* () {49 Effect.gen(function* () {
36 const result = yield* readWorkflowScript({ scriptPath });50 const result = yield* readWorkflowScript({ scriptPath });
...@@ -47,6 +61,12 @@ describe("readWorkflowScript containment", () => {...@@ -47,6 +61,12 @@ describe("readWorkflowScript containment", () => {
47 readWorkflowScript({ scriptPath: scriptPath.replace(".js", ".ts") }),61 readWorkflowScript({ scriptPath: scriptPath.replace(".js", ".ts") }),
48 );62 );
49 assert.equal(nonJs._tag, "Failure");63 assert.equal(nonJs._tag, "Failure");
64 const error = yield* Effect.flip(readWorkflowScript({ scriptPath: "run.js" }));
65 assert.equal(error._tag, "OrchestrationGetWorkflowScriptError");
66 assert.equal(error.scriptPath, "run.js");
67 assert.equal(error.reason, "invalid-path");
68 assert.equal(error.message, "Workflow scripts must be absolute .js paths.");
69 assert.instanceOf(error.cause, OrchestrationWorkflowFileError);
50 }),70 }),
51 );71 );
5272
apps/server/src/orchestration-v2/workflowScriptQuery.ts+16-121
...@@ -1,128 +1,23 @@...@@ -1,128 +1,23 @@
1// @effect-diagnostics nodeBuiltinImport:off
2/**
3 * Read-only access to persisted workflow scripts for the Agents surface's
4 * "{} script" affordance.
5 *
6 * Containment rules (lifted from the reviewed #3650 inspection service):
7 * - the resolved realpath must live under ~/.claude/projects (where the
8 * Claude harness persists workflow scripts) — realpath re-containment
9 * defeats symlink escapes, including a symlinked leaf file;
10 * - only .js leaf files are served;
11 * - reads are size-capped rather than failed, with a truncation marker.
12 *
13 * The client-supplied path is a hint from the workflow's runHandles; it is
14 * never trusted beyond these checks.
15 */
16import * as NodeFSP from "node:fs/promises";
17import * as NodePath from "node:path";
18
19import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts";1import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts";
20import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
21import * as HostProcess from "@t3tools/shared/HostProcess";3import { readContainedWorkflowFile } from "./workflowFileRead.ts";
22
23const SCRIPT_BYTE_CAP = 256 * 1024;
24
25function scriptsRoot(home: string): string {
26 return NodePath.join(home, ".claude", "projects");
27}
284
29export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: {5export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: {
30 readonly scriptPath: string;6 readonly scriptPath: string;
31}) {7}) {
32 const requested = input.scriptPath;8 const file = yield* readContainedWorkflowFile({
339 path: input.scriptPath,
34 if (!NodePath.isAbsolute(requested) || NodePath.extname(requested) !== ".js") {10 extension: ".js",
35 return yield* new OrchestrationGetWorkflowScriptError({11 byteCap: 256 * 1024,
36 reason: "invalid-path",12 }).pipe(
37 scriptPath: requested,13 Effect.mapError(
38 });14 (error) =>
39 }15 new OrchestrationGetWorkflowScriptError({
4016 reason: error.reason === "wrong-extension" ? "not-js" : error.reason,
41 const home = yield* HostProcess.HomeDirectory;17 scriptPath: error.path,
42 const root = yield* Effect.tryPromise({18 cause: error,
43 try: () => NodeFSP.realpath(scriptsRoot(home)),19 }),
44 catch: (cause) =>20 ),
45 new OrchestrationGetWorkflowScriptError({21 );
46 reason: "root-unavailable",22 return { scriptPath: file.path, contents: file.contents, truncated: file.truncated };
47 scriptPath: requested,
48 cause,
49 }),
50 });
51
52 // Realpath the FILE itself (not just its directory): a symlink named
53 // like a script inside a contained directory must not escape.
54 const resolved = yield* Effect.tryPromise({
55 try: () => NodeFSP.realpath(requested),
56 catch: (cause) =>
57 new OrchestrationGetWorkflowScriptError({
58 reason: "not-found",
59 scriptPath: requested,
60 cause,
61 }),
62 });
63
64 if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) {
65 return yield* new OrchestrationGetWorkflowScriptError({
66 reason: "outside-root",
67 scriptPath: resolved,
68 });
69 }
70 if (NodePath.extname(resolved) !== ".js") {
71 return yield* new OrchestrationGetWorkflowScriptError({
72 reason: "not-js",
73 scriptPath: resolved,
74 });
75 }
76
77 // TOCTOU-safe read (review finding): open FIRST, then verify what was
78 // actually opened via the file descriptor. Re-checking the path after
79 // open would race against a swap; fstat on the handle cannot. The two
80 // containment checks fail with their own tagged reasons (not manufactured
81 // Errors folded into read-failed); "read-failed" is reserved for genuine
82 // platform failures with the real cause attached.
83 const read = yield* Effect.tryPromise({
84 try: async () => {
85 const handle = await NodeFSP.open(resolved, "r");
86 try {
87 const stat = await handle.stat();
88 if (!stat.isFile()) {
89 return { failure: "not-regular-file" as const };
90 }
91 // The opened inode must be the same one realpath resolved to: a
92 // process swapping the path between realpath and open changes the
93 // inode, which this comparison catches.
94 const pathStat = await NodeFSP.lstat(resolved);
95 if (stat.ino !== pathStat.ino || stat.dev !== pathStat.dev) {
96 return { failure: "changed-during-read" as const };
97 }
98 const truncated = stat.size > SCRIPT_BYTE_CAP;
99 const buffer = Buffer.alloc(Math.min(stat.size, SCRIPT_BYTE_CAP));
100 const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
101 return {
102 contents: buffer.subarray(0, bytesRead).toString("utf8"),
103 truncated,
104 };
105 } finally {
106 await handle.close();
107 }
108 },
109 catch: (cause) =>
110 new OrchestrationGetWorkflowScriptError({
111 reason: "read-failed",
112 scriptPath: resolved,
113 cause,
114 }),
115 });
116 if ("failure" in read) {
117 return yield* new OrchestrationGetWorkflowScriptError({
118 reason: read.failure,
119 scriptPath: resolved,
120 });
121 }
122
123 return {
124 scriptPath: resolved,
125 contents: read.contents,
126 truncated: read.truncated,
127 };
128});23});
apps/server/src/relay/AgentAwarenessRelay.test.ts+1
...@@ -212,6 +212,7 @@ const makeTestRelay = Effect.fnUntraced(function* (...@@ -212,6 +212,7 @@ const makeTestRelay = Effect.fnUntraced(function* (
212 settleAfterRun: unused,212 settleAfterRun: unused,
213 settleThread: unused,213 settleThread: unused,
214 interruptThread: unused,214 interruptThread: unused,
215 stopWorkflow: () => Effect.die("unused stopWorkflow"),
215 stopDelegatedTasks: unused,216 stopDelegatedTasks: unused,
216 getThreadEventSequence: unused,217 getThreadEventSequence: unused,
217 recoverDelegatedTask: unused,218 recoverDelegatedTask: unused,
apps/server/src/ws.ts+1
...@@ -1864,6 +1864,7 @@ const layerWsRpc = (...@@ -1864,6 +1864,7 @@ const layerWsRpc = (
1864 ),1864 ),
1865 ),1865 ),
1866 ),1866 ),
1867 [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: (input) => threadManagement.stopWorkflow(input),
1867 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) =>1868 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) =>
1868 readWorkflowScript({ scriptPath: input.scriptPath }),1869 readWorkflowScript({ scriptPath: input.scriptPath }),
1869 [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) =>1870 [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) =>
apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx created+266
...@@ -0,0 +1,266 @@
1import type {
2 SubagentWorkflowGroup,
3 RuntimeSubagent,
4} from "@t3tools/client-runtime/state/subagentRuntime";
5import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts";
6import { ChevronDownIcon, LoaderCircleIcon, SquareIcon } from "lucide-react";
7import { useState, type ReactNode } from "react";
8
9import { AgentElapsed } from "./AgentElapsed";
10import { SubagentTooltipContent } from "./SubagentTooltipContent";
11import { ThreadHoverCardPopup } from "../ThreadHoverCard";
12import { ThreadRelationshipIcon } from "./ThreadRelationshipIcon";
13import { cn } from "../../lib/utils";
14import { ThreadDetailsControl } from "./ThreadDetailsControl";
15import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header";
16import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
17import {
18 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
19 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
20} from "./threadDetailsPanelStyles";
21
22/**
23 * Wall clock for the phase: its members run in parallel, so the span from the
24 * first start to the last finish is the time the phase actually took. Shaped
25 * for AgentElapsed, which ticks it while the phase is still running.
26 */
27function phaseElapsed(phase: SubagentWorkflowGroup["phases"][number]) {
28 const instants = (key: "startedAt" | "completedAt") =>
29 phase.members
30 .map((member) => member[key])
31 .filter((value) => value !== null)
32 .sort();
33 const running = phase.state === "running";
34 const startedAt = instants("startedAt")[0] ?? null;
35 const completedAt = running ? null : (instants("completedAt").at(-1) ?? null);
36 return {
37 status: running ? ("running" as const) : ("completed" as const),
38 // A settled phase whose members never reported an end has no span to show.
39 startedAt: running || completedAt !== null ? startedAt : null,
40 completedAt,
41 };
42}
43
44/** Phase state for the header's label; the tone and member badges show it visually. */
45function phaseStatusLabel(phase: SubagentWorkflowGroup["phases"][number]) {
46 if (phase.state === "running") return "running";
47 if (phase.members.some((member) => member.status === "failed")) return "failed";
48 if (
49 phase.members.some((member) => member.status === "cancelled" || member.status === "interrupted")
50 ) {
51 return "stopped";
52 }
53 return phase.state === "done" ? "done" : "not started";
54}
55
56/** Members run under the coordinator's provider, so they share its glyph. */
57function WorkflowMemberRow({
58 member,
59 providerInstanceId,
60 provider,
61 providers,
62 driver,
63 onOpen,
64 isThreadAvailable,
65}: {
66 member: RuntimeSubagent;
67 providerInstanceId: ProviderInstanceId;
68 provider: ServerProvider | undefined;
69 providers: ReadonlyArray<ServerProvider> | undefined;
70 driver: ProviderDriverKind | undefined;
71 onOpen: (threadId: string) => void;
72 isThreadAvailable: (threadId: string) => boolean;
73}) {
74 const threadId = member.childThreadId;
75 const canOpen = threadId !== null && isThreadAvailable(threadId);
76 return (
77 <li>
78 <Tooltip>
79 <TooltipTrigger
80 delay={200}
81 render={
82 <ThreadDetailsControl
83 part="row"
84 aria-label={`Open ${member.title} chat`}
85 disabled={!canOpen}
86 onClick={() => canOpen && onOpen(threadId)}
87 />
88 }
89 >
90 <ThreadRelationshipIcon driver={driver} provider={provider} status={member.status} />
91 <span className="min-w-0 flex-1 truncate">{member.title}</span>
92 <span className="sr-only">{member.status}</span>
93 {member.startedAt ? (
94 <span className="shrink-0 text-2xs font-normal tabular-nums text-muted-foreground">
95 <AgentElapsed agent={member} />
96 </span>
97 ) : null}
98 </TooltipTrigger>
99 <ThreadHoverCardPopup side="left">
100 <SubagentTooltipContent
101 title={member.title}
102 model={member.model}
103 providerInstanceId={providerInstanceId}
104 origin="provider_native"
105 provider={provider}
106 providers={providers}
107 driver={driver}
108 elapsed={<AgentElapsed agent={member} />}
109 status={member.status}
110 result={member.result ?? member.error}
111 progress={member.progress}
112 />
113 </ThreadHoverCardPopup>
114 </Tooltip>
115 </li>
116 );
117}
118
119export function ThreadLineageWorkflowRow({
120 group,
121 header,
122 providerInstanceId,
123 provider,
124 providers,
125 driver,
126 onOpenThread,
127 isThreadAvailable,
128 onStop,
129 stopping,
130 stopDisabled,
131}: {
132 readonly group: SubagentWorkflowGroup;
133 readonly header: ReactNode;
134 readonly providerInstanceId: ProviderInstanceId;
135 readonly provider: ServerProvider | undefined;
136 readonly providers: ReadonlyArray<ServerProvider> | undefined;
137 readonly driver: ProviderDriverKind | undefined;
138 readonly onOpenThread: (threadId: string) => void;
139 readonly isThreadAvailable: (threadId: string) => boolean;
140 readonly onStop?: (() => void) | undefined;
141 readonly stopping: boolean;
142 readonly stopDisabled: boolean;
143}) {
144 const [expanded, setExpanded] = useState(false);
145 // The running phase opens itself until the user picks a side for it.
146 const [phaseOpen, setPhaseOpen] = useState<ReadonlyMap<number, boolean>>(() => new Map());
147 const label = group.workflow.workflowName ?? group.workflow.title;
148 const phases = group.phases.filter((phase) => phase.members.length > 0);
149 return (
150 // The flag lets the lineage list trade its compact height for the open tree.
151 <li className="group" data-workflow-expanded={expanded ? "" : undefined}>
152 <div className={cn("relative", THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS)}>
153 {header}
154 {onStop ? (
155 <div className="pointer-events-none absolute right-9 top-1/2 -translate-y-1/2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">
156 <Tooltip>
157 <TooltipTrigger
158 render={
159 <ThreadDetailsControl
160 size="icon-xs"
161 variant="ghost"
162 part="icon"
163 tone="destructive"
164 aria-label={`Stop workflow ${label}`}
165 disabled={stopDisabled}
166 onClick={onStop}
167 />
168 }
169 >
170 {stopping ? (
171 <LoaderCircleIcon aria-hidden className="size-3 animate-spin" />
172 ) : (
173 <SquareIcon aria-hidden className="size-3 fill-current" />
174 )}
175 </TooltipTrigger>
176 <TooltipPopup side="left">Stop entire workflow</TooltipPopup>
177 </Tooltip>
178 </div>
179 ) : null}
180 <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} />
181 <ThreadDetailsControl
182 size="sm"
183 variant="ghost"
184 part="secondary"
185 aria-expanded={expanded}
186 aria-label={`${expanded ? "Collapse" : "Expand"} ${label}`}
187 onClick={() => setExpanded((value) => !value)}
188 >
189 <ChevronDownIcon
190 aria-hidden
191 className={cn(
192 "size-3.5 text-muted-foreground transition-transform",
193 expanded && "rotate-180",
194 )}
195 />
196 </ThreadDetailsControl>
197 </div>
198 {expanded ? (
199 <ul className="m-0 list-none p-0 ps-6.5">
200 {phases.map((phase) => {
201 const open = phaseOpen.get(phase.index) ?? phase.state === "running";
202 const status = phaseStatusLabel(phase);
203 return (
204 <li key={phase.index}>
205 <CollapsibleSectionHeader
206 variant="panel"
207 tone={phase.state === "running" ? "emphasized" : "muted"}
208 expanded={open}
209 aria-label={`${open ? "Collapse" : "Expand"} ${phase.title} phase, ${status}`}
210 onClick={() => setPhaseOpen((phases) => new Map(phases).set(phase.index, !open))}
211 accessory={
212 <>
213 {status === "failed" ? (
214 <SectionHeaderStatus>Failed</SectionHeaderStatus>
215 ) : null}
216 <span className="shrink-0 text-2xs font-normal tabular-nums">
217 {phase.settledCount}/{phase.members.length}{" "}
218 {phase.members.length === 1 ? "agent" : "agents"}
219 </span>
220 <span className="shrink-0 text-2xs font-normal tabular-nums empty:hidden">
221 <AgentElapsed agent={phaseElapsed(phase)} />
222 </span>
223 </>
224 }
225 >
226 {phase.title}
227 </CollapsibleSectionHeader>
228 {open ? (
229 <ul className="m-0 list-none p-0">
230 {phase.members.map((member) => (
231 <WorkflowMemberRow
232 key={member.id}
233 member={member}
234 providerInstanceId={providerInstanceId}
235 provider={provider}
236 providers={providers}
237 driver={driver}
238 onOpen={onOpenThread}
239 isThreadAvailable={isThreadAvailable}
240 />
241 ))}
242 </ul>
243 ) : null}
244 </li>
245 );
246 })}
247 {group.unphasedMembers.map((member) => (
248 <WorkflowMemberRow
249 key={member.id}
250 member={member}
251 providerInstanceId={providerInstanceId}
252 provider={provider}
253 providers={providers}
254 driver={driver}
255 onOpen={onOpenThread}
256 isThreadAvailable={isThreadAvailable}
257 />
258 ))}
259 {phases.length === 0 && group.unphasedMembers.length === 0 ? (
260 <li className="px-2.5 py-1.5 text-2xs text-muted-foreground/70">No agents yet</li>
261 ) : null}
262 </ul>
263 ) : null}
264 </li>
265 );
266}
apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx+470-53
...@@ -18,6 +18,8 @@ const state = vi.hoisted(() => ({...@@ -18,6 +18,8 @@ const state = vi.hoisted(() => ({
18 configs: new Map<string, unknown>(),18 configs: new Map<string, unknown>(),
19 showTooltips: false,19 showTooltips: false,
20 command: vi.fn().mockResolvedValue({ _tag: "Success" }),20 command: vi.fn().mockResolvedValue({ _tag: "Success" }),
21 workflowCommand: vi.fn().mockResolvedValue({ _tag: "Success" }),
22 toast: vi.fn(),
21}));23}));
2224
23vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate }));25vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate }));
...@@ -30,7 +32,25 @@ vi.mock("../../state/entities", () => ({...@@ -30,7 +32,25 @@ vi.mock("../../state/entities", () => ({
30vi.mock("../../lib/archivedThreadsState", () => ({32vi.mock("../../lib/archivedThreadsState", () => ({
31 useArchivedThreadSnapshots: () => ({ snapshots: [] }),33 useArchivedThreadSnapshots: () => ({ snapshots: [] }),
32}));34}));
33vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.command }));35vi.mock("../../state/use-atom-command", () => ({
36 useAtomCommand: (command: { label: string }) =>
37 command.label === "environment-data:orchestration:stop-workflow"
38 ? state.workflowCommand
39 : state.command,
40}));
41vi.mock("../ui/toast", () => ({ toastManager: { add: state.toast } }));
42vi.mock("../../state/orchestration", async () => {
43 const { Atom } = await import("effect/reactivity");
44 const granted = Atom.make(true);
45 return {
46 orchestrationEnvironment: {
47 stopWorkflow: {
48 label: "environment-data:orchestration:stop-workflow",
49 permissionAtom: () => granted,
50 },
51 },
52 };
53});
34vi.mock("../ui/tooltip", () => ({54vi.mock("../ui/tooltip", () => ({
35 Tooltip: ({ children }: { children: ReactNode }) => children,55 Tooltip: ({ children }: { children: ReactNode }) => children,
36 TooltipTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) =>56 TooltipTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) =>
...@@ -49,17 +69,423 @@ afterEach(async () => {...@@ -49,17 +69,423 @@ afterEach(async () => {
49 state.projects = [];69 state.projects = [];
50 state.configs.clear();70 state.configs.clear();
51 state.showTooltips = false;71 state.showTooltips = false;
72 state.navigate.mockClear();
52 state.command.mockClear();73 state.command.mockClear();
74 state.workflowCommand.mockReset().mockResolvedValue({ _tag: "Success" });
75 state.toast.mockClear();
53 state.projection = null;76 state.projection = null;
54});77});
5578
79it("stops the entire native workflow through its parent without changing state before acknowledgement", async () => {
80 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
81 const coordinator = {
82 id: "workflow",
83 origin: "provider_native",
84 driver: "claudeAgent",
85 providerInstanceId: "claude-work",
86 childThreadId: "workflow-chat",
87 title: "Checkout review",
88 status: "running",
89 startedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"),
90 completedAt: null,
91 updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"),
92 workflow: {
93 name: "Checkout review",
94 phases: [{ index: 0, title: "Inspect" }],
95 agents: [{ index: 0, label: "Checker", state: "running", phaseIndex: 0 }],
96 },
97 };
98 state.projection = {
99 thread: { id: "parent", lineage: { relationshipToParent: null } },
100 runs: [],
101 providerThreads: [],
102 providerSessions: [],
103 contextTransfers: [],
104 subagents: [
105 coordinator,
106 {
107 ...coordinator,
108 id: "worker",
109 childThreadId: "worker-chat",
110 origin: "app_owned",
111 title: "Worker",
112 workflow: undefined,
113 },
114 ],
115 };
116 state.shells = [
117 {
118 environmentId: "remote",
119 source: {
120 id: "workflow-chat",
121 title: "Coordinator chat",
122 lineage: { parentThreadId: "parent", relationshipToParent: "subagent" },
123 activityRunStatus: "waiting",
124 },
125 },
126 ];
127 await act(async () => {
128 renderer = create(
129 <ThreadRelationshipsPanel
130 environmentId={EnvironmentId.make("remote")}
131 threadId={ThreadId.make("parent")}
132 />,
133 );
134 });
135 await act(async () =>
136 renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(),
137 );
138 const stopButton = () =>
139 renderer.root.findByProps({ "aria-label": "Stop workflow Checkout review" });
140 expect(
141 renderer.root.findAll(
142 (node) => node.type === "button" && String(node.props["aria-label"]).startsWith("Stop "),
143 ),
144 ).toHaveLength(2);
145 let acknowledge!: (result: { _tag: string }) => void;
146 state.workflowCommand.mockImplementationOnce(
147 () =>
148 new Promise((resolve) => {
149 acknowledge = resolve;
150 }),
151 );
152 await act(async () => {
153 stopButton().props.onClick();
154 });
155 expect(state.workflowCommand).toHaveBeenCalledExactlyOnceWith({
156 environmentId: "remote",
157 input: { threadId: "parent", subagentId: "workflow" },
158 });
159 expect(state.command).not.toHaveBeenCalled();
160 expect(state.navigate).not.toHaveBeenCalled();
161 expect(stopButton().props.disabled).toBe(true);
162 expect(renderer.root.findByProps({ "aria-label": "Stop subagent Worker" }).props.disabled).toBe(
163 true,
164 );
165 expect(renderer.root.findByProps({ "aria-label": "Open Checker chat" })).toBeDefined();
166 expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 2 running"]);
167 await act(async () => {
168 acknowledge({ _tag: "Success" });
169 });
170 expect(stopButton().props.disabled).toBe(false);
171 expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 2 running"]);
172 state.workflowCommand.mockResolvedValueOnce({ _tag: "Failure" });
173 await act(async () => {
174 stopButton().props.onClick();
175 });
176 expect(state.toast).toHaveBeenCalledExactlyOnceWith({
177 type: "error",
178 title: "Could not stop workflow",
179 });
180 expect(stopButton().props.disabled).toBe(false);
181 expect(state.command).not.toHaveBeenCalled();
182 expect(state.navigate).not.toHaveBeenCalled();
183});
184
185it.each([
186 ["claudeAgent", "provider_native", "completed"],
187 ["claudeAgent", "provider_native", "failed"],
188 ["claudeAgent", "provider_native", "interrupted"],
189 ["claudeAgent", "provider_native", "waiting"],
190 ["codex", "provider_native", "running"],
191 ["claudeAgent", "app_owned", "running"],
192])("does not offer workflow stop for %s %s %s", async (driver, origin, status) => {
193 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
194 state.projection = {
195 thread: { id: "parent", lineage: { relationshipToParent: null } },
196 runs: [],
197 providerThreads: [],
198 providerSessions: [],
199 contextTransfers: [],
200 subagents: [
201 {
202 id: "workflow",
203 driver,
204 origin,
205 status,
206 providerInstanceId: "claude-work",
207 childThreadId: "workflow-chat",
208 title: "Checkout review",
209 startedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"),
210 completedAt: null,
211 updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"),
212 workflow: { name: "Checkout review", phases: [], agents: [] },
213 },
214 ],
215 };
216 await act(async () => {
217 renderer = create(
218 <ThreadRelationshipsPanel
219 environmentId={EnvironmentId.make("remote")}
220 threadId={ThreadId.make("parent")}
221 />,
222 );
223 });
224 const previousAgents = renderer.root.findAll(
225 (node) =>
226 node.type === "button" && node.props["aria-expanded"] === false && !node.props["aria-label"],
227 );
228 if (previousAgents.length > 0) await act(async () => previousAgents[0]!.props.onClick());
229 expect(renderer.root.findByProps({ "aria-label": "Expand Checkout review" })).toBeDefined();
230 expect(
231 renderer.root.findAllByProps({ "aria-label": "Stop workflow Checkout review" }),
232 ).toHaveLength(0);
233 expect(state.workflowCommand).not.toHaveBeenCalled();
234});
235
236it("opens the correct chat for every workflow phase and unphased member", async () => {
237 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
238 state.showTooltips = true;
239 state.configs.set("remote", {
240 providers: [
241 {
242 instanceId: "claude-personal",
243 driver: "claudeAgent",
244 displayName: "Personal account",
245 models: [],
246 },
247 {
248 instanceId: "claude-work",
249 driver: "claudeAgent",
250 displayName: "Work account",
251 models: [{ slug: "claude-sonnet-4-6", name: "Sonnet" }],
252 },
253 ],
254 });
255 const phases = ["Inspect", "Improve", "Verify"].map((title, index) => ({ index, title }));
256 const agents = Array.from({ length: 7 }, (_, index) => ({
257 index,
258 label: `Member ${index}`,
259 state:
260 index === 2 ? "cancelled" : index === 4 ? "failed" : index === 5 ? "running" : "completed",
261 ...(index < 6 ? { phaseIndex: Math.floor(index / 2) } : {}),
262 childThreadId: `member-chat-${index}`,
263 model: "claude-sonnet-4-6",
264 result: `Result ${index}`,
265 totalTokens: 1200,
266 // Staggered starts, so a phase spans longer than either member's own run.
267 startedAt: 1_700_000_000_000 + index * 1000,
268 durationMs: 5000,
269 }));
270 const project = (members: typeof agents) => ({
271 thread: { id: "parent", lineage: { relationshipToParent: null } },
272 runs: [],
273 providerThreads: [],
274 providerSessions: [],
275 contextTransfers: [],
276 subagents: [
277 {
278 id: "workflow",
279 driver: "claudeAgent",
280 providerInstanceId: "claude-work",
281 childThreadId: "workflow-chat",
282 title: "Checkout review",
283 status: "running",
284 startedAt: null,
285 completedAt: null,
286 updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"),
287 workflow: { name: "Checkout review", phases, agents: members },
288 },
289 ],
290 });
291 state.projection = project(agents);
292 // Matching IDs from another environment cannot make these chats available.
293 const memberShells = agents.map((agent) => ({
294 id: agent.childThreadId,
295 title: agent.label,
296 lineage: { parentThreadId: "workflow-chat", relationshipToParent: "subagent" },
297 status: "completed",
298 activityRunStatus: null,
299 }));
300 state.shells = memberShells.map((source) => ({ environmentId: "other", source }));
301 const panel = (
302 <ThreadRelationshipsPanel
303 environmentId={EnvironmentId.make("remote")}
304 threadId={ThreadId.make("parent")}
305 />
306 );
307 await act(async () => {
308 renderer = create(panel);
309 });
310 await act(async () =>
311 renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(),
312 );
313 const memberButtons = () =>
314 renderer.root.findAll(
315 (node) =>
316 node.type === "button" && String(node.props["aria-label"]).startsWith("Open Member"),
317 );
318 const phaseButtons = () =>
319 renderer.root.findAll(
320 (node) =>
321 node.type === "button" &&
322 typeof node.props["aria-expanded"] === "boolean" &&
323 String(node.props["aria-label"]).includes(" phase, "),
324 );
325 // Only the running phase starts open, beside the unphased member.
326 expect(memberButtons()).toHaveLength(3);
327 expect(phaseButtons()).toHaveLength(3);
328 expect(renderer.root.findByProps({ "aria-label": "Expand Inspect phase, done" })).toBeDefined();
329 expect(
330 renderer.root.findByProps({ "aria-label": "Expand Improve phase, stopped" }),
331 ).toBeDefined();
332 expect(
333 renderer.root.findByProps({ "aria-label": "Collapse Verify phase, running" }),
334 ).toBeDefined();
335 for (const phase of phaseButtons().slice(0, 2)) await act(async () => phase.props.onClick());
336 expect(memberButtons()).toHaveLength(7);
337 for (const button of memberButtons()) {
338 expect(button.props.disabled).toBe(true);
339 await act(async () => button.props.onClick());
340 }
341 expect(state.navigate).not.toHaveBeenCalled();
342 // The coordinator roster arrives before the member thread shells.
343 state.shells = memberShells.map((source) => ({ environmentId: "remote", source }));
344 await act(async () => renderer.update(cloneElement(panel)));
345 for (const button of memberButtons()) expect(button.props.disabled).toBe(false);
346 for (const agent of agents) {
347 await act(async () =>
348 renderer.root.findByProps({ "aria-label": `Open ${agent.label} chat` }).props.onClick(),
349 );
350 expect(state.navigate).toHaveBeenLastCalledWith({
351 to: "/$environmentId/$threadId",
352 params: { environmentId: "remote", threadId: agent.childThreadId },
353 });
354 }
355 const rendered = JSON.stringify(renderer.toJSON());
356 expect(rendered.match(/Sonnet · Work account/g)).toHaveLength(7);
357 expect(rendered).not.toContain("Personal account");
358 for (const phase of phases) expect(rendered).toContain(phase.title);
359 // The closed row leaves phase progress to the tree.
360 expect(rendered).not.toContain(" phases");
361 expect(rendered).toContain('"1","/","2"," ","agents"');
362 expect(rendered).toContain("Running");
363 // The phase holding the running member reports itself as the active one.
364 expect(rendered).toContain("running");
365 expect(rendered).toContain("done");
366 expect(rendered).toContain("stopped");
367 // Members 0 and 1 start a second apart and run 5s each, so Inspect took 6s.
368 expect(rendered).toContain('"6s"');
369 for (const phase of phaseButtons()) {
370 const label = String(phase.props["aria-label"]).slice("Collapse ".length);
371 await act(async () => phase.props.onClick());
372 expect(memberButtons()).toHaveLength(5);
373 await act(async () =>
374 renderer.root.findByProps({ "aria-label": `Expand ${label}` }).props.onClick(),
375 );
376 expect(memberButtons()).toHaveLength(7);
377 }
378 // A phase the user closed stays closed once it starts running, and stays
379 // closed after it settles.
380 await act(async () => phaseButtons()[0]!.props.onClick());
381 expect(memberButtons()).toHaveLength(5);
382 const restarted = agents.map((agent) =>
383 agent.index === 0 ? { ...agent, state: "running" } : agent,
384 );
385 state.projection = project(restarted);
386 await act(async () => renderer.update(cloneElement(panel)));
387 expect(memberButtons()).toHaveLength(5);
388 state.projection = project(agents);
389 await act(async () => renderer.update(cloneElement(panel)));
390 expect(memberButtons()).toHaveLength(5);
391 await act(async () =>
392 renderer.root.findByProps({ "aria-label": "Collapse Checkout review" }).props.onClick(),
393 );
394 expect(memberButtons()).toHaveLength(0);
395 expect(state.navigate).toHaveBeenCalledTimes(7);
396});
397
398it("groups and counts a workflow by its run status instead of its coordinator chat", async () => {
399 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
400 state.showTooltips = true;
401 const coordinator = {
402 id: "workflow",
403 driver: "claudeAgent",
404 providerInstanceId: "claudeAgent",
405 childThreadId: "workflow-chat",
406 title: "Checkout review",
407 status: "running",
408 startedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"),
409 progress: "Inspecting the checkout workflow",
410 result: "Workflow checks complete",
411 completedAt: null,
412 updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"),
413 workflow: {
414 name: "Checkout review",
415 phases: [{ index: 0, title: "Inspect" }],
416 agents: [{ index: 0, label: "Checker", state: "running", phaseIndex: 0 }],
417 },
418 };
419 const projection = {
420 thread: { id: "parent", lineage: { relationshipToParent: null } },
421 runs: [],
422 providerThreads: [],
423 providerSessions: [],
424 contextTransfers: [],
425 subagents: [coordinator],
426 };
427 const child = {
428 id: "workflow-chat",
429 title: "Coordinator chat",
430 lineage: { parentThreadId: "parent", relationshipToParent: "subagent" },
431 status: "completed",
432 activityRunStatus: null,
433 };
434 state.projection = projection;
435 state.shells = [{ environmentId: "test", source: child }];
436 const panel = (
437 <ThreadRelationshipsPanel
438 environmentId={EnvironmentId.make("test")}
439 threadId={ThreadId.make("parent")}
440 />
441 );
442 await act(async () => {
443 renderer = create(panel);
444 });
445 const text = () =>
446 renderer.root
447 .findAll((node) => typeof node.type === "string")
448 .flatMap((node) => node.children.filter((child) => typeof child === "string"))
449 .join("");
450 expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 1 running"]);
451 await act(async () =>
452 renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(),
453 );
454 expect(renderer.root.findByProps({ "aria-label": "Open Checker chat" })).toBeDefined();
455 state.shells = [{ environmentId: "test", source: { ...child, activityRunStatus: "waiting" } }];
456 await act(async () => renderer.update(cloneElement(panel)));
457 expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 1 running"]);
458 expect(text()).toContain("Inspecting the checkout workflow");
459 state.projection = {
460 ...projection,
461 subagents: [
462 {
463 ...coordinator,
464 status: "completed",
465 completedAt: DateTime.makeUnsafe("2026-09-21T12:10:00Z"),
466 },
467 ],
468 };
469 await act(async () => renderer.update(cloneElement(panel)));
470 expect(renderer.root.findByType("h3").children).toEqual(["Lineage"]);
471 expect(text()).toContain("Previous agents");
472 expect(renderer.root.findAllByProps({ "aria-label": "Open Checker chat" })).toHaveLength(0);
473 await act(async () =>
474 renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(),
475 );
476 expect(renderer.root.findByProps({ "aria-label": "Expand Checkout review" })).toBeDefined();
477 expect(text()).toContain("Workflow checks complete");
478 expect(text()).toContain("10m");
479});
480
56it.each([481it.each([
57 { driver: "codex", origin: "app_owned" },482 { driver: "codex", origin: "app_owned", runId: null, canStop: true },
58 { driver: "claudeAgent", origin: "app_owned" },483 { driver: "claudeAgent", origin: "app_owned", runId: null, canStop: true },
59 { driver: "claudeAgent", origin: "provider_native" },484 { driver: "claudeAgent", origin: "provider_native", runId: "run-1", canStop: true },
485 { driver: "claudeAgent", origin: "provider_native", runId: null, canStop: false },
60])(486])(
61 "stops active $origin $driver subagents without opening their thread",487 "offers individual Stop for supported $origin $driver subagents with run $runId",
62 async ({ driver, origin }) => {488 async ({ driver, origin, runId, canStop }) => {
63 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);489 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
64 const parent = {490 const parent = {
65 id: "parent",491 id: "parent",
...@@ -76,6 +502,7 @@ it.each([...@@ -76,6 +502,7 @@ it.each([
76 childThreadId: "child",502 childThreadId: "child",
77 origin,503 origin,
78 threadId: "parent",504 threadId: "parent",
505 runId,
79 nativeTaskRef: { driver, nativeId: "claude-task", strength: "strong" },506 nativeTaskRef: { driver, nativeId: "claude-task", strength: "strong" },
80 driver,507 driver,
81 providerInstanceId: "codex",508 providerInstanceId: "codex",
...@@ -108,6 +535,12 @@ it.each([...@@ -108,6 +535,12 @@ it.each([
108 await act(async () => {535 await act(async () => {
109 renderer = create(panel);536 renderer = create(panel);
110 });537 });
538 if (!canStop) {
539 expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(
540 0,
541 );
542 return;
543 }
111 const stopButton = () => renderer.root.findByProps({ "aria-label": "Stop subagent Worker" });544 const stopButton = () => renderer.root.findByProps({ "aria-label": "Stop subagent Worker" });
112 await act(async () => stopButton().props.onClick());545 await act(async () => stopButton().props.onClick());
113 expect(state.command).toHaveBeenCalledWith({546 expect(state.command).toHaveBeenCalledWith({
...@@ -397,11 +830,16 @@ it("shows readable models and only differing workspace details in agent tooltips...@@ -397,11 +830,16 @@ it("shows readable models and only differing workspace details in agent tooltips
397 };830 };
398 return read(renderer.root);831 return read(renderer.root);
399 };832 };
400 expect(text()).toContain("My GPT · high");833 const rerender = async (source = child) => {
401 state.projection = {834 state.shells = [{ environmentId: "test", source: { ...source } }];
835 await act(async () => renderer.update(cloneElement(panel)));
836 };
837 const nativeProjection = {
402 ...projection,838 ...projection,
403 subagents: [{ ...projection.subagents[0], origin: "provider_native" }],839 subagents: [{ ...projection.subagents[0], origin: "provider_native" }],
404 };840 };
841 expect(text()).toContain("My GPT · high");
842 state.projection = nativeProjection;
405 await act(async () => renderer.update(cloneElement(panel)));843 await act(async () => renderer.update(cloneElement(panel)));
406 expect(text()).toContain("My GPT");844 expect(text()).toContain("My GPT");
407 expect(text()).not.toContain("My GPT · high");845 expect(text()).not.toContain("My GPT · high");
...@@ -467,8 +905,7 @@ it("shows readable models and only differing workspace details in agent tooltips...@@ -467,8 +905,7 @@ it("shows readable models and only differing workspace details in agent tooltips
467 [undefined, ""],905 [undefined, ""],
468 ] as const) {906 ] as const) {
469 child.modelSelection.options = options;907 child.modelSelection.options = options;
470 state.shells = [{ environmentId: "test", source: { ...child } }];908 await rerender();
471 await act(async () => renderer.update(cloneElement(panel)));
472 expect(text()).toContain(`My GPT${expected}`);909 expect(text()).toContain(`My GPT${expected}`);
473 if (!expected) expect(text()).not.toContain("My GPT ·");910 if (!expected) expect(text()).not.toContain("My GPT ·");
474 }911 }
...@@ -532,8 +969,7 @@ it("shows readable models and only differing workspace details in agent tooltips...@@ -532,8 +969,7 @@ it("shows readable models and only differing workspace details in agent tooltips
532 { id: "reasoningEffort", value: "high" },969 { id: "reasoningEffort", value: "high" },
533 ...(value === undefined ? [] : [{ id: descriptor.id, value }]),970 ...(value === undefined ? [] : [{ id: descriptor.id, value }]),
534 ];971 ];
535 state.shells = [{ environmentId: "test", source: { ...child } }];972 await rerender();
536 await act(async () => renderer.update(cloneElement(panel)));
537 expect(text(true)).toContain("My GPT · high");973 expect(text(true)).toContain("My GPT · high");
538 expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/);974 expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/);
539 expect(text()).toContain(`My GPT · ${iconLabel}high`);975 expect(text()).toContain(`My GPT · ${iconLabel}high`);
...@@ -541,27 +977,11 @@ it("shows readable models and only differing workspace details in agent tooltips...@@ -541,27 +977,11 @@ it("shows readable models and only differing workspace details in agent tooltips
541 child.modelSelection.options = child.modelSelection.options.filter(977 child.modelSelection.options = child.modelSelection.options.filter(
542 ({ id }) => id !== "reasoningEffort",978 ({ id }) => id !== "reasoningEffort",
543 );979 );
544 state.shells = [{ environmentId: "test", source: { ...child } }];980 await rerender();
545 await act(async () => renderer.update(cloneElement(panel)));
546 expect(text()).not.toContain(" · high");981 expect(text()).not.toContain(" · high");
547 expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/);982 expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/);
548 if (iconLabel) expect(text()).toContain(iconLabel);983 if (iconLabel) expect(text()).toContain(iconLabel);
549 else expect(text()).not.toContain("mode on");984 else expect(text()).not.toContain("mode on");
550 state.projection = {
551 ...projection,
552 subagents: [{ ...projection.subagents[0], origin: "provider_native" }],
553 };
554 await act(async () => renderer.update(cloneElement(panel)));
555 expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/);
556 state.projection = projection;
557 for (const modelSelection of [
558 { ...child.modelSelection, instanceId: "other" },
559 { ...child.modelSelection, model: "gpt-5.5" },
560 ]) {
561 state.shells = [{ environmentId: "test", source: { ...child, modelSelection } }];
562 await act(async () => renderer.update(cloneElement(panel)));
563 expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/);
564 }
565 }985 }
566 state.configs.set("test", {986 state.configs.set("test", {
567 providers: [987 providers: [
...@@ -589,11 +1009,21 @@ it("shows readable models and only differing workspace details in agent tooltips...@@ -589,11 +1009,21 @@ it("shows readable models and only differing workspace details in agent tooltips
589 { id: "reasoningEffort", value: "high" },1009 { id: "reasoningEffort", value: "high" },
590 { id: "serviceTier", value: "priority" },1010 { id: "serviceTier", value: "priority" },
591 ];1011 ];
592 state.shells = [{ environmentId: "test", source: { ...child } }];1012 await rerender();
593 await act(async () => renderer.update(cloneElement(panel)));
594 expect(text(true)).toContain("My GPT · Work account · high");1013 expect(text(true)).toContain("My GPT · Work account · high");
595 expect(text()).toContain("My GPT · Work account · Fast mode onhigh");1014 expect(text()).toContain("My GPT · Work account · Fast mode onhigh");
596 expect(text()).not.toContain("Personal account");1015 expect(text()).not.toContain("Personal account");
1016 state.projection = nativeProjection;
1017 await act(async () => renderer.update(cloneElement(panel)));
1018 expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/);
1019 state.projection = projection;
1020 for (const modelSelection of [
1021 { ...child.modelSelection, instanceId: "other" },
1022 { ...child.modelSelection, model: "gpt-5.5" },
1023 ]) {
1024 await rerender({ ...child, modelSelection });
1025 expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/);
1026 }
597 state.shells = [];1027 state.shells = [];
598 for (const status of ["running", "completed", "failed"] as const) {1028 for (const status of ["running", "completed", "failed"] as const) {
599 state.projection = {1029 state.projection = {
...@@ -629,21 +1059,14 @@ it("shows readable models and only differing workspace details in agent tooltips...@@ -629,21 +1059,14 @@ it("shows readable models and only differing workspace details in agent tooltips
629 state.projection = projection;1059 state.projection = projection;
630 state.configs.set("test", speedConfig);1060 state.configs.set("test", speedConfig);
631 child.modelSelection.options = [{ id: "reasoningEffort", value: "high" }];1061 child.modelSelection.options = [{ id: "reasoningEffort", value: "high" }];
632 state.shells = [1062 await rerender({ ...child, modelSelection: { ...child.modelSelection, instanceId: "other" } });
633 {
634 environmentId: "test",
635 source: { ...child, modelSelection: { ...child.modelSelection, instanceId: "other" } },
636 },
637 ];
638 await act(async () => renderer.update(cloneElement(panel)));
639 expect(text()).not.toContain("My GPT ·");1063 expect(text()).not.toContain("My GPT ·");
640 const config = state.configs.get("test");1064 const config = state.configs.get("test");
641 state.configs.clear();1065 state.configs.clear();
642 await act(async () => renderer.update(cloneElement(panel)));1066 await act(async () => renderer.update(cloneElement(panel)));
643 expect(text()).toContain("GPT-5.4");1067 expect(text()).toContain("GPT-5.4");
644 expect(text()).not.toContain("GPT-5.4 ·");1068 expect(text()).not.toContain("GPT-5.4 ·");
645 state.shells = [{ environmentId: "test", source: { ...child } }];1069 await rerender();
646 await act(async () => renderer.update(cloneElement(panel)));
647 expect(text()).toContain("GPT-5.4 · high");1070 expect(text()).toContain("GPT-5.4 · high");
648 state.configs.set("test", config);1071 state.configs.set("test", config);
649 for (const model of ["custom/model-v1", "custom/model-v2"]) {1072 for (const model of ["custom/model-v1", "custom/model-v2"]) {
...@@ -651,13 +1074,10 @@ it("shows readable models and only differing workspace details in agent tooltips...@@ -651,13 +1074,10 @@ it("shows readable models and only differing workspace details in agent tooltips
651 ...projection,1074 ...projection,
652 subagents: [{ ...projection.subagents[0], model }],1075 subagents: [{ ...projection.subagents[0], model }],
653 };1076 };
654 state.shells = [1077 await rerender({
655 {1078 ...child,
656 environmentId: "test",1079 modelSelection: { ...child.modelSelection, model: "custom/model-v1" },
657 source: { ...child, modelSelection: { ...child.modelSelection, model: "custom/model-v1" } },1080 });
658 },
659 ];
660 await act(async () => renderer.update(cloneElement(panel)));
661 expect(text().includes(`${model} · high`)).toBe(model === "custom/model-v1");1081 expect(text().includes(`${model} · high`)).toBe(model === "custom/model-v1");
662 }1082 }
6631083
...@@ -692,16 +1112,14 @@ it("shows readable models and only differing workspace details in agent tooltips...@@ -692,16 +1112,14 @@ it("shows readable models and only differing workspace details in agent tooltips
692 state.projection = projection;1112 state.projection = projection;
6931113
694 child.worktreePath = "/main/worktrees/checker";1114 child.worktreePath = "/main/worktrees/checker";
695 state.shells = [{ environmentId: "test", source: { ...child } }];1115 await rerender();
696 await act(async () => renderer.update(cloneElement(panel)));
697 expect(text()).toContain("Worktree");1116 expect(text()).toContain("Worktree");
698 expect(text()).toContain("checker");1117 expect(text()).toContain("checker");
699 expect(text()).not.toContain("/main/worktrees");1118 expect(text()).not.toContain("/main/worktrees");
700 expect(text()).not.toContain("Project");1119 expect(text()).not.toContain("Project");
7011120
702 child.branch = "fix/checker";1121 child.branch = "fix/checker";
703 state.shells = [{ environmentId: "test", source: { ...child } }];1122 await rerender();
704 await act(async () => renderer.update(cloneElement(panel)));
705 expect(text()).toContain("Branch");1123 expect(text()).toContain("Branch");
706 expect(text()).toContain("fix/checker");1124 expect(text()).toContain("fix/checker");
707 expect(text()).not.toContain("Worktree");1125 expect(text()).not.toContain("Worktree");
...@@ -710,8 +1128,7 @@ it("shows readable models and only differing workspace details in agent tooltips...@@ -710,8 +1128,7 @@ it("shows readable models and only differing workspace details in agent tooltips
710 child.projectId = "other";1128 child.projectId = "other";
711 child.worktreePath = null;1129 child.worktreePath = null;
712 child.branch = null;1130 child.branch = null;
713 state.shells = [{ environmentId: "test", source: { ...child } }];1131 await rerender();
714 await act(async () => renderer.update(cloneElement(panel)));
715 expect(text()).toContain("Other project");1132 expect(text()).toContain("Other project");
716 expect(text()).toContain("Workspace");1133 expect(text()).toContain("Workspace");
717 expect(text()).toContain("other");1134 expect(text()).toContain("other");
apps/web/src/components/chat/ThreadRelationshipsControl.tsx+160-70
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { useAtomValue } from "@effect/atom-react";
1import { ThreadDetailsControl } from "./ThreadDetailsControl";2import { ThreadDetailsControl } from "./ThreadDetailsControl";
2import { ThreadHoverCardPopup } from "../ThreadHoverCard";3import { ThreadHoverCardPopup } from "../ThreadHoverCard";
3import { ThreadDetailsSection } from "./ThreadDetailsSection";4import { ThreadDetailsSection } from "./ThreadDetailsSection";
...@@ -6,6 +7,7 @@ import { SubagentTooltipContent } from "./SubagentTooltipContent";...@@ -6,6 +7,7 @@ import { SubagentTooltipContent } from "./SubagentTooltipContent";
6import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";7import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
7import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";8import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
8import {9import {
10 deriveWorkflowGroups,
9 projectedSubagentsToRuntime,11 projectedSubagentsToRuntime,
10 type RuntimeSubagent,12 type RuntimeSubagent,
11} from "@t3tools/client-runtime/state/subagentRuntime";13} from "@t3tools/client-runtime/state/subagentRuntime";
...@@ -24,11 +26,12 @@ import {...@@ -24,11 +26,12 @@ import {
24 canDetachThreadProviderSession,26 canDetachThreadProviderSession,
25 resolveLatestMergeBackRun,27 resolveLatestMergeBackRun,
26} from "@t3tools/client-runtime/state/thread-workflows";28} from "@t3tools/client-runtime/state/thread-workflows";
27import type {29import {
28 EnvironmentId,30 NodeId,
29 OrchestrationV2Subagent,31 type EnvironmentId,
30 OrchestrationV2ThreadShell,32 type OrchestrationV2Subagent,
31 ThreadId,33 type OrchestrationV2ThreadShell,
34 type ThreadId,
32} from "@t3tools/contracts";35} from "@t3tools/contracts";
33import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming";36import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming";
34import { groupBy } from "effect/Array";37import { groupBy } from "effect/Array";
...@@ -56,9 +59,11 @@ import {...@@ -56,9 +59,11 @@ import {
56 useThreadShells,59 useThreadShells,
57} from "../../state/entities";60} from "../../state/entities";
58import { threadEnvironment } from "../../state/threads";61import { threadEnvironment } from "../../state/threads";
62import { orchestrationEnvironment } from "../../state/orchestration";
59import { useAtomCommand } from "../../state/use-atom-command";63import { useAtomCommand } from "../../state/use-atom-command";
60import { AgentElapsed } from "./AgentElapsed";64import { AgentElapsed } from "./AgentElapsed";
61import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadRelationshipIcon";65import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadRelationshipIcon";
66import { ThreadLineageWorkflowRow } from "./ThreadLineageWorkflowRow";
6267
63import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";68import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
64import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";69import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
...@@ -97,11 +102,13 @@ export function ThreadLineageRowList(props: {...@@ -97,11 +102,13 @@ export function ThreadLineageRowList(props: {
97 component: this sits inside an already scrolling panel, where a102 component: this sits inside an already scrolling panel, where a
98 max-height-only virtual viewport measures badly. Every row is a focusable103 max-height-only virtual viewport measures badly. Every row is a focusable
99 button, so keyboard users reach and scroll the region through the rows104 button, so keyboard users reach and scroll the region through the rows
100 themselves and the container needs no extra tab stop of its own.105 themselves and the container needs no extra tab stop of its own. An
106 expanded workflow asks for a taller window, so it raises the bound while
107 its tree is open.
101 */}108 */}
102 <ul109 <ul
103 aria-label="Related threads"110 aria-label="Related threads"
104 className="m-0 max-h-[13.5rem] list-none overflow-y-auto overscroll-contain p-0"111 className="m-0 max-h-[13.5rem] list-none overflow-y-auto overscroll-contain p-0 has-data-workflow-expanded:max-h-[min(28rem,55dvh)]"
105 >112 >
106 {props.children}113 {props.children}
107 </ul>114 </ul>
...@@ -136,6 +143,7 @@ function ThreadLineageGroup(props: {...@@ -136,6 +143,7 @@ function ThreadLineageGroup(props: {
136 <div>143 <div>
137 {props.label ? (144 {props.label ? (
138 <CollapsibleSectionHeader145 <CollapsibleSectionHeader
146 variant="panel"
139 expanded={expanded}147 expanded={expanded}
140 onClick={() => setExpanded(!expanded)}148 onClick={() => setExpanded(!expanded)}
141 accessory={149 accessory={
...@@ -217,28 +225,39 @@ export function ThreadRelationshipsPanel(props: {...@@ -217,28 +225,39 @@ export function ThreadRelationshipsPanel(props: {
217 const ref = scopeThreadRef(props.environmentId, props.threadId);225 const ref = scopeThreadRef(props.environmentId, props.threadId);
218 const projection = useThreadProjection(ref)?.projection ?? null;226 const projection = useThreadProjection(ref)?.projection ?? null;
219 const providers = useServerConfigs().get(props.environmentId)?.providers;227 const providers = useServerConfigs().get(props.environmentId)?.providers;
220 const subagentsByThreadId = useMemo(228 const runtimeSubagents = useMemo(
221 () =>229 () => projectedSubagentsToRuntime(projection?.subagents ?? []),
222 new Map(
223 (projection?.subagents ?? [])
224 .filter((subagent) => subagent.childThreadId !== null)
225 .map((subagent) => [
226 subagent.childThreadId,
227 {
228 ...projectedSubagentsToRuntime([subagent])[0]!,
229 id: subagent.id,
230 threadId: subagent.threadId,
231 nativeTaskRef: subagent.nativeTaskRef,
232 nativeStatus: subagent.status,
233 driver: subagent.driver,
234 providerInstanceId: subagent.providerInstanceId,
235 origin: subagent.origin,
236 modelSelection: subagent.modelSelection,
237 },
238 ]),
239 ),
240 [projection?.subagents],230 [projection?.subagents],
241 );231 );
232 const subagentsByThreadId = useMemo(() => {
233 const byId = new Map(runtimeSubagents.map((agent) => [agent.id, agent]));
234 return new Map(
235 (projection?.subagents ?? [])
236 .filter((subagent) => subagent.childThreadId !== null)
237 .map((subagent) => [
238 subagent.childThreadId,
239 {
240 ...byId.get(subagent.id)!,
241 id: subagent.id,
242 threadId: subagent.threadId,
243 runId: subagent.runId,
244 nativeTaskRef: subagent.nativeTaskRef,
245 nativeStatus: subagent.status,
246 driver: subagent.driver,
247 providerInstanceId: subagent.providerInstanceId,
248 origin: subagent.origin,
249 modelSelection: subagent.modelSelection,
250 },
251 ]),
252 );
253 }, [projection?.subagents, runtimeSubagents]);
254 // A workflow coordinator is already a subagent row here; the grouped model
255 // is what lets that row unfold into the phases and members it ran.
256 const workflowGroupsById = useMemo(
257 () =>
258 new Map(deriveWorkflowGroups(runtimeSubagents).map((group) => [group.workflow.id, group])),
259 [runtimeSubagents],
260 );
242 const threadShells = useThreadShells();261 const threadShells = useThreadShells();
243 const projects = useProjects().filter((project) => project.environmentId === props.environmentId);262 const projects = useProjects().filter((project) => project.environmentId === props.environmentId);
244 const archived = useArchivedThreadSnapshots([props.environmentId]);263 const archived = useArchivedThreadSnapshots([props.environmentId]);
...@@ -260,8 +279,12 @@ export function ThreadRelationshipsPanel(props: {...@@ -260,8 +279,12 @@ export function ThreadRelationshipsPanel(props: {
260 const mergeBack = useAtomCommand(threadEnvironment.mergeBack);279 const mergeBack = useAtomCommand(threadEnvironment.mergeBack);
261 const stopSession = useAtomCommand(threadEnvironment.stopSession);280 const stopSession = useAtomCommand(threadEnvironment.stopSession);
262 const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn);281 const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn);
282 const stopWorkflowCommand = useAtomCommand(orchestrationEnvironment.stopWorkflow);
283 const canOperateWorkflow = useAtomValue(
284 orchestrationEnvironment.stopWorkflow.permissionAtom(props.environmentId),
285 );
263 const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null);286 const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null);
264 const [stoppingThreadId, setStoppingThreadId] = useState<ThreadId | null>(null);287 const [stoppingId, setStoppingId] = useState<string | null>(null);
265 const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection);288 const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection);
266 const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection);289 const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection);
267 const relationshipRows = useMemo(290 const relationshipRows = useMemo(
...@@ -271,8 +294,17 @@ export function ThreadRelationshipsPanel(props: {...@@ -271,8 +294,17 @@ export function ThreadRelationshipsPanel(props: {
271 rows: immediateThreadRelationships(graph, props.threadId),294 rows: immediateThreadRelationships(graph, props.threadId),
272 currentThreadId: props.threadId,295 currentThreadId: props.threadId,
273 mergeTargetThreadId,296 mergeTargetThreadId,
297 }).map((row) => {
298 const agent =
299 row.edge.kind === "subagent" && !isParentThreadRelationship(row.edge, props.threadId)
300 ? subagentsByThreadId.get(row.threadId)
301 : undefined;
302 const workflowGroup = agent === undefined ? undefined : workflowGroupsById.get(agent.id);
303 return workflowGroup === undefined
304 ? row
305 : { ...row, edge: { ...row.edge, status: workflowGroup.workflow.status } };
274 }),306 }),
275 [graph, mergeTargetThreadId, props.threadId],307 [graph, mergeTargetThreadId, props.threadId, subagentsByThreadId, workflowGroupsById],
276 );308 );
277 const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null;309 const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null;
278 const canDetach = projection ? canDetachThreadProviderSession(projection) : false;310 const canDetach = projection ? canDetachThreadProviderSession(projection) : false;
...@@ -341,8 +373,8 @@ export function ThreadRelationshipsPanel(props: {...@@ -341,8 +373,8 @@ export function ThreadRelationshipsPanel(props: {
341 childThreadId: ThreadId,373 childThreadId: ThreadId,
342 agent: Pick<OrchestrationV2Subagent, "id" | "origin" | "threadId">,374 agent: Pick<OrchestrationV2Subagent, "id" | "origin" | "threadId">,
343 ) => {375 ) => {
344 if (stoppingThreadId !== null) return;376 if (stoppingId !== null) return;
345 setStoppingThreadId(childThreadId);377 setStoppingId(childThreadId);
346 const result = await interruptTurn({378 const result = await interruptTurn({
347 environmentId: props.environmentId,379 environmentId: props.environmentId,
348 input:380 input:
...@@ -350,12 +382,25 @@ export function ThreadRelationshipsPanel(props: {...@@ -350,12 +382,25 @@ export function ThreadRelationshipsPanel(props: {
350 ? { threadId: agent.threadId, subagentId: agent.id }382 ? { threadId: agent.threadId, subagentId: agent.id }
351 : { threadId: childThreadId },383 : { threadId: childThreadId },
352 });384 });
353 setStoppingThreadId(null);385 setStoppingId(null);
354 if (result._tag === "Failure") {386 if (result._tag === "Failure") {
355 toastManager.add({ type: "error", title: "Could not stop subagent" });387 toastManager.add({ type: "error", title: "Could not stop subagent" });
356 }388 }
357 };389 };
358390
391 const stopWorkflow = async (subagentId: string) => {
392 if (stoppingId !== null) return;
393 setStoppingId(subagentId);
394 const result = await stopWorkflowCommand({
395 environmentId: props.environmentId,
396 input: { threadId: props.threadId, subagentId: NodeId.make(subagentId) },
397 });
398 setStoppingId(null);
399 if (result._tag === "Failure") {
400 toastManager.add({ type: "error", title: "Could not stop workflow" });
401 }
402 };
403
359 const parentTitle =404 const parentTitle =
360 mergeTargetThreadId === null405 mergeTargetThreadId === null
361 ? null406 ? null
...@@ -400,33 +445,41 @@ export function ThreadRelationshipsPanel(props: {...@@ -400,33 +445,41 @@ export function ThreadRelationshipsPanel(props: {
400 const isSubagent = edge.kind === "subagent";445 const isSubagent = edge.kind === "subagent";
401 const isMergeTarget = threadId === mergeTargetThreadId;446 const isMergeTarget = threadId === mergeTargetThreadId;
402 const isParent = isParentThreadRelationship(edge, props.threadId);447 const isParent = isParentThreadRelationship(edge, props.threadId);
403 const status = threadRelationshipRowStatus(graph, { threadId, edge });448 const rowStatus = threadRelationshipRowStatus(graph, { threadId, edge });
404 const RelationshipIcon = isParent449 const RelationshipIcon = isParent
405 ? CornerLeftUpIcon450 ? CornerLeftUpIcon
406 : isSubagent451 : isSubagent
407 ? BotIcon452 ? BotIcon
408 : GitForkIcon;453 : GitForkIcon;
409 const relationship = relationshipLabel(edge, props.threadId);454 const relationship = relationshipLabel(edge, props.threadId);
410 const agent = currentSubagent(455 const projectedAgent =
411 isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined,456 isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined;
412 node?.thread,457 const workflowGroup =
413 );458 projectedAgent === undefined
414 const failed = status === "failed" || status === "error";459 ? undefined
460 : workflowGroupsById.get(projectedAgent.id);
461 const agent = workflowGroup
462 ? projectedAgent
463 : currentSubagent(projectedAgent, node?.thread);
464 const failed = rowStatus === "failed" || rowStatus === "error";
415 const canStop =465 const canStop =
416 agent &&466 agent &&
417 (agent.origin === "app_owned" ||467 (agent.origin === "app_owned" ||
418 (agent.origin === "provider_native" &&468 (agent.origin === "provider_native" &&
419 agent.driver === "claudeAgent" &&469 agent.driver === "claudeAgent" &&
470 agent.runId != null &&
420 agent.nativeTaskRef?.strength === "strong" &&471 agent.nativeTaskRef?.strength === "strong" &&
421 agent.nativeTaskRef.nativeId !== null &&472 agent.nativeTaskRef.nativeId !== null &&
422 ["pending", "running", "waiting"].includes(agent.nativeStatus))) &&473 ["pending", "running", "waiting"].includes(agent.nativeStatus))) &&
423 agent.startedAt &&474 agent.startedAt &&
424 ["pending", "running", "waiting"].includes(agent.status);475 ["pending", "running", "waiting"].includes(agent.status);
425 const trailingVisibilityClass = canStop
426 ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0"
427 : "";
428 const threadTitle = relationshipThreadTitle({476 const threadTitle = relationshipThreadTitle({
429 title: node?.thread?.title ?? agent?.title ?? threadId,477 // A workflow's name is shorter than its child thread's title.
478 title:
479 workflowGroup?.workflow.workflowName ??
480 node?.thread?.title ??
481 agent?.title ??
482 threadId,
430 isSubagent,483 isSubagent,
431 });484 });
432 const provider = providers?.find(485 const provider = providers?.find(
...@@ -435,6 +488,14 @@ export function ThreadRelationshipsPanel(props: {...@@ -435,6 +488,14 @@ export function ThreadRelationshipsPanel(props: {
435 (agent?.providerInstanceId ?? node?.thread?.providerInstanceId),488 (agent?.providerInstanceId ?? node?.thread?.providerInstanceId),
436 );489 );
437 const providerDriver = agent?.driver ?? provider?.driver;490 const providerDriver = agent?.driver ?? provider?.driver;
491 const canStopWorkflow =
492 canOperateWorkflow &&
493 workflowGroup?.workflow.status === "running" &&
494 agent?.origin === "provider_native" &&
495 providerDriver === "claudeAgent";
496 const trailingVisibilityClass = (workflowGroup ? canStopWorkflow : canStop)
497 ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0"
498 : "";
438 const project = projects.find((project) => project.id === node?.thread?.projectId);499 const project = projects.find((project) => project.id === node?.thread?.projectId);
439 const relationshipHint = node?.missing500 const relationshipHint = node?.missing
440 ? "This related thread is unavailable"501 ? "This related thread is unavailable"
...@@ -468,7 +529,7 @@ export function ThreadRelationshipsPanel(props: {...@@ -468,7 +529,7 @@ export function ThreadRelationshipsPanel(props: {
468 driver={isSubagent && !isParent ? providerDriver : undefined}529 driver={isSubagent && !isParent ? providerDriver : undefined}
469 provider={provider}530 provider={provider}
470 fallbackIcon={RelationshipIcon}531 fallbackIcon={RelationshipIcon}
471 status={status}532 status={rowStatus}
472 />533 />
473 <span className="min-w-0 flex-1">534 <span className="min-w-0 flex-1">
474 <span className="block truncate text-left text-sm font-medium leading-4 text-foreground/85">535 <span className="block truncate text-left text-sm font-medium leading-4 text-foreground/85">
...@@ -478,23 +539,68 @@ export function ThreadRelationshipsPanel(props: {...@@ -478,23 +539,68 @@ export function ThreadRelationshipsPanel(props: {
478 {agent ? null : (539 {agent ? null : (
479 <ArrowRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />540 <ArrowRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
480 )}541 )}
481 {/* One trailing item keeps room for the title: the icon dot already542 {agent &&
482 carries status, so an agent with a known time shows only that. */}543 !failed &&
483 {agent && !failed && deriveSubagentElapsedMs(agent, 0) !== null ? (544 (deriveSubagentElapsedMs(agent, 0) !== null || canStopWorkflow) ? (
484 <span545 <span
485 className={`shrink-0 text-2xs font-normal tabular-nums text-muted-foreground ${trailingVisibilityClass}`}546 className={`shrink-0 text-2xs font-normal tabular-nums text-muted-foreground ${canStopWorkflow ? "min-w-6 text-end" : ""} ${trailingVisibilityClass}`}
486 >547 >
487 <AgentElapsed agent={agent} compact />548 <AgentElapsed agent={agent} compact />
488 </span>549 </span>
489 ) : !isMergeTarget ? (550 ) : !isMergeTarget ? (
490 <span551 <span
491 className={`shrink-0 text-2xs ${failed ? "text-destructive" : "text-muted-foreground"} ${trailingVisibilityClass}`}552 className={
553 workflowGroup
554 ? "sr-only"
555 : `shrink-0 text-2xs ${failed ? "text-destructive" : "text-muted-foreground"} ${trailingVisibilityClass}`
556 }
492 >557 >
493 {threadRelationshipStatusLabel(status)}558 {threadRelationshipStatusLabel(rowStatus)}
494 </span>559 </span>
495 ) : null}560 ) : null}
496 </>561 </>
497 );562 );
563 // A workflow pairs this row with a disclosure, so it renders as the
564 // leading half of a split row; everything else renders it directly.
565 const relationshipLink = (
566 <Tooltip>
567 <TooltipTrigger
568 delay={200}
569 render={
570 <ThreadDetailsControl
571 size="sm"
572 variant="ghost"
573 part={workflowGroup ? "primary" : "row"}
574 disabled={node?.missing === true}
575 onClick={() => openThread(threadId)}
576 />
577 }
578 >
579 {relationshipContent}
580 </TooltipTrigger>
581 <RelationshipPopup side="left">{relationshipTooltip}</RelationshipPopup>
582 </Tooltip>
583 );
584 if (workflowGroup && agent) {
585 return (
586 <ThreadLineageWorkflowRow
587 key={threadId}
588 group={workflowGroup}
589 providerInstanceId={agent.providerInstanceId}
590 provider={provider}
591 providers={providers}
592 driver={providerDriver}
593 onOpenThread={(memberThreadId) => openThread(memberThreadId as ThreadId)}
594 isThreadAvailable={(memberThreadId) =>
595 graph.nodes.get(memberThreadId as ThreadId)?.missing === false
596 }
597 onStop={canStopWorkflow ? () => void stopWorkflow(agent.id) : undefined}
598 stopping={stoppingId === agent.id}
599 stopDisabled={stoppingId !== null}
600 header={relationshipLink}
601 />
602 );
603 }
498 return (604 return (
499 <li key={threadId} className="group relative flex h-8 items-center rounded-lg">605 <li key={threadId} className="group relative flex h-8 items-center rounded-lg">
500 {isMergeTarget ? (606 {isMergeTarget ? (
...@@ -507,7 +613,7 @@ export function ThreadRelationshipsPanel(props: {...@@ -507,7 +613,7 @@ export function ThreadRelationshipsPanel(props: {
507 size="sm"613 size="sm"
508 variant="ghost"614 variant="ghost"
509 part="primary"615 part="primary"
510 aria-label={`${threadTitle} ${threadRelationshipStatusLabel(status)}`}616 aria-label={`${threadTitle} ${threadRelationshipStatusLabel(rowStatus)}`}
511 disabled={node?.missing === true}617 disabled={node?.missing === true}
512 onClick={() => openThread(threadId)}618 onClick={() => openThread(threadId)}
513 />619 />
...@@ -553,27 +659,11 @@ export function ThreadRelationshipsPanel(props: {...@@ -553,27 +659,11 @@ export function ThreadRelationshipsPanel(props: {
553 </TooltipPopup>659 </TooltipPopup>
554 </Tooltip>660 </Tooltip>
555 <span className="shrink-0 border border-transparent ps-1 pe-2.5 text-2xs font-medium text-muted-foreground">661 <span className="shrink-0 border border-transparent ps-1 pe-2.5 text-2xs font-medium text-muted-foreground">
556 {threadRelationshipStatusLabel(status)}662 {threadRelationshipStatusLabel(rowStatus)}
557 </span>663 </span>
558 </div>664 </div>
559 ) : (665 ) : (
560 <Tooltip>666 relationshipLink
561 <TooltipTrigger
562 delay={200}
563 render={
564 <ThreadDetailsControl
565 size="sm"
566 variant="ghost"
567 disabled={node?.missing === true}
568 onClick={() => openThread(threadId)}
569 part="row"
570 />
571 }
572 >
573 {relationshipContent}
574 </TooltipTrigger>
575 <RelationshipPopup side="left">{relationshipTooltip}</RelationshipPopup>
576 </Tooltip>
577 )}667 )}
578 {canStop && agent ? (668 {canStop && agent ? (
579 <div className="pointer-events-none absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">669 <div className="pointer-events-none absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">
...@@ -586,12 +676,12 @@ export function ThreadRelationshipsPanel(props: {...@@ -586,12 +676,12 @@ export function ThreadRelationshipsPanel(props: {
586 part="icon"676 part="icon"
587 tone="destructive"677 tone="destructive"
588 aria-label={`Stop subagent ${threadTitle}`}678 aria-label={`Stop subagent ${threadTitle}`}
589 disabled={stoppingThreadId !== null}679 disabled={stoppingId !== null}
590 onClick={() => void stopSubagent(threadId, agent)}680 onClick={() => void stopSubagent(threadId, agent)}
591 />681 />
592 }682 }
593 >683 >
594 {stoppingThreadId === threadId ? (684 {stoppingId === threadId ? (
595 <LoaderCircleIcon aria-hidden className="size-3 animate-spin" />685 <LoaderCircleIcon aria-hidden className="size-3 animate-spin" />
596 ) : (686 ) : (
597 <SquareIcon aria-hidden className="size-3 fill-current" />687 <SquareIcon aria-hidden className="size-3 fill-current" />
apps/web/src/components/ui/collapsible-section-header.tsx+4-1
...@@ -14,11 +14,13 @@ export function CollapsibleSectionHeader({...@@ -14,11 +14,13 @@ export function CollapsibleSectionHeader({
14 children,14 children,
15 expanded,15 expanded,
16 tone = "muted",16 tone = "muted",
17 variant = "default",
17 accessory,18 accessory,
18 ...buttonProps19 ...buttonProps
19}: Omit<ComponentProps<"button">, "className" | "style" | "aria-expanded"> & {20}: Omit<ComponentProps<"button">, "className" | "style" | "aria-expanded"> & {
20 expanded: boolean;21 expanded: boolean;
21 tone?: keyof typeof tones;22 tone?: keyof typeof tones;
23 variant?: "default" | "panel";
22 accessory?: ReactNode;24 accessory?: ReactNode;
23}) {25}) {
24 return (26 return (
...@@ -27,7 +29,8 @@ export function CollapsibleSectionHeader({...@@ -27,7 +29,8 @@ export function CollapsibleSectionHeader({
27 type="button"29 type="button"
28 aria-expanded={expanded}30 aria-expanded={expanded}
29 className={cn(31 className={cn(
30 "flex h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",32 "flex h-8 w-full cursor-pointer items-center gap-2 rounded-md text-left text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
33 variant === "panel" ? "ps-2 pe-2.5" : "px-2",
31 tones[tone].label,34 tones[tone].label,
32 )}35 )}
33 >36 >
packages/client-runtime/src/state/orchestration.ts+4
...@@ -44,6 +44,10 @@ export function createOrchestrationEnvironmentAtoms<R, E>(...@@ -44,6 +44,10 @@ export function createOrchestrationEnvironmentAtoms<R, E>(
44 staleTimeMs: 300_000,44 staleTimeMs: 300_000,
45 idleTtlMs: 300_000,45 idleTtlMs: 300_000,
46 }),46 }),
47 stopWorkflow: createEnvironmentRpcCommand(runtime, {
48 label: "environment-data:orchestration:stop-workflow",
49 tag: ORCHESTRATION_V2_WS_METHODS.stopWorkflow,
50 }),
47 // Keyed by the item revision, so a live row refetches as its output grows.51 // Keyed by the item revision, so a live row refetches as its output grows.
48 turnItem: createEnvironmentRpcQueryAtomFamily(runtime, {52 turnItem: createEnvironmentRpcQueryAtomFamily(runtime, {
49 label: "environment-data:orchestration:turn-item",53 label: "environment-data:orchestration:turn-item",
packages/client-runtime/src/state/subagentRuntime.test.ts created+220
...@@ -0,0 +1,220 @@
1import { describe, expect, it } from "vite-plus/test";
2import type { OrchestrationV2Subagent, OrchestrationV2SubagentWorkflow } from "@t3tools/contracts";
3import * as DateTime from "effect/DateTime";
4
5import { deriveWorkflowGroups, projectedSubagentsToRuntime } from "./subagentRuntime.ts";
6
7const at = (iso: string) => DateTime.makeUnsafe(iso);
8
9type ProjectedSubagent = Parameters<typeof projectedSubagentsToRuntime>[0][number];
10
11function subagent(overrides: Partial<ProjectedSubagent> = {}): ProjectedSubagent {
12 return {
13 id: "coordinator",
14 title: "probe",
15 prompt: "export const meta = …",
16 model: "claude-opus-5[1m]",
17 status: "running" satisfies OrchestrationV2Subagent["status"],
18 result: null,
19 startedAt: at("2026-08-01T10:00:00.000Z"),
20 completedAt: null,
21 updatedAt: at("2026-08-01T10:00:05.000Z"),
22 ...overrides,
23 };
24}
25
26const member = (
27 overrides: Partial<OrchestrationV2SubagentWorkflow["agents"][number]> = {},
28): OrchestrationV2SubagentWorkflow["agents"][number] => ({
29 index: 1,
30 label: "alpha:one",
31 state: "running",
32 phaseIndex: 1,
33 phaseTitle: "Alpha",
34 ...overrides,
35});
36
37const groupsOf = (subagents: ReadonlyArray<ProjectedSubagent>) =>
38 deriveWorkflowGroups(projectedSubagentsToRuntime(subagents));
39
40describe("projectedSubagentsToRuntime workflow expansion", () => {
41 it("leaves a plain subagent flat", () => {
42 const [agent, ...rest] = projectedSubagentsToRuntime([subagent()]);
43 expect(rest).toEqual([]);
44 expect(agent?.kind).toBe("subagent");
45 expect(agent?.phases).toEqual([]);
46 });
47
48 it("projects a coordinator plus one row per member", () => {
49 const rows = projectedSubagentsToRuntime([
50 subagent({
51 workflow: {
52 name: "probe-wf",
53 phases: [
54 { index: 1, title: "Alpha" },
55 { index: 2, title: "Beta" },
56 ],
57 agents: [member(), member({ index: 2, label: "alpha:two", state: "queued" })],
58 totalTokens: 1000,
59 },
60 }),
61 ]);
62 expect(rows.map((row) => row.kind)).toEqual(["workflow", "workflow_agent", "workflow_agent"]);
63 expect(rows[0]?.workflowName).toBe("probe-wf");
64 expect(rows[0]?.usage?.totalTokens).toBe(1000);
65 expect(rows[1]).toMatchObject({
66 id: "coordinator:agent:1",
67 title: "alpha:one",
68 parentAgentId: "coordinator",
69 phaseIndex: 1,
70 phaseTitle: "Alpha",
71 status: "running",
72 });
73 // A queued member is not started work: it must not report an elapsed clock.
74 expect(rows[2]?.status).toBe("pending");
75 expect(rows[2]?.startedAt).toBeNull();
76 });
77
78 it("derives a settled member's end from its reported duration", () => {
79 const [, settled] = projectedSubagentsToRuntime([
80 subagent({
81 workflow: {
82 phases: [],
83 agents: [
84 member({
85 state: "completed",
86 startedAt: Date.UTC(2026, 7, 1, 10, 0, 0),
87 durationMs: 1500,
88 totalTokens: 71_141,
89 toolCalls: 3,
90 prompt: "Reply with exactly: A1",
91 result: "A1",
92 }),
93 ],
94 },
95 }),
96 ]);
97 expect(settled?.status).toBe("completed");
98 expect(settled?.completedAt).toBe("2026-08-01T10:00:01.500Z");
99 expect(settled?.result).toBe("A1");
100 // The prompt stays on progress so the detail view can show both halves.
101 expect(settled?.progress).toBe("Reply with exactly: A1");
102 expect(settled?.usage).toEqual({ totalTokens: 71_141, toolUses: 3, durationMs: 1500 });
103 });
104
105 it.each(["completed", "cancelled", "failed"] as const)(
106 "settles unfinished members when the coordinator is %s",
107 (status) => {
108 const groups = groupsOf([
109 subagent({
110 status,
111 completedAt: at("2026-08-01T10:00:09.000Z"),
112 workflow: {
113 phases: [],
114 agents: [
115 member(),
116 member({ index: 2, state: "queued" }),
117 member({ index: 3, state: "completed" }),
118 ],
119 },
120 }),
121 ]);
122 // The settled run keeps its members reachable under the coordinator.
123 expect(groups[0]?.workflow.status).toBe(status);
124 const members = groups[0]?.phases.flatMap((phase) => phase.members);
125 expect(members?.map((agent) => agent.status)).toEqual([status, status, "completed"]);
126 expect(members?.[0]?.completedAt).toBe("2026-08-01T10:00:09.000Z");
127 },
128 );
129
130 it("surfaces a retried member through the row's activation count", () => {
131 const [, retried] = projectedSubagentsToRuntime([
132 subagent({ workflow: { phases: [], agents: [member({ attempt: 3 })] } }),
133 ]);
134 expect(retried?.activationCount).toBe(3);
135 expect(retried?.attempt).toBe(3);
136 });
137});
138
139describe("deriveWorkflowGroups over a dynamic workflow", () => {
140 it("groups members under their phases and counts the run", () => {
141 const groups = groupsOf([
142 subagent({
143 workflow: {
144 name: "probe-wf",
145 phases: [
146 { index: 1, title: "Alpha" },
147 { index: 2, title: "Beta" },
148 ],
149 agents: [
150 member({ state: "completed" }),
151 member({ index: 2, label: "alpha:two", state: "failed" }),
152 member({ index: 3, label: "beta:one", phaseIndex: 2, phaseTitle: "Beta" }),
153 member({
154 index: 4,
155 label: "beta:two",
156 phaseIndex: 2,
157 phaseTitle: "Beta",
158 state: "queued",
159 }),
160 ],
161 },
162 }),
163 ]);
164 const group = groups[0];
165 expect(group?.phases.map((phase) => [phase.title, phase.state])).toEqual([
166 ["Alpha", "done"],
167 ["Beta", "running"],
168 ]);
169 expect(group?.phases.map((phase) => phase.settledCount)).toEqual([2, 0]);
170 expect(group?.phases.flatMap((phase) => phase.members)).toHaveLength(4);
171 expect(group?.unphasedMembers).toEqual([]);
172 });
173
174 it("shows a phase the script only reached at runtime", () => {
175 const groups = groupsOf([
176 subagent({
177 workflow: {
178 // The declared plan has not caught up with the member's phase yet.
179 phases: [{ index: 1, title: "Alpha" }],
180 agents: [
181 member({ state: "completed" }),
182 member({ index: 2, label: "dynamic:late", phaseIndex: 3, phaseTitle: "Gamma" }),
183 ],
184 },
185 }),
186 ]);
187 const group = groups[0];
188 expect(group?.phases.map((phase) => phase.title)).toEqual(["Alpha", "Gamma"]);
189 expect(group?.unphasedMembers).toEqual([]);
190 });
191
192 it.each([false, true])("keeps a reported phase title, missing title first=%s", (missingFirst) => {
193 const agents = [member({ phaseTitle: "Review" }), member({ index: 2, phaseTitle: undefined })];
194 const groups = groupsOf([
195 subagent({ workflow: { phases: [], agents: missingFirst ? agents.toReversed() : agents } }),
196 ]);
197 expect(groups[0]?.phases[0]?.title).toBe("Review");
198 });
199
200 it("keeps unphased members with their workflow and excludes unrelated agents", () => {
201 const runtime = projectedSubagentsToRuntime([
202 subagent({ id: "ordinary" }),
203 subagent({
204 id: "__proto__",
205 workflow: {
206 phases: [],
207 agents: [member({ phaseIndex: undefined, phaseTitle: undefined })],
208 },
209 }),
210 subagent({ id: "constructor", workflow: { phases: [], agents: [] } }),
211 subagent({ id: "orphan", workflow: { phases: [], agents: [member()] } }),
212 ]);
213 const groups = deriveWorkflowGroups(runtime.filter((agent) => agent.id !== "orphan"));
214 expect(groups.map((group) => group.workflow.id)).toEqual(["__proto__", "constructor"]);
215 expect(groups[0]?.phases).toEqual([]);
216 expect(groups[0]?.unphasedMembers.map((agent) => agent.title)).toEqual(["alpha:one"]);
217 expect(groups[1]?.phases).toEqual([]);
218 expect(groups[1]?.unphasedMembers).toEqual([]);
219 });
220});
packages/client-runtime/src/state/subagentRuntime.ts+184-20
...@@ -2,8 +2,13 @@...@@ -2,8 +2,13 @@
2 * Subagent status helpers shared by web and mobile, and the runtime shape the2 * Subagent status helpers shared by web and mobile, and the runtime shape the
3 * web agent rows render.3 * web agent rows render.
4 */4 */
5import { groupBy } from "effect/Array";
5import * as DateTime from "effect/DateTime";6import * as DateTime from "effect/DateTime";
6import type { OrchestrationV2Subagent, OrchestrationV2TurnItem } from "@t3tools/contracts";7import type {
8 OrchestrationV2Subagent,
9 OrchestrationV2SubagentWorkflow,
10 OrchestrationV2TurnItem,
11} from "@t3tools/contracts";
7import { isOrchestrationV2WorkActive } from "@t3tools/contracts";12import { isOrchestrationV2WorkActive } from "@t3tools/contracts";
813
9export type RuntimeSubagentStatus =14export type RuntimeSubagentStatus =
...@@ -36,11 +41,12 @@ export interface SubagentWorkflowPhase {...@@ -36,11 +41,12 @@ export interface SubagentWorkflowPhase {
36 readonly title: string;41 readonly title: string;
37}42}
3843
44// Optional-and-undefined, matching the contract's own optionals.
39export interface SubagentRunHandles {45export interface SubagentRunHandles {
40 readonly runId?: string;46 readonly runId?: string | undefined;
41 readonly scriptPath?: string;47 readonly scriptPath?: string | undefined;
42 readonly transcriptDir?: string;48 readonly transcriptDir?: string | undefined;
43 readonly sessionUrl?: string;49 readonly sessionUrl?: string | undefined;
44}50}
4551
46export interface RuntimeSubagent {52export interface RuntimeSubagent {
...@@ -66,6 +72,8 @@ export interface RuntimeSubagent {...@@ -66,6 +72,8 @@ export interface RuntimeSubagent {
66 readonly workflowName: string | null;72 readonly workflowName: string | null;
67 readonly phases: ReadonlyArray<SubagentWorkflowPhase>;73 readonly phases: ReadonlyArray<SubagentWorkflowPhase>;
68 readonly runHandles: SubagentRunHandles | null;74 readonly runHandles: SubagentRunHandles | null;
75 /** The thread this agent owns, when it has one. */
76 readonly childThreadId: string | null;
69 readonly recentActivity: ReadonlyArray<SubagentActivityEntry>;77 readonly recentActivity: ReadonlyArray<SubagentActivityEntry>;
70 /** First retained observation, used as the roster's stable display order. */78 /** First retained observation, used as the roster's stable display order. */
71 readonly firstSeenAt: string;79 readonly firstSeenAt: string;
...@@ -91,18 +99,105 @@ export function isActiveSubagentStatus(status: RuntimeSubagentStatus): boolean {...@@ -91,18 +99,105 @@ export function isActiveSubagentStatus(status: RuntimeSubagentStatus): boolean {
91 return isOrchestrationV2WorkActive(status);99 return isOrchestrationV2WorkActive(status);
92}100}
93101
94/**
95 * A subagent card whose child is still working. The card outlives its
96 * launching turn: once that turn settles, the waiting footer counts the child,
97 * so the card stays out of the turn's fold until the child finishes.
98 */
99export function isLiveSubagentTurnItem(item: OrchestrationV2TurnItem): boolean {102export function isLiveSubagentTurnItem(item: OrchestrationV2TurnItem): boolean {
100 return item.type === "subagent" && isOrchestrationV2WorkActive(item.status);103 return item.type === "subagent" && isOrchestrationV2WorkActive(item.status);
101}104}
102105
106/** A workflow coordinator with its members grouped by phase, as Lineage renders it. */
107export interface SubagentWorkflowGroup {
108 readonly workflow: RuntimeSubagent;
109 readonly phases: ReadonlyArray<{
110 readonly index: number;
111 readonly title: string;
112 readonly members: ReadonlyArray<RuntimeSubagent>;
113 /** done = every member settled (success or error); running = any active. */
114 readonly state: "pending" | "running" | "done";
115 readonly settledCount: number;
116 }>;
117 /** Members with no resolvable phase (orphans render under the workflow). */
118 readonly unphasedMembers: ReadonlyArray<RuntimeSubagent>;
119}
120
121function workflowUsage(source: {
122 readonly totalTokens?: number | undefined;
123 readonly toolCalls?: number | undefined;
124 readonly durationMs?: number | undefined;
125}): SubagentUsage | null {
126 if (source.totalTokens === undefined) return null;
127 return {
128 totalTokens: source.totalTokens,
129 ...(source.toolCalls === undefined ? {} : { toolUses: source.toolCalls }),
130 ...(source.durationMs === undefined ? {} : { durationMs: source.durationMs }),
131 };
132}
133
134function isoFromEpochMillis(value: number | undefined): string | null {
135 return value === undefined ? null : DateTime.formatIso(DateTime.makeUnsafe(value));
136}
137
138/**
139 * Expands a coordinator's workflow roster into member rows. Members are
140 * synthesized from the coordinator's snapshot rather than read from their own
141 * subagent entities, so a parent thread's Lineage can show them without
142 * loading the coordinator's child thread; their identity is the coordinator id
143 * plus the spawn ordinal.
144 */
145function workflowMembersToRuntime(
146 coordinator: RuntimeSubagent,
147 workflow: OrchestrationV2SubagentWorkflow,
148): ReadonlyArray<RuntimeSubagent> {
149 return workflow.agents.map((agent) => {
150 // Every member state but queued is already a runtime status.
151 const status =
152 (agent.state === "queued" || agent.state === "running") &&
153 isTerminalSubagentStatus(coordinator.status)
154 ? coordinator.status
155 : agent.state === "queued"
156 ? "pending"
157 : agent.state;
158 const failed = status === "failed";
159 const startedAt = isoFromEpochMillis(agent.startedAt);
160 // The provider reports a settled member's duration, not its end instant.
161 const completedAt = isTerminalSubagentStatus(status)
162 ? agent.startedAt !== undefined && agent.durationMs !== undefined
163 ? isoFromEpochMillis(agent.startedAt + agent.durationMs)
164 : coordinator.completedAt
165 : null;
166 return {
167 // Members share the run's handles: the transcript directory is what
168 // makes their conversation readable, and only the run knows it.
169 ...coordinator,
170 id: `${coordinator.id}:agent:${agent.index}`,
171 kind: "workflow_agent" as const,
172 title: agent.label,
173 model: agent.model ?? null,
174 status,
175 // A member the workflow retried counts each attempt as a run.
176 activationCount: agent.attempt ?? 1,
177 usage: workflowUsage(agent),
178 // A member keeps its prompt on `progress` even once settled: that is the
179 // question half of its conversation, and the detail view shows both halves.
180 progress: agent.prompt ?? null,
181 result: failed ? null : (agent.result ?? null),
182 error: failed ? (agent.result ?? null) : null,
183 parentAgentId: coordinator.id,
184 agentIndex: agent.index,
185 phaseIndex: agent.phaseIndex ?? null,
186 phaseTitle: agent.phaseTitle ?? null,
187 attempt: agent.attempt ?? null,
188 phases: [],
189 childThreadId: agent.childThreadId ?? null,
190 recentActivity: [],
191 firstSeenAt: isoFromEpochMillis(agent.queuedAt) ?? startedAt ?? coordinator.firstSeenAt,
192 startedAt,
193 completedAt,
194 updatedAt: completedAt ?? startedAt ?? coordinator.firstSeenAt,
195 } satisfies RuntimeSubagent;
196 });
197}
198
103/**199/**
104 * Projects orchestration-v2 subagent entities into the runtime shape the web200 * Projects subagents and workflow members into the runtime roster.
105 * agent rows render.
106 */201 */
107export function projectedSubagentsToRuntime(202export function projectedSubagentsToRuntime(
108 subagents: ReadonlyArray<{203 subagents: ReadonlyArray<{
...@@ -112,18 +207,23 @@ export function projectedSubagentsToRuntime(...@@ -112,18 +207,23 @@ export function projectedSubagentsToRuntime(
112 readonly model: string | null;207 readonly model: string | null;
113 readonly status: OrchestrationV2Subagent["status"];208 readonly status: OrchestrationV2Subagent["status"];
114 readonly progress?: string | undefined;209 readonly progress?: string | undefined;
210 readonly childThreadId?: string | null | undefined;
211 readonly workflow?: OrchestrationV2SubagentWorkflow | undefined;
115 readonly result: string | null;212 readonly result: string | null;
116 readonly startedAt: DateTime.Utc | null;213 readonly startedAt: DateTime.Utc | null;
117 readonly completedAt: DateTime.Utc | null;214 readonly completedAt: DateTime.Utc | null;
118 readonly updatedAt: DateTime.Utc;215 readonly updatedAt: DateTime.Utc;
119 }>,216 }>,
120): ReadonlyArray<RuntimeSubagent> {217): ReadonlyArray<RuntimeSubagent> {
121 return subagents.map((subagent) => {218 return subagents.flatMap((subagent) => {
122 const updatedAt = DateTime.formatIso(subagent.updatedAt);219 const updatedAt = DateTime.formatIso(subagent.updatedAt);
123 const startedAt = subagent.startedAt === null ? null : DateTime.formatIso(subagent.startedAt);220 const startedAt = subagent.startedAt === null ? null : DateTime.formatIso(subagent.startedAt);
124 return {221 const firstSeenAt = startedAt ?? updatedAt;
222 const { workflow } = subagent;
223 const runHandles = workflow?.runHandles ?? null;
224 const coordinator = {
125 id: subagent.id,225 id: subagent.id,
126 kind: "subagent" as const,226 kind: workflow === undefined ? "subagent" : "workflow",
127 title:227 title:
128 subagent.title ??228 subagent.title ??
129 (subagent.prompt.length > 80 ? `${subagent.prompt.slice(0, 77)}...` : subagent.prompt),229 (subagent.prompt.length > 80 ? `${subagent.prompt.slice(0, 77)}...` : subagent.prompt),
...@@ -132,7 +232,7 @@ export function projectedSubagentsToRuntime(...@@ -132,7 +232,7 @@ export function projectedSubagentsToRuntime(
132 effort: null,232 effort: null,
133 status: subagent.status,233 status: subagent.status,
134 activationCount: 1,234 activationCount: 1,
135 usage: null,235 usage: workflow === undefined ? null : workflowUsage(workflow),
136 progress: subagent.progress ?? null,236 progress: subagent.progress ?? null,
137 lastToolName: null,237 lastToolName: null,
138 result: subagent.result,238 result: subagent.result,
...@@ -143,14 +243,78 @@ export function projectedSubagentsToRuntime(...@@ -143,14 +243,78 @@ export function projectedSubagentsToRuntime(
143 phaseIndex: null,243 phaseIndex: null,
144 phaseTitle: null,244 phaseTitle: null,
145 attempt: null,245 attempt: null,
146 workflowName: null,246 workflowName: workflow?.name ?? null,
147 phases: [],247 phases: workflow?.phases ?? [],
148 runHandles: null,248 runHandles,
249 childThreadId: subagent.childThreadId ?? null,
149 recentActivity: [],250 recentActivity: [],
150 firstSeenAt: startedAt ?? updatedAt,251 firstSeenAt,
151 startedAt,252 startedAt,
152 completedAt: subagent.completedAt === null ? null : DateTime.formatIso(subagent.completedAt),253 completedAt: subagent.completedAt === null ? null : DateTime.formatIso(subagent.completedAt),
153 updatedAt,254 updatedAt,
154 } satisfies RuntimeSubagent;255 } satisfies RuntimeSubagent;
256 return workflow === undefined
257 ? [coordinator]
258 : [coordinator, ...workflowMembersToRuntime(coordinator, workflow)];
259 });
260}
261
262export function deriveWorkflowGroups(
263 agents: ReadonlyArray<RuntimeSubagent>,
264): ReadonlyArray<SubagentWorkflowGroup> {
265 const workflows = agents
266 .filter((agent) => agent.kind === "workflow")
267 .sort((a, b) => a.firstSeenAt.localeCompare(b.firstSeenAt) || a.id.localeCompare(b.id));
268 const members = groupBy(
269 agents.filter((agent) => agent.kind !== "workflow" && agent.parentAgentId !== null),
270 (agent) => agent.parentAgentId!,
271 );
272
273 return workflows.map((workflow) => {
274 const workflowMembers = Object.hasOwn(members, workflow.id) ? members[workflow.id]! : [];
275 // Union, not either/or: the declared plan lags the members, because the
276 // provider only admits a phase once something in it starts. Members seed
277 // the map; the declared titles then overwrite whatever they guessed.
278 const phaseTitles = new Map<number, string>();
279 for (const member of workflowMembers) {
280 if (member.phaseIndex === null) continue;
281 phaseTitles.set(
282 member.phaseIndex,
283 member.phaseTitle ?? phaseTitles.get(member.phaseIndex) ?? `Phase ${member.phaseIndex + 1}`,
284 );
285 }
286 for (const phase of workflow.phases) phaseTitles.set(phase.index, phase.title);
287 const knownPhases = Array.from(phaseTitles.entries())
288 .map(([index, title]) => ({ index, title }))
289 .sort((a, b) => a.index - b.index);
290
291 const phases = knownPhases.map((phase) => {
292 const phaseMembers = workflowMembers
293 .filter((member) => member.phaseIndex === phase.index)
294 .sort((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0));
295 const hasActiveMember = phaseMembers.some(
296 // Idle members count as active for phase-liveness: a resumable Codex
297 // member has not finished the phase.
298 (member) => isActiveSubagentStatus(member.status) || member.status === "idle",
299 );
300 const settledCount = phaseMembers.filter((member) =>
301 isTerminalSubagentStatus(member.status),
302 ).length;
303 const state: "pending" | "running" | "done" =
304 phaseMembers.length === 0 ? "pending" : hasActiveMember ? "running" : "done";
305 return {
306 index: phase.index,
307 title: phase.title,
308 members: phaseMembers,
309 state,
310 settledCount,
311 };
312 });
313
314 const unphasedMembers = workflowMembers
315 .filter((member) => member.phaseIndex === null)
316 .sort((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0));
317
318 return { workflow, phases, unphasedMembers };
155 });319 });
156}320}
packages/client-runtime/src/t3ToolSummary.test.ts+17
...@@ -248,4 +248,21 @@ describe("summarizeT3ToolCalls", () => {...@@ -248,4 +248,21 @@ describe("summarizeT3ToolCalls", () => {
248 ]).label,248 ]).label,
249 ).toBe("Requested deletion of 1 scheduled task");249 ).toBe("Requested deletion of 1 scheduled task");
250 });250 });
251
252 it("counts workflow stop requests by coordinator and excludes failed requests", () => {
253 const first = completed({ threadId: "parent", subagentId: "workflow-1" }, {});
254 const failed = completed({ threadId: "parent", subagentId: "workflow-3" }, { isError: true });
255 expect(
256 summarizeT3ToolCalls("workflow-stop", [
257 first,
258 first,
259 completed({ threadId: "parent", subagentId: "workflow-2" }, {}),
260 failed,
261 ]),
262 ).toEqual({ label: "Requested stops for 2 workflows", failedCount: 1 });
263 expect(summarizeT3ToolCalls("workflow-stop", [failed])).toEqual({
264 label: "Tried to stop 1 workflow",
265 failedCount: 1,
266 });
267 });
251});268});
packages/client-runtime/src/t3ToolSummary.ts+7
...@@ -168,6 +168,13 @@ export function summarizeT3ToolCalls(...@@ -168,6 +168,13 @@ export function summarizeT3ToolCalls(
168 quantity(countEntities(threadIds), "thread"),168 quantity(countEntities(threadIds), "thread"),
169 );169 );
170 break;170 break;
171 case "workflow-stop":
172 label = phrase(
173 "Requested stops for",
174 "stop",
175 quantity(countEntities(entityIds("subagentId")), "workflow"),
176 );
177 break;
171 case "task-status":178 case "task-status":
172 label = phrase("Checked", "check", `task status ${times}`);179 label = phrase("Checked", "check", `task status ${times}`);
173 break;180 break;
packages/client-runtime/src/work-log/presentation.test.ts+42
...@@ -308,6 +308,7 @@ describe("resolveWorkEntryToolPresentation", () => {...@@ -308,6 +308,7 @@ describe("resolveWorkEntryToolPresentation", () => {
308 ["t3_thread_fork", "Forking this thread", "Requested a fork of this thread"],308 ["t3_thread_fork", "Forking this thread", "Requested a fork of this thread"],
309 ["t3_thread_send_attachments", "Sending attachments", "Sent attachments"],309 ["t3_thread_send_attachments", "Sending attachments", "Sent attachments"],
310 ["run_scheduled_task_now", "Running a scheduled task", "Requested a run of a scheduled task"],310 ["run_scheduled_task_now", "Running a scheduled task", "Requested a run of a scheduled task"],
311 ["t3_workflow_stop", "Stopping a workflow", "Requested a stop for a workflow"],
311 ])("labels %s through its lifecycle", (tool, running, completed) => {312 ])("labels %s through its lifecycle", (tool, running, completed) => {
312 expect(resolveWorkEntryToolPresentation({ label: `T3-code.${tool}` })?.displayName).toBe(313 expect(resolveWorkEntryToolPresentation({ label: `T3-code.${tool}` })?.displayName).toBe(
313 running,314 running,
...@@ -320,6 +321,47 @@ describe("resolveWorkEntryToolPresentation", () => {...@@ -320,6 +321,47 @@ describe("resolveWorkEntryToolPresentation", () => {
320 ).toBe(completed);321 ).toBe(completed);
321 });322 });
322323
324 it("summarizes workflow stop targets separately from their parent thread", () => {
325 const entry = {
326 id: "workflow-stop",
327 createdAt: "2026-10-07T00:00:00.000Z",
328 tone: "tool",
329 label: "Custom title",
330 itemType: "dynamic_tool",
331 toolLifecycleStatus: "completed",
332 toolData: {
333 server: "t3-code",
334 tool: "t3_workflow_stop",
335 arguments: { threadId: "parent", subagentId: "workflow-1" },
336 result: {},
337 },
338 } satisfies WorkLogPresentationEntry;
339 expect(
340 summarizeToolGroup([
341 entry,
342 entry,
343 {
344 ...entry,
345 toolData: {
346 ...entry.toolData,
347 arguments: { threadId: "parent", subagentId: "workflow-2" },
348 },
349 },
350 {
351 ...entry,
352 toolData: {
353 ...entry.toolData,
354 tool: "t3_thread_interrupt",
355 arguments: { threadId: "parent" },
356 },
357 },
358 ]),
359 ).toEqual({
360 summary: "Requested stops for 2 workflows and requested interrupts for 1 thread",
361 hasFailure: false,
362 });
363 });
364
323 it("summarizes project tools from MCP arguments and results without claiming failed effects", () => {365 it("summarizes project tools from MCP arguments and results without claiming failed effects", () => {
324 const entry: WorkLogPresentationEntry = {366 const entry: WorkLogPresentationEntry = {
325 id: "clone",367 id: "clone",
packages/client-runtime/src/work-log/presentation.ts+1
...@@ -609,6 +609,7 @@ function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction)...@@ -609,6 +609,7 @@ function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction)
609 case "thread-create":609 case "thread-create":
610 case "thread-send":610 case "thread-send":
611 case "thread-interrupt":611 case "thread-interrupt":
612 case "workflow-stop":
612 case "schedule-create":613 case "schedule-create":
613 case "schedule-update":614 case "schedule-update":
614 case "schedule-delete":615 case "schedule-delete":
packages/contracts/src/clientRpcPermissions.ts+2
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 AuthSourceControlWriteScope,6 AuthSourceControlWriteScope,
7 type AuthEnvironmentScope,7 type AuthEnvironmentScope,
8} from "./auth.ts";8} from "./auth.ts";
9import { ORCHESTRATION_V2_WS_METHODS } from "./orchestrationV2.ts";
9import { WS_METHODS } from "./rpc.ts";10import { WS_METHODS } from "./rpc.ts";
1011
11/** Incremental client enforcement; the server still authorizes every request. */12/** Incremental client enforcement; the server still authorizes every request. */
...@@ -43,6 +44,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = {...@@ -43,6 +44,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = {
43 [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope,44 [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope,
44 [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope,45 [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope,
45 [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope,46 [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope,
47 [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope,
46} as const;48} as const;
47export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES;49export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES;
4850
packages/contracts/src/orchestrationV2.test.ts+64
...@@ -36,6 +36,7 @@ import {...@@ -36,6 +36,7 @@ import {
36 OrchestrationV2ShellSnapshot,36 OrchestrationV2ShellSnapshot,
37 OrchestrationV2SubscribeThreadInput,37 OrchestrationV2SubscribeThreadInput,
38 OrchestrationV2Subagent,38 OrchestrationV2Subagent,
39 OrchestrationV2SubagentJson,
39 OrchestrationV2ThreadHistoryPage,40 OrchestrationV2ThreadHistoryPage,
40 OrchestrationV2ThreadProjection,41 OrchestrationV2ThreadProjection,
41 OrchestrationV2ThreadStreamItem,42 OrchestrationV2ThreadStreamItem,
...@@ -112,6 +113,7 @@ const decodeOrchestrationV2Checkpoint = Schema.decodeUnknownSync(OrchestrationV2...@@ -112,6 +113,7 @@ const decodeOrchestrationV2Checkpoint = Schema.decodeUnknownSync(OrchestrationV2
112const decodeOrchestrationV2DomainEvent = Schema.decodeUnknownSync(OrchestrationV2DomainEvent);113const decodeOrchestrationV2DomainEvent = Schema.decodeUnknownSync(OrchestrationV2DomainEvent);
113const decodeProviderReplayTranscript = Schema.decodeUnknownSync(ProviderReplayTranscript);114const decodeProviderReplayTranscript = Schema.decodeUnknownSync(ProviderReplayTranscript);
114const decodeOrchestrationV2Subagent = Schema.decodeUnknownSync(OrchestrationV2Subagent);115const decodeOrchestrationV2Subagent = Schema.decodeUnknownSync(OrchestrationV2Subagent);
116const encodeOrchestrationV2SubagentJson = Schema.encodeSync(OrchestrationV2SubagentJson);
115const decodeOrchestrationV2ThreadProjection = Schema.decodeUnknownSync(117const decodeOrchestrationV2ThreadProjection = Schema.decodeUnknownSync(
116 OrchestrationV2ThreadProjection,118 OrchestrationV2ThreadProjection,
117);119);
...@@ -929,6 +931,68 @@ describe("orchestration V2 contracts", () => {...@@ -929,6 +931,68 @@ describe("orchestration V2 contracts", () => {
929 expect(turnItem.progress).toBe("Inspecting package metadata");931 expect(turnItem.progress).toBe("Inspecting package metadata");
930 });932 });
931933
934 it.each([" indented prompt\n", "\n \t\n"])(
935 "preserves workflow prompt and result %j in stored and wire JSON",
936 (preview) => {
937 const subagent = {
938 ...decodeOrchestrationV2Subagent({
939 id: "workflow-coordinator",
940 threadId: "thread-1",
941 runId: "run-1",
942 parentNodeId: "node-root-1",
943 origin: "provider_native",
944 createdBy: "agent",
945 driver: "claudeAgent",
946 providerInstanceId: "claudeAgent",
947 providerThreadId: null,
948 childThreadId: null,
949 nativeTaskRef: null,
950 prompt: "Run workflow",
951 title: null,
952 model: null,
953 status: "running",
954 result: null,
955 startedAt: now,
956 completedAt: null,
957 updatedAt: now,
958 }),
959 workflow: {
960 phases: [],
961 agents: [
962 {
963 index: 1,
964 label: "Member",
965 state: "completed" as const,
966 prompt: preview,
967 result: preview,
968 },
969 ],
970 },
971 };
972 const { workflow, ...coordinator } = subagent;
973 const incoming = { ...encodeOrchestrationV2SubagentJson(coordinator), workflow };
974 for (const codec of [
975 OrchestrationV2SubagentJson,
976 Schema.toCodecJson(OrchestrationV2Subagent),
977 ]) {
978 const encode = Schema.encodeSync(codec);
979 const decode = Schema.decodeUnknownSync(codec);
980 const json: unknown = JSON.parse(JSON.stringify(encode(subagent)));
981 expect(json).toMatchObject({
982 workflow: { agents: [{ prompt: preview, result: preview }] },
983 });
984 expect(decode(json).workflow?.agents[0]).toMatchObject({
985 prompt: preview,
986 result: preview,
987 });
988 expect(decode(incoming).workflow?.agents[0]).toMatchObject({
989 prompt: preview,
990 result: preview,
991 });
992 }
993 },
994 );
995
932 it("decodes app-owned subagent parent-wake policies", () => {996 it("decodes app-owned subagent parent-wake policies", () => {
933 const appOwnedSubagent = {997 const appOwnedSubagent = {
934 id: "node-subagent-2",998 id: "node-subagent-2",
packages/contracts/src/orchestrationV2.ts+155
...@@ -664,6 +664,86 @@ export const OrchestrationV2ExecutionNode = Schema.Struct({...@@ -664,6 +664,86 @@ export const OrchestrationV2ExecutionNode = Schema.Struct({
664});664});
665export type OrchestrationV2ExecutionNode = typeof OrchestrationV2ExecutionNode.Type;665export type OrchestrationV2ExecutionNode = typeof OrchestrationV2ExecutionNode.Type;
666666
667/**
668 * One entry of a dynamic workflow's phase plan. The plan grows while the run
669 * executes: a phase a script only reaches at runtime joins the list when it
670 * starts, so a client must treat the list as append-only rather than as a
671 * fixed arc declared up front.
672 */
673export const OrchestrationV2WorkflowPhase = Schema.Struct({
674 index: NonNegativeInt,
675 title: TrimmedNonEmptyString,
676});
677export type OrchestrationV2WorkflowPhase = typeof OrchestrationV2WorkflowPhase.Type;
678
679/**
680 * One agent inside a workflow run. Timestamps are epoch milliseconds rather
681 * than `DateTimeUtc` because only the top-level entity fields are remapped for
682 * the JSON wire shape (see `OrchestrationV2SubagentJson`), so a nested
683 * `DateTimeUtc` would never be encoded.
684 */
685export const OrchestrationV2WorkflowAgent = Schema.Struct({
686 /** Spawn ordinal within the run. Stable identity: an agent keeps it across updates. */
687 index: NonNegativeInt,
688 label: TrimmedNonEmptyString,
689 /**
690 * Provider-assigned id, absent until the agent actually starts. Names the
691 * member's transcript file inside the run's transcript directory.
692 */
693 agentId: Schema.optional(TrimmedNonEmptyString),
694 /** The member's own thread, so the UI can open it like any subagent's. */
695 childThreadId: Schema.optional(ThreadId),
696 state: Schema.Literals(["queued", "running", "completed", "failed"]),
697 phaseIndex: Schema.optional(NonNegativeInt),
698 phaseTitle: Schema.optional(TrimmedNonEmptyString),
699 model: Schema.optional(TrimmedNonEmptyString),
700 /** Retry counter; > 1 means the agent was restarted after a provider error. */
701 attempt: Schema.optional(NonNegativeInt),
702 totalTokens: Schema.optional(NonNegativeInt),
703 toolCalls: Schema.optional(NonNegativeInt),
704 durationMs: Schema.optional(NonNegativeInt),
705 queuedAt: Schema.optional(NonNegativeInt),
706 startedAt: Schema.optional(NonNegativeInt),
707 /**
708 * Prompt and result excerpts. A workflow member's whole conversation is one
709 * prompt and one answer, so together these are its transcript as far as the
710 * provider exposes it — both are capped by the CLI at a few hundred
711 * characters.
712 */
713 prompt: Schema.optional(Schema.NonEmptyString),
714 result: Schema.optional(Schema.NonEmptyString),
715});
716export type OrchestrationV2WorkflowAgent = typeof OrchestrationV2WorkflowAgent.Type;
717
718/**
719 * Filesystem handles the provider reports when a run launches. The transcript
720 * directory is what makes a member's full answer readable: the progress
721 * snapshot only carries a capped excerpt, so the server reads the member's
722 * transcript from here to fill its thread.
723 */
724export const OrchestrationV2WorkflowRunHandles = Schema.Struct({
725 runId: Schema.optional(TrimmedNonEmptyString),
726 transcriptDir: Schema.optional(TrimmedNonEmptyString),
727 scriptPath: Schema.optional(TrimmedNonEmptyString),
728});
729export type OrchestrationV2WorkflowRunHandles = typeof OrchestrationV2WorkflowRunHandles.Type;
730
731/**
732 * Workflow-run shape carried by the coordinator subagent. The snapshot is the
733 * provider's own replace-in-full view of the run; members are additionally
734 * projected as subagents in their own right.
735 */
736export const OrchestrationV2SubagentWorkflow = Schema.Struct({
737 name: Schema.optional(TrimmedNonEmptyString),
738 runHandles: Schema.optional(OrchestrationV2WorkflowRunHandles),
739 phases: Schema.Array(OrchestrationV2WorkflowPhase),
740 agents: Schema.Array(OrchestrationV2WorkflowAgent),
741 totalTokens: Schema.optional(NonNegativeInt),
742 toolCalls: Schema.optional(NonNegativeInt),
743 durationMs: Schema.optional(NonNegativeInt),
744});
745export type OrchestrationV2SubagentWorkflow = typeof OrchestrationV2SubagentWorkflow.Type;
746
667export const OrchestrationV2Subagent = Schema.Struct({747export const OrchestrationV2Subagent = Schema.Struct({
668 id: NodeId,748 id: NodeId,
669 threadId: ThreadId,749 threadId: ThreadId,
...@@ -698,6 +778,8 @@ export const OrchestrationV2Subagent = Schema.Struct({...@@ -698,6 +778,8 @@ export const OrchestrationV2Subagent = Schema.Struct({
698 "interrupted",778 "interrupted",
699 ]),779 ]),
700 progress: Schema.optional(Schema.String),780 progress: Schema.optional(Schema.String),
781 /** Present only on a workflow coordinator task; absent on ordinary subagents. */
782 workflow: Schema.optional(OrchestrationV2SubagentWorkflow),
701 result: Schema.NullOr(Schema.String),783 result: Schema.NullOr(Schema.String),
702 startedAt: Schema.NullOr(Schema.DateTimeUtc),784 startedAt: Schema.NullOr(Schema.DateTimeUtc),
703 completedAt: Schema.NullOr(Schema.DateTimeUtc),785 completedAt: Schema.NullOr(Schema.DateTimeUtc),
...@@ -3163,6 +3245,7 @@ export const ORCHESTRATION_V2_WS_METHODS = {...@@ -3163,6 +3245,7 @@ export const ORCHESTRATION_V2_WS_METHODS = {
3163 getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot",3245 getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot",
3164 getThreadProjection: "orchestration.getThreadProjection",3246 getThreadProjection: "orchestration.getThreadProjection",
3165 getWorkflowScript: "orchestration.getWorkflowScript",3247 getWorkflowScript: "orchestration.getWorkflowScript",
3248 stopWorkflow: "orchestration.stopWorkflow",
3166 getTurnItem: "orchestration.getTurnItem",3249 getTurnItem: "orchestration.getTurnItem",
3167 launchThread: "orchestration.launchThread",3250 launchThread: "orchestration.launchThread",
3168 subscribeArchivedShell: "orchestration.subscribeArchivedShell",3251 subscribeArchivedShell: "orchestration.subscribeArchivedShell",
...@@ -3448,11 +3531,41 @@ export class OrchestrationV2ThreadLaunchError extends Schema.TaggedError<Orchest...@@ -3448,11 +3531,41 @@ export class OrchestrationV2ThreadLaunchError extends Schema.TaggedError<Orchest
3448 },3531 },
3449) {}3532) {}
34503533
3534export const OrchestrationV2StopWorkflowInput = Schema.Struct({
3535 threadId: ThreadId,
3536 subagentId: NodeId,
3537});
3538export type OrchestrationV2StopWorkflowInput = typeof OrchestrationV2StopWorkflowInput.Type;
3539
3540export class OrchestrationV2StopWorkflowError extends Schema.TaggedError<OrchestrationV2StopWorkflowError>()(
3541 "OrchestrationV2StopWorkflowError",
3542 {
3543 threadId: ThreadId,
3544 subagentId: NodeId,
3545 reason: Schema.Literals(["not-running", "unsupported", "unavailable", "stop-failed"]),
3546 cause: Schema.optional(Schema.Defect()),
3547 },
3548) {
3549 override get message(): string {
3550 switch (this.reason) {
3551 case "not-running":
3552 return "This workflow is no longer running.";
3553 case "unsupported":
3554 return "This provider cannot stop a workflow.";
3555 case "unavailable":
3556 return "This workflow has no live provider task.";
3557 case "stop-failed":
3558 return "Failed to stop the workflow.";
3559 }
3560 }
3561}
3562
3451export const OrchestrationV2RpcError = Schema.Union([3563export const OrchestrationV2RpcError = Schema.Union([
3452 OrchestrationV2DispatchCommandError,3564 OrchestrationV2DispatchCommandError,
3453 OrchestrationV2GetThreadProjectionError,3565 OrchestrationV2GetThreadProjectionError,
3454 OrchestrationV2GetShellSnapshotError,3566 OrchestrationV2GetShellSnapshotError,
3455 OrchestrationV2ThreadLaunchError,3567 OrchestrationV2ThreadLaunchError,
3568 OrchestrationV2StopWorkflowError,
3456]);3569]);
3457export type OrchestrationV2RpcError = typeof OrchestrationV2RpcError.Type;3570export type OrchestrationV2RpcError = typeof OrchestrationV2RpcError.Type;
34583571
...@@ -3473,6 +3586,44 @@ export const OrchestrationV2GetWorkflowScriptResult = Schema.Struct({...@@ -3473,6 +3586,44 @@ export const OrchestrationV2GetWorkflowScriptResult = Schema.Struct({
3473export type OrchestrationV2GetWorkflowScriptResult =3586export type OrchestrationV2GetWorkflowScriptResult =
3474 typeof OrchestrationV2GetWorkflowScriptResult.Type;3587 typeof OrchestrationV2GetWorkflowScriptResult.Type;
34753588
3589/**
3590 * Shared by every read of a file the Claude harness persisted for a workflow
3591 * run — the script and each member's transcript. One vocabulary because both
3592 * reads apply identical containment rules.
3593 */
3594const WORKFLOW_FILE_ERROR_MESSAGES = {
3595 "invalid-path": "Workflow files must be absolute paths with the expected extension.",
3596 "root-unavailable": "Workflow file root unavailable.",
3597 "not-found": "Workflow file not found.",
3598 "outside-root": "Path is outside the workflow files root.",
3599 "wrong-extension": "Resolved file has the wrong extension.",
3600 "not-regular-file": "Workflow file is not a regular file.",
3601 "changed-during-read": "Workflow file changed between resolution and open.",
3602 "read-failed": "Workflow file read failed.",
3603} as const;
3604
3605export class OrchestrationWorkflowFileError extends Schema.TaggedError<OrchestrationWorkflowFileError>()(
3606 "OrchestrationWorkflowFileError",
3607 {
3608 reason: Schema.Literals([
3609 "invalid-path",
3610 "root-unavailable",
3611 "not-found",
3612 "outside-root",
3613 "wrong-extension",
3614 "not-regular-file",
3615 "changed-during-read",
3616 "read-failed",
3617 ]),
3618 path: Schema.String,
3619 cause: Schema.optional(Schema.Defect()),
3620 },
3621) {
3622 override get message(): string {
3623 return WORKFLOW_FILE_ERROR_MESSAGES[this.reason];
3624 }
3625}
3626
3476export const OrchestrationV2GetTurnItemInput = Schema.Struct({3627export const OrchestrationV2GetTurnItemInput = Schema.Struct({
3477 threadId: ThreadId,3628 threadId: ThreadId,
3478 itemId: TurnItemId,3629 itemId: TurnItemId,
...@@ -3601,6 +3752,10 @@ export const OrchestrationV2RpcSchemas = {...@@ -3601,6 +3752,10 @@ export const OrchestrationV2RpcSchemas = {
3601 input: OrchestrationV2GetThreadProjectionInput,3752 input: OrchestrationV2GetThreadProjectionInput,
3602 output: OrchestrationV2ThreadProjection,3753 output: OrchestrationV2ThreadProjection,
3603 },3754 },
3755 stopWorkflow: {
3756 input: OrchestrationV2StopWorkflowInput,
3757 output: Schema.Void,
3758 },
3604 getWorkflowScript: {3759 getWorkflowScript: {
3605 input: OrchestrationV2GetWorkflowScriptInput,3760 input: OrchestrationV2GetWorkflowScriptInput,
3606 output: OrchestrationV2GetWorkflowScriptResult,3761 output: OrchestrationV2GetWorkflowScriptResult,
packages/contracts/src/orchestratorMcp.ts+1
...@@ -399,6 +399,7 @@ export const OrchestratorMcpThreadTimelineItem = Schema.Struct({...@@ -399,6 +399,7 @@ export const OrchestratorMcpThreadTimelineItem = Schema.Struct({
399 visibility: Schema.Literals(["local", "inherited", "synthetic"]),399 visibility: Schema.Literals(["local", "inherited", "synthetic"]),
400 sourceThreadId: ThreadId,400 sourceThreadId: ThreadId,
401 itemId: TurnItemId,401 itemId: TurnItemId,
402 subagentId: Schema.optional(NodeId),
402 runId: Schema.NullOr(RunId),403 runId: Schema.NullOr(RunId),
403 messageId: Schema.NullOr(MessageId),404 messageId: Schema.NullOr(MessageId),
404 createdBy: Schema.NullOr(OrchestrationV2Actor),405 createdBy: Schema.NullOr(OrchestrationV2Actor),
packages/contracts/src/rpc.ts+8
...@@ -208,6 +208,7 @@ import {...@@ -208,6 +208,7 @@ import {
208import {208import {
209 ORCHESTRATION_V2_WS_METHODS,209 ORCHESTRATION_V2_WS_METHODS,
210 OrchestrationGetWorkflowScriptError,210 OrchestrationGetWorkflowScriptError,
211 OrchestrationV2StopWorkflowError,
211 OrchestrationV2DispatchCommandError,212 OrchestrationV2DispatchCommandError,
212 OrchestrationV2GetShellSnapshotError,213 OrchestrationV2GetShellSnapshotError,
213 OrchestrationV2GetThreadProjectionError,214 OrchestrationV2GetThreadProjectionError,
...@@ -1662,6 +1663,12 @@ const WsOrchestrationV2GetThreadProjectionRpc = Rpc.make(...@@ -1662,6 +1663,12 @@ const WsOrchestrationV2GetThreadProjectionRpc = Rpc.make(
1662 },1663 },
1663);1664);
16641665
1666const WsOrchestrationV2StopWorkflowRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.stopWorkflow, {
1667 payload: OrchestrationV2RpcSchemas.stopWorkflow.input,
1668 success: OrchestrationV2RpcSchemas.stopWorkflow.output,
1669 error: Schema.Union([OrchestrationV2StopWorkflowError, EnvironmentAuthorizationError]),
1670});
1671
1665const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make(1672const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make(
1666 ORCHESTRATION_V2_WS_METHODS.getWorkflowScript,1673 ORCHESTRATION_V2_WS_METHODS.getWorkflowScript,
1667 {1674 {
...@@ -2032,6 +2039,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -2032,6 +2039,7 @@ export const WsRpcGroup = RpcGroup.make(
2032 WsSubscribeResourceTelemetryRpc,2039 WsSubscribeResourceTelemetryRpc,
2033 WsOrchestrationV2DispatchCommandRpc,2040 WsOrchestrationV2DispatchCommandRpc,
2034 WsOrchestrationV2GetWorkflowScriptRpc,2041 WsOrchestrationV2GetWorkflowScriptRpc,
2042 WsOrchestrationV2StopWorkflowRpc,
2035 WsOrchestrationV2GetTurnItemRpc,2043 WsOrchestrationV2GetTurnItemRpc,
2036 WsOrchestrationV2GetTurnDiffRpc,2044 WsOrchestrationV2GetTurnDiffRpc,
2037 WsOrchestrationV2GetFullThreadDiffRpc,2045 WsOrchestrationV2GetFullThreadDiffRpc,
packages/provider-core/src/server/ProviderAdapter.ts+4
...@@ -595,6 +595,10 @@ export interface ProviderAdapterV2SessionRuntime {...@@ -595,6 +595,10 @@ export interface ProviderAdapterV2SessionRuntime {
595 readonly steerTurn: (595 readonly steerTurn: (
596 input: ProviderAdapterV2SteerInput,596 input: ProviderAdapterV2SteerInput,
597 ) => Effect.Effect<void, ProviderAdapterV2Error>;597 ) => Effect.Effect<void, ProviderAdapterV2Error>;
598 readonly stopTask?: (input: {
599 readonly providerThread: OrchestrationV2ProviderThread;
600 readonly taskId: string;
601 }) => Effect.Effect<void, ProviderAdapterV2Error>;
598 readonly interruptTurn: (602 readonly interruptTurn: (
599 input: ProviderAdapterV2InterruptInput,603 input: ProviderAdapterV2InterruptInput,
600 ) => Effect.Effect<void, ProviderAdapterV2Error>;604 ) => Effect.Effect<void, ProviderAdapterV2Error>;
packages/shared/src/t3McpToolPresentation.ts+5
...@@ -21,6 +21,7 @@ export type T3McpToolSummaryAction =...@@ -21,6 +21,7 @@ export type T3McpToolSummaryAction =
21 | "thread-send"21 | "thread-send"
22 | "thread-wait"22 | "thread-wait"
23 | "thread-interrupt"23 | "thread-interrupt"
24 | "workflow-stop"
24 | "thread-configuration"25 | "thread-configuration"
25 | "thread-configure"26 | "thread-configure"
26 | "thread-fork"27 | "thread-fork"
...@@ -138,6 +139,10 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = {...@@ -138,6 +139,10 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = {
138 t3_thread_read: tool(["Read", "Reading", "Read", "a T3 thread"], "thread-read"),139 t3_thread_read: tool(["Read", "Reading", "Read", "a T3 thread"], "thread-read"),
139 t3_thread_send: tool(["Send", "Sending", "Sent", "to a T3 thread"], "thread-send"),140 t3_thread_send: tool(["Send", "Sending", "Sent", "to a T3 thread"], "thread-send"),
140 t3_thread_wait: tool(["Wait", "Waiting", "Waited", "for a T3 thread"], "thread-wait"),141 t3_thread_wait: tool(["Wait", "Waiting", "Waited", "for a T3 thread"], "thread-wait"),
142 t3_workflow_stop: tool(
143 ["Stop", "Stopping", "Requested a stop for", "a workflow"],
144 "workflow-stop",
145 ),
141 t3_thread_interrupt: tool(146 t3_thread_interrupt: tool(
142 ["Interrupt", "Interrupting", "Requested an interrupt of", "a T3 thread"],147 ["Interrupt", "Interrupting", "Requested an interrupt of", "a T3 thread"],
143 "thread-interrupt",148 "thread-interrupt",