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 {
88 ProviderInstanceId,
99 ThreadId,
1010 AuthOrchestrationOperateScope,
11 ORCHESTRATION_V2_WS_METHODS,
1112 AuthOrchestrationReadScope,
1213 AuthSourceControlWriteScope,
1314 AuthPreviewOperateScope,
......@@ -35,6 +36,12 @@ describe("RPC authorization scopes", () => {
3536 expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys()));
3637 });
3738
39 it("requires operate access to stop a workflow", () => {
40 expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.stopWorkflow)).toBe(
41 AuthOrchestrationOperateScope,
42 );
43 });
44
3845 it("authorizes background policy reporting and observation deliberately", () => {
3946 expect(requiredScopeForRpcMethod(WS_METHODS.serverReportClientActivity)).toBe(
4047 AuthOrchestrationReadScope,
apps/server/src/auth/RpcAuthorization.ts+1
......@@ -46,6 +46,7 @@ export const RPC_REQUIRED_SCOPES = {
4646 ...CLIENT_GUARDED_RPC_SCOPES,
4747 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
4848 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,
49 [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope,
4950 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
5051 [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,
5152 [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,
apps/server/src/mcp/OrchestratorMcpService.ts+1
......@@ -879,6 +879,7 @@ function timelineItem(input: {
879879 visibility: input.row.visibility,
880880 sourceThreadId: input.row.sourceThreadId,
881881 itemId: input.row.sourceItemId,
882 ...(input.row.item.type === "subagent" ? { subagentId: input.row.item.subagentId } : {}),
882883 runId: input.row.item.runId,
883884 messageId,
884885 createdBy: message?.createdBy ?? null,
apps/server/src/mcp/toolkits/core.test.ts+132-21
......@@ -8,9 +8,14 @@ import {
88 ChatImageAttachment,
99 CommandId,
1010 EnvironmentId,
11 NodeId,
12 OrchestratorMcpThreadReadResult,
13 ProviderDriverKind,
1114 ProviderInstanceId,
1215 RunId,
1316 ThreadId,
17 TurnItemId,
18 type OrchestrationV2ProjectedTurnItem,
1419 type OrchestrationV2ThreadShell,
1520} from "@t3tools/contracts";
1621import * as Effect from "effect/Effect";
......@@ -69,6 +74,7 @@ import {
6974import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
7075
7176const decodeMcpAttachmentInput = Schema.decodeUnknownEffect(McpAttachmentInput);
77const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadReadResult);
7278
7379// Registration asks for every service the thread tools declare; these cases call none that use them.
7480const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe(
......@@ -663,16 +669,127 @@ function scheduledTask(id: string, runtimeMode: "auto" | "full-access"): never {
663669 } as never;
664670}
665671
666it.effect("a caller cannot interrupt a thread that runs above its own modes", () =>
667 Effect.gen(function* () {
672it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])(
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* () {
668766 const server = yield* McpServer.McpServer;
669 const result = yield* server
670 .callTool({ name: "t3_thread_interrupt", arguments: { threadId: "full-access-thread" } })
767 const scope = clientScope("full-access");
768 const read = yield* server
769 .callTool({ name: "t3_thread_read", arguments: { threadId, view: "activity" } })
671770 .pipe(
672 Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")),
771 Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
673772 Effect.provideService(McpSchema.McpServerClient, client),
674773 );
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" }]);
676793 }).pipe(
677794 Effect.provide(
678795 McpHttpServer.layerOrchestratorToolkit.pipe(
......@@ -680,20 +797,14 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", ()
680797 Layer.provide(NodeCrypto.layer),
681798 Layer.provide(
682799 Layer.mock(ThreadManagement.ThreadManagementService)({
683 getThreadShell: () =>
684 Effect.succeed({ projectId: "project-a", deletedAt: null } as never),
800 getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)),
685801 getProjectThreadRecords: () =>
686 Effect.succeed({
687 thread: {
688 id: ThreadId.make("full-access-thread"),
689 projectId: "project-a",
690 runtimeMode: "full-access",
691 interactionMode: "default",
692 deletedAt: null,
693 },
694 runs: [],
695 } as never),
696 interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"),
802 Effect.succeed(McpToolAccessTestkit.idleThreadProjection(shell)),
803 getTimelinePage: () => Effect.succeed({ items: [row], totalItems: 1, hasMore: false }),
804 stopWorkflow: (input) =>
805 Effect.sync(() => {
806 stopped.push(input);
807 }),
697808 }),
698809 ),
699810 Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})),
......@@ -703,8 +814,8 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", ()
703814 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
704815 ),
705816 ),
706 ),
707);
817 );
818});
708819
709820it.effect("only the caller that prepared a pending upload can discard it", () =>
710821 Effect.gen(function* () {
apps/server/src/mcp/toolkits/orchestrator/handlers.ts+22
......@@ -1,6 +1,8 @@
1import { OrchestratorMcpFailure } from "@t3tools/contracts";
12import { OrchestratorToolkit } from "./tools.ts";
23import * as Effect from "effect/Effect";
34
5import { readCaller } from "../../threadAccess.ts";
46import * as McpInvocationContext from "../../McpInvocationContext.ts";
57import * as McpToolAccess from "../../McpToolAccess.ts";
68import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts";
......@@ -35,6 +37,26 @@ const handlers = {
3537 return yield* service.cancelTask(scope, input);
3638 }),
3739 ),
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 ),
3860 schedule_task: McpToolAccess.startsThreads(
3961 // A scheduled task runs with the caller's own modes.
4062 () => ({}),
apps/server/src/mcp/toolkits/orchestrator/tools.ts+15
......@@ -1,5 +1,6 @@
11import {
22 OrchestratorMcpCapabilitiesResult,
3 OrchestrationV2StopWorkflowInput,
34 OrchestratorMcpCreateThreadsInput,
45 OrchestratorMcpCreateThreadsResult,
56 OrchestratorMcpDelegateTaskInput,
......@@ -31,6 +32,7 @@ import {
3132 ThreadMetadataMcpUpdateResult,
3233} from "@t3tools/contracts";
3334import { Tool, Toolkit } from "effect/ai";
35import * as Schema from "effect/Schema";
3436
3537import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
3638import * as McpInvocationContext from "../../McpInvocationContext.ts";
......@@ -100,6 +102,18 @@ const TaskCancelTool = Tool.make("task_cancel", {
100102 .annotate(Tool.Title, "Cancel delegated task")
101103 .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
103117export const ScheduleTaskTool = Tool.make("schedule_task", {
104118 description:
105119 "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(
261275 DelegateTaskTool,
262276 TaskStatusTool,
263277 TaskCancelTool,
278 WorkflowStopTool,
264279 ScheduleTaskTool,
265280 ListScheduledTasksTool,
266281 UpdateScheduledTaskTool,
apps/server/src/observability/RpcInstrumentation.ts+1
......@@ -17,6 +17,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
1717const RPC_AGGREGATES = {
1818 [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2",
1919 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration",
20 [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: "orchestration",
2021 [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: "orchestration",
2122 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: "orchestration",
2223 [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: "orchestration",
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+2382-11
......@@ -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
22import * as NodeEvents from "node:events";
3import * as NodeFS from "node:fs";
34import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
46import * as NodeStream from "node:stream";
57
68import type {
......@@ -44,6 +46,7 @@ import {
4446 ThreadId,
4547} from "@t3tools/contracts";
4648import { assert, describe, it } from "@effect/vitest";
49import * as Clock from "effect/Clock";
4750import * as Context from "effect/Context";
4851import * as DateTime from "effect/DateTime";
4952import * as Deferred from "effect/Deferred";
......@@ -1024,6 +1027,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {
10241027 offer: () => Effect.void,
10251028 setModel: () => Effect.void,
10261029 setPermissionMode: () => Effect.void,
1030 stopTask: () => Effect.void,
10271031 interrupt: Effect.void,
10281032 close: Effect.void,
10291033 };
......@@ -1183,6 +1187,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio
11831187 offer: () => Effect.void,
11841188 setModel: () => Effect.void,
11851189 setPermissionMode: () => Effect.void,
1190 stopTask: () => Effect.void,
11861191 interrupt: Effect.void,
11871192 close: Effect.void,
11881193 };
......@@ -1302,6 +1307,7 @@ describe("ClaudeAdapterV2 resume compaction", () => {
13021307 offer: () => Effect.void,
13031308 setModel: () => Effect.void,
13041309 setPermissionMode: () => Effect.void,
1310 stopTask: () => Effect.void,
13051311 interrupt: Effect.void,
13061312 close: Effect.void,
13071313 };
......@@ -1527,6 +1533,7 @@ describe("ClaudeAdapterV2 attachments", () => {
15271533 }),
15281534 setModel: () => Effect.void,
15291535 setPermissionMode: () => Effect.void,
1536 stopTask: () => Effect.void,
15301537 interrupt: Effect.void,
15311538 close: Effect.void,
15321539 }),
......@@ -1671,6 +1678,7 @@ describe("ClaudeAdapterV2 attachments", () => {
16711678 offer: () => Effect.void,
16721679 setModel: () => Effect.void,
16731680 setPermissionMode: () => Effect.void,
1681 stopTask: () => Effect.void,
16741682 interrupt: Effect.void,
16751683 close: Effect.void,
16761684 };
......@@ -1765,6 +1773,7 @@ describe("ClaudeAdapterV2 native fork", () => {
17651773 offer: () => Effect.void,
17661774 setModel: () => Effect.void,
17671775 setPermissionMode: () => Effect.void,
1776 stopTask: () => Effect.void,
17681777 interrupt: Effect.void,
17691778 close: Effect.void,
17701779 };
......@@ -2056,6 +2065,7 @@ describe("ClaudeAdapterV2 native session identity", () => {
20562065 offer: () => Effect.void,
20572066 setModel: () => Effect.void,
20582067 setPermissionMode: () => Effect.void,
2068 stopTask: () => Effect.void,
20592069 interrupt: Effect.void,
20602070 close: Effect.void,
20612071 };
......@@ -2309,6 +2319,8 @@ describe("ClaudeAdapterV2 background wake turns", () => {
23092319 readonly interrupt?: Effect.Effect<void>;
23102320 readonly stopTask?: (taskId: string) => Effect.Effect<void>;
23112321 readonly environment?: NodeJS.ProcessEnv;
2322 readonly subagentLaunchToolUseId?: string;
2323 readonly nativeSessionId?: string;
23122324 readonly settings?: ClaudeSettings;
23132325 // A CLI process opened after the first streams from its own queue, so the
23142326 // first one can exit (Queue.shutdown) and a later turn can start another.
......@@ -2323,24 +2335,30 @@ describe("ClaudeAdapterV2 background wake turns", () => {
23232335 const sdkMessages = yield* Queue.unbounded<SDKMessage>();
23242336 const processQueues: Array<Queue.Queue<SDKMessage>> = [];
23252337 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 ) {
23272342 const processed = yield* Deferred.make<void>();
23282343 processedMessages.set(message, processed);
23292344 yield* Queue.offer(sdkMessages, message);
2345 if (offered !== undefined) yield* Deferred.succeed(offered, undefined);
23302346 yield* Deferred.await(processed);
23312347 });
23322348 const offeredMessages: Array<SDKUserMessage> = [];
23332349 const permissionModeChanges: Array<string> = [];
23342350 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
23352351 [];
2336 const subagentReceipts =
2337 yield* Queue.unbounded<
2338 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated" }>
2339 >();
2352 const continuationReceipts =
2353 yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>();
23402354 const terminalReceipts =
23412355 yield* Queue.unbounded<
23422356 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }>
23432357 >();
2358 const subagentReceipts =
2359 yield* Queue.unbounded<
2360 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated" }>
2361 >();
23442362 const systemNoticeReceipts =
23452363 yield* Queue.unbounded<
23462364 Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn_item.updated" }>
......@@ -2359,10 +2377,10 @@ describe("ClaudeAdapterV2 background wake turns", () => {
23592377 offer: (request) =>
23602378 Effect.sync(() => {
23612379 continuationRequests.push(request);
2362 }),
2380 }).pipe(Effect.andThen(Queue.offer(continuationReceipts, request))),
23632381 },
23642382 queryRunner: {
2365 allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION),
2383 allocateSessionId: Effect.succeed(options?.nativeSessionId ?? WAKE_NATIVE_SESSION),
23662384 open: (input) =>
23672385 Effect.gen(function* () {
23682386 openedOptions = input.options;
......@@ -2377,6 +2395,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
23772395 }),
23782396 setModel: () => Effect.void,
23792397 setPermissionMode: () => Effect.void,
2398 stopTask: options?.stopTask ?? (() => Effect.void),
23802399 interrupt: Effect.void,
23812400 close: Queue.shutdown(processMessages),
23822401 };
......@@ -2409,13 +2428,13 @@ describe("ClaudeAdapterV2 background wake turns", () => {
24092428 Effect.sync(() => {
24102429 permissionModeChanges.push(mode);
24112430 }),
2412 ...(options?.stopTask === undefined ? {} : { stopTask: options.stopTask }),
2431 stopTask: options?.stopTask ?? (() => Effect.void),
24132432 interrupt: options?.interrupt ?? Effect.void,
24142433 close: options?.close?.(sdkMessages) ?? Effect.void,
24152434 };
24162435 }),
24172436 forkSession: () => Effect.die("unused forkSession"),
2418 subagentLaunchToolUseId: () => Effect.succeed(null),
2437 subagentLaunchToolUseId: () => Effect.succeed(options?.subagentLaunchToolUseId ?? null),
24192438 assertComplete: Effect.void,
24202439 },
24212440 });
......@@ -2436,7 +2455,9 @@ describe("ClaudeAdapterV2 background wake turns", () => {
24362455 Stream.runForEach((event) =>
24372456 Effect.gen(function* () {
24382457 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 }
24402461 if (event.type === "turn.terminal") {
24412462 yield* Queue.offer(terminalReceipts, event);
24422463 }
......@@ -2474,6 +2495,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
24742495 },
24752496 permissionModeChanges,
24762497 continuationRequests,
2498 continuationReceipts,
24772499 events,
24782500 terminalReceipts,
24792501 subagentReceipts,
......@@ -4709,6 +4731,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
47094731 offer: () => Effect.void,
47104732 setModel: () => Effect.void,
47114733 setPermissionMode: () => Effect.void,
4734 stopTask: () => Effect.void,
47124735 interrupt: Effect.void,
47134736 // The first CLI process keeps streaming until the test ends
47144737 // it, so Stop stays parked waiting for it to exit.
......@@ -4968,6 +4991,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
49684991 offer: () => Effect.void,
49694992 setModel: () => Effect.void,
49704993 setPermissionMode: () => Effect.void,
4994 stopTask: () => Effect.void,
49714995 interrupt: Effect.void,
49724996 close: Queue.shutdown(queue),
49734997 };
......@@ -6547,6 +6571,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
65476571 const harness = yield* makeWakeHarnessWithOptions({
65486572 close: (sdkMessages) =>
65496573 Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))),
6574 stopTask: () => Effect.void,
65506575 interrupt: Deferred.succeed(interruptStarted, undefined),
65516576 });
65526577 const idAllocator = yield* IdAllocator.IdAllocatorV2;
......@@ -6608,6 +6633,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
66086633 const harness = yield* makeWakeHarnessWithOptions({
66096634 close: (sdkMessages) =>
66106635 Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))),
6636 stopTask: () => Effect.void,
66116637 interrupt: Deferred.succeed(interruptStarted, undefined),
66126638 });
66136639 const idAllocator = yield* IdAllocator.IdAllocatorV2;
......@@ -9692,6 +9718,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
96929718 offer: () => Effect.void,
96939719 setModel: () => Effect.void,
96949720 setPermissionMode: () => Effect.void,
9721 stopTask: () => Effect.void,
96959722 interrupt: Effect.void,
96969723 // End this process stream so openQuery can replace it.
96979724 close: Queue.shutdown(sdkMessages),
......@@ -9881,6 +9908,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
98819908 offer: () => Effect.void,
98829909 setModel: () => Effect.void,
98839910 setPermissionMode: () => Effect.void,
9911 stopTask: () => Effect.void,
98849912 interrupt: Effect.void,
98859913 close: Queue.shutdown(sdkMessages),
98869914 };
......@@ -10122,6 +10150,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1012210150 offer: () => Effect.void,
1012310151 setModel: () => Effect.void,
1012410152 setPermissionMode: () => Effect.void,
10153 stopTask: () => Effect.void,
1012510154 interrupt: Effect.void,
1012610155 close: Queue.shutdown(sdkMessages),
1012710156 };
......@@ -10320,6 +10349,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1032010349 offer: () => Effect.void,
1032110350 setModel: () => Effect.void,
1032210351 setPermissionMode: () => Effect.void,
10352 stopTask: () => Effect.void,
1032310353 interrupt: Effect.void,
1032410354 close: Queue.shutdown(sdkMessages),
1032510355 };
......@@ -10502,6 +10532,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1050210532 offer: () => Effect.void,
1050310533 setModel: () => Effect.void,
1050410534 setPermissionMode: () => Effect.void,
10535 stopTask: () => Effect.void,
1050510536 interrupt: Effect.void,
1050610537 close: Queue.shutdown(sdkMessages),
1050710538 };
......@@ -10640,6 +10671,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1064010671 offer: () => Effect.void,
1064110672 setModel: () => Effect.void,
1064210673 setPermissionMode: () => Effect.void,
10674 stopTask: () => Effect.void,
1064310675 interrupt: Effect.void,
1064410676 close: Queue.shutdown(sdkMessages),
1064510677 };
......@@ -10874,6 +10906,2345 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1087410906 ),
1087510907 );
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
1087713248 describe("native goals", () => {
1087813249 const syntheticFrame = (uuid: string, text: string) =>
1087913250 claudeSdkFrame({
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts+6-1
......@@ -17,8 +17,8 @@ import {
1717 type ProviderReplayTranscript,
1818} from "@t3tools/contracts";
1919import * as Cause from "effect/Cause";
20import * as Duration from "effect/Duration";
2120import * as Crypto from "effect/Crypto";
21import * as Duration from "effect/Duration";
2222import * as Effect from "effect/Effect";
2323import * as Exit from "effect/Exit";
2424import * as Layer from "effect/Layer";
......@@ -242,6 +242,7 @@ interface ClaudeSubagentFoundFrame {
242242}
243243
244244type ClaudeOutboundFrame =
245 | { readonly type: "query.stop_task"; readonly taskId: string }
245246 | ClaudeQueryOpenFrame
246247 | ClaudePromptOfferFrame
247248 | ClaudeQuerySetModelFrame
......@@ -825,6 +826,10 @@ function makeReplayQueryRunner(
825826 mode,
826827 });
827828 }),
829 stopTask: (taskId) =>
830 replayEffect(() => {
831 assertNextOutboundFrame({ type: "query.stop_task", taskId });
832 }),
828833 interrupt: replayEffect(() => {
829834 assertNextOutboundFrame({ type: "query.interrupt" });
830835 }),
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+896-235
......@@ -4,6 +4,15 @@ import {
44 formatReadToolLabel,
55 formatSearchToolLabel,
66} 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
716import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
817import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts";
918import {
......@@ -58,6 +67,9 @@ import {
5867 type OrchestrationV2RuntimeRequest,
5968 type OrchestrationV2UserInputQuestion,
6069 type OrchestrationV2Subagent,
70 type OrchestrationV2AppThread,
71 type OrchestrationV2SubagentWorkflow,
72 type OrchestrationV2WorkflowRunHandles,
6173 type OrchestrationV2TurnItem,
6274 type OrchestrationV2WebSearchResult,
6375 type ProviderApprovalDecision,
......@@ -66,15 +78,18 @@ import {
6678 type ProviderRequestKind,
6779 type ProviderUserInputAnswers,
6880 type ProviderThreadId,
81 type RunId,
6982 type ThreadId,
7083} from "@t3tools/contracts";
7184
7285import * as Cause from "effect/Cause";
7386import * as Context from "effect/Context";
7487import * as Crypto from "effect/Crypto";
88import * as Hex from "effect/encoding/Hex";
7589import * as DateTime from "effect/DateTime";
7690import * as Deferred from "effect/Deferred";
7791import * as Effect from "effect/Effect";
92import * as Equal from "effect/Equal";
7893import * as Exit from "effect/Exit";
7994import * as FileSystem from "effect/FileSystem";
8095import * as Layer from "effect/Layer";
......@@ -83,6 +98,7 @@ import * as Path from "effect/Path";
8398import * as Queue from "effect/Queue";
8499import * as Ref from "effect/Ref";
85100import * as Schema from "effect/Schema";
101import * as Semaphore from "effect/Semaphore";
86102import * as Stream from "effect/Stream";
87103
88104import { resolveAttachmentPath } from "../../attachmentStore.ts";
......@@ -365,7 +381,7 @@ export interface ClaudeAgentSdkQuerySession {
365381 readonly setPermissionMode: (
366382 mode: PermissionMode,
367383 ) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
368 readonly stopTask?: (taskId: string) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
384 readonly stopTask: (taskId: string) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
369385 readonly interrupt: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
370386 readonly close: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
371387}
......@@ -513,6 +529,14 @@ export type ClaudeAgentSdkProtocolLogEvent =
513529 readonly mode: PermissionMode;
514530 };
515531 }
532 | {
533 readonly direction: "outgoing";
534 readonly stage: "decoded";
535 readonly payload: {
536 readonly type: "query.stop_task";
537 readonly taskId: string;
538 };
539 }
516540 | {
517541 readonly direction: "outgoing";
518542 readonly stage: "decoded";
......@@ -809,7 +833,15 @@ export const layerQueryRunner: Layer.Layer<
809833 Effect.tryPromise({
810834 try: () => queryRuntime.stopTask(taskId),
811835 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 ),
813845 interrupt: Effect.tryPromise({
814846 try: () => queryRuntime.interrupt(),
815847 catch: (cause) => queryRunnerError(cause, "interrupt"),
......@@ -1534,6 +1566,12 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input:
15341566 } satisfies SDKUserMessage;
15351567});
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
15371575export function isClaudePromptUuid(value: string): value is NonNullable<SDKUserMessage["uuid"]> {
15381576 return /^[^-]+-[^-]+-[^-]+-[^-]+-[^-]+$/.test(value);
15391577}
......@@ -2908,6 +2946,12 @@ interface ActiveClaudeSubagent {
29082946 // owning subagent's node for a subagent another subagent started.
29092947 readonly rootNodeId: OrchestrationV2ExecutionNode["id"];
29102948 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;
29112955 readonly childRootNodeId: OrchestrationV2ExecutionNode["id"];
29122956 readonly turnItemId: OrchestrationV2TurnItem["id"];
29132957 readonly turnItemOrdinal: number;
......@@ -2932,6 +2976,8 @@ interface ClaudeLiveQueryContext {
29322976 readonly queryPolicyKey: string;
29332977 readonly selectionKey: string;
29342978 readonly closed: Deferred.Deferred<void, never>;
2979 // Retained for process-exit cleanup even when its last turn failed.
2980 lastTurn: ActiveClaudeTurnContext | null;
29352981 // Whether this CLI process echoes a prompt's uuid on the first frame of
29362982 // the turn answering it ("early") or only on its result. Learned from the
29372983 // first prompt turn. "acknowledged": the CLI confirmed it took a prompt's
......@@ -3334,6 +3380,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
33343380 });
33353381 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
33363382 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 );
33373406 const interruptedTurns = yield* Ref.make(new Set<OrchestrationV2ProviderTurn["id"]>());
33383407 const steeredTurns = yield* Ref.make(new Set<OrchestrationV2ProviderTurn["id"]>());
33393408 const queryContext = yield* Ref.make<ClaudeLiveQueryContext | null>(null);
......@@ -4335,6 +4404,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
43354404 const ids = claudeSubagentIds(resume.context, resume.taskId);
43364405 const now = yield* DateTime.now;
43374406 const subagent: ActiveClaudeSubagent = {
4407 childThread: null,
43384408 task: {
43394409 id: ids.nodeId,
43404410 threadId: resume.context.input.threadId,
......@@ -4385,6 +4455,341 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
43854455 );
43864456 });
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
43884793 const updateClaudeSubagentNode = Effect.fnUntraced(function* (input: {
43894794 readonly context: ActiveClaudeTurnContext;
43904795 readonly taskId: string;
......@@ -4397,24 +4802,39 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
43974802 readonly effort?: string;
43984803 readonly owner?: ActiveClaudeSubagent;
43994804 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;
44004809 readonly result?: string;
44014810 readonly status: Extract<
44024811 OrchestrationV2ExecutionNode["status"],
44034812 "running" | "completed" | "failed" | "cancelled"
44044813 >;
44054814 readonly reopen?: boolean;
4815 readonly onlyIfRunningInRun?: RunId | null;
44064816 }) {
44074817 // The session registry lets a wake-replay turn (fresh context maps)
44084818 // 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.
44094821 const existingSubagent =
4410 input.context.subagentsByTaskId.get(input.taskId) ??
4411 (input.toolUseId === undefined
4412 ? undefined
4413 : input.context.subagentsByToolUseId.get(input.toolUseId)) ??
4414 (yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId);
4822 input.onlyIfRunningInRun !== undefined
4823 ? registeredSubagent
4824 : (input.context.subagentsByTaskId.get(input.taskId) ??
4825 (input.toolUseId === undefined
4826 ? undefined
4827 : input.context.subagentsByToolUseId.get(input.toolUseId)) ??
4828 registeredSubagent);
44154829 if (existingSubagent === undefined && input.status !== "running") {
44164830 return;
44174831 }
4832 if (
4833 input.onlyIfRunningInRun !== undefined &&
4834 (existingSubagent?.task.status !== "running" ||
4835 existingSubagent.task.runId !== input.onlyIfRunningInRun)
4836 )
4837 return;
44184838 // Status is monotone with one exception: task_started for a known
44194839 // task id is an authoritative CLI lifecycle event (SendMessage to a
44204840 // completed subagent resumes it and re-emits task_started with the
......@@ -4433,23 +4853,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
44334853 ) {
44344854 return;
44354855 }
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 }
44534856 // A task_started under a tool call other than the current run's is
44544857 // a resume: SendMessage re-emits task_started for the same task id
44554858 // under its own tool_use_id, with the sent message as the prompt.
......@@ -4493,6 +4896,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
44934896 existingSubagent.task,
44944897 )
44954898 : 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);
44964914 const effort =
44974915 existingSubagent === undefined
44984916 ? (input.effort ??
......@@ -4552,6 +4970,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
45524970 }
45534971 : {}),
45544972 ...(input.progress === undefined ? {} : { progress: input.progress }),
4973 ...(workflowWithThreads === undefined ? {} : { workflow: workflowWithThreads }),
45554974 ...(input.result === undefined ? {} : { result: input.result }),
45564975 ...(isReopen ? { startedAt: now } : {}),
45574976 completedAt: input.status === "running" ? null : (priorTask?.completedAt ?? now),
......@@ -4568,6 +4987,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
45684987 existingSubagent === undefined
45694988 ? (input.toolUseId ?? null)
45704989 : (resumeToolUseId ?? existingSubagent.runToolUseId),
4990 childThread: existingSubagent?.childThread ?? null,
45714991 effort,
45724992 nextChildItemOrdinal: existingSubagent?.nextChildItemOrdinal ?? 100,
45734993 resultItemOrdinal: existingSubagent?.resultItemOrdinal ?? null,
......@@ -4579,16 +4999,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
45794999 lastAssistantMessageId:
45805000 input.reopen === true ? null : (existingSubagent?.lastAssistantMessageId ?? null),
45815001 } 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 }
45925002 // The same terminal protection, applied atomically: a concurrent
45935003 // fiber (live stream vs continuation drain) may have terminalized
45945004 // the registry entry after this update's lookup read it. A resume
......@@ -4596,20 +5006,51 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
45965006 // is still the terminal generation the lookup resolved; if a
45975007 // concurrent fiber installed a newer terminal entry meanwhile, the
45985008 // re-open must not clobber its result.
4599 yield* Ref.update(sessionSubagentsByTaskId, (current) => {
5009 const accepted = yield* Ref.modify(sessionSubagentsByTaskId, (current) => {
46005010 const registered = current.get(input.taskId);
46015011 if (
4602 registered !== undefined &&
4603 registered.task.status !== "running" &&
4604 input.status === "running" &&
4605 !(isReopen && registered === existingSubagent)
5012 (input.onlyIfRunningInRun !== undefined &&
5013 (registered?.task.status !== "running" ||
5014 registered.task.runId !== input.onlyIfRunningInRun)) ||
5015 (registered !== undefined &&
5016 registered.task.status !== "running" &&
5017 input.status === "running" &&
5018 !(isReopen && registered === existingSubagent))
46065019 ) {
4607 return current;
5020 return [false, current] as const;
46085021 }
4609 return new Map(current).set(input.taskId, subagent);
5022 return [true, new Map(current).set(input.taskId, subagent)] as const;
46105023 });
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) {
46135054 const childThread = makeSubagentChildThread({
46145055 parentThread: input.context.input.appThread,
46155056 childThreadId,
......@@ -4620,18 +5061,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
46205061 title: subagentThreadTitle({
46215062 parentTitle: input.context.input.appThread.title,
46225063 prompt: task.prompt,
4623 title: task.title,
5064 title:
5065 workflowWithThreads === undefined
5066 ? task.title
5067 : `Workflow · ${workflowWithThreads.name ?? task.title ?? "Untitled"}`,
46245068 ordinal: input.context.subagentsByTaskId.size,
46255069 }),
46265070 now,
46275071 createdBy: "agent",
46285072 creationSource: "provider",
46295073 });
4630 yield* emitProviderEvent({
4631 type: "app_thread.created",
4632 driver: CLAUDE_PROVIDER,
4633 appThread: childThread,
4634 });
5074 if (existingSubagent === undefined) {
5075 yield* emitProviderEvent({
5076 type: "app_thread.created",
5077 driver: CLAUDE_PROVIDER,
5078 appThread: childThread,
5079 });
5080 }
5081 subagent.childThread = childThread;
46355082 }
46365083
46375084 if (lifecycleChanged) {
......@@ -4695,46 +5142,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
46955142 ? null
46965143 : `${nativeItemId}:prompt:${resumeToolUseId}`;
46975144 if (promptNativeItemId !== null) {
4698 const promptArtifacts = makeSubagentConversationArtifacts({
5145 yield* emitSubagentConversationMessage({
5146 nativeItemId: promptNativeItemId,
46995147 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 }),
47085148 threadId: childThreadId,
47095149 rootNodeId: childRootNodeId,
4710 providerThreadId: null,
4711 providerTurnId: null,
4712 nativeItemRef: {
4713 driver: CLAUDE_PROVIDER,
4714 nativeId: promptNativeItemId,
4715 strength: "strong",
4716 },
47175150 role: "user",
47185151 text: task.prompt,
47195152 ordinal: existingSubagent === undefined ? 100 : ++subagent.nextChildItemOrdinal,
47205153 now,
47215154 });
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 });
47325155 }
47335156 yield* emitProviderEvent({
47345157 type: "subagent.updated",
47355158 driver: CLAUDE_PROVIDER,
47365159 subagent: task,
47375160 });
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 }
47385168 yield* emitProviderEvent({
47395169 type: "turn_item.updated",
47405170 driver: CLAUDE_PROVIDER,
......@@ -4782,39 +5212,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
47825212 const resultNativeItemId = `${nativeItemId}:result`;
47835213 const resultItemOrdinal = subagent.resultItemOrdinal ?? ++subagent.nextChildItemOrdinal;
47845214 subagent.resultItemOrdinal = resultItemOrdinal;
4785 const resultArtifacts = makeSubagentConversationArtifacts({
4786 messageId: idAllocator.derive.messageFromProviderItem({
4787 driver: CLAUDE_PROVIDER,
4788 nativeItemId: resultNativeItemId,
4789 }),
4790 turnItemId: idAllocator.derive.turnItemFromProviderItem({
4791 driver: CLAUDE_PROVIDER,
4792 nativeItemId: resultNativeItemId,
4793 }),
5215 yield* emitSubagentConversationMessage({
5216 nativeItemId: resultNativeItemId,
47945217 threadId: childThreadId,
47955218 rootNodeId: childRootNodeId,
4796 providerThreadId: null,
4797 providerTurnId: null,
4798 nativeItemRef: {
4799 driver: CLAUDE_PROVIDER,
4800 nativeId: resultNativeItemId,
4801 strength: "strong",
4802 },
48035219 role: "assistant",
48045220 text: input.result,
48055221 ordinal: resultItemOrdinal,
48065222 now,
48075223 });
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 });
48185224 }
48195225 });
48205226
......@@ -5281,6 +5687,33 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
52815687 readonly result?: SDKResultMessage;
52825688 }) {
52835689 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 }
52845717 // A subagent still running in the background keeps its open calls:
52855718 // their results arrive after this turn. One left by an earlier CLI
52865719 // process is gone and never reports them.
......@@ -5288,14 +5721,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
52885721 const runningChildThreadIds = new Set(
52895722 [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].flatMap((subagent) =>
52905723 subagent.task.status === "running" &&
5724 (input.status === "completed" ||
5725 (liveQuery !== null && subagent.task.runId !== input.context.input.runId)) &&
52915726 liveQuery?.subagentsFromEarlierProcesses.has(subagent) !== true
52925727 ? [subagent.childThreadId]
52935728 : [],
52945729 ),
52955730 );
52965731 const endingSubagentCalls = [...subagentToolCalls.values()].filter(
5297 (toolCall) =>
5298 input.status !== "completed" || !runningChildThreadIds.has(toolCall.threadId),
5732 (toolCall) => !runningChildThreadIds.has(toolCall.threadId),
52995733 );
53005734 yield* endToolCalls({
53015735 context: input.context,
......@@ -5570,27 +6004,72 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
55706004 });
55716005
55726006 const finalizeActiveTurnAfterQueryExit = Effect.fnUntraced(function* (
6007 closedQuery: ClaudeLiveQueryContext,
55736008 cause?: Cause.Cause<ClaudeAgentSdkQueryRunnerError>,
55746009 ) {
5575 const context = yield* Ref.get(activeTurn);
5576 if (context === null) {
5577 return;
5578 }
5579 const completedAt = yield* DateTime.now;
5580 const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId);
5581 yield* finalizeActiveTurn({
5582 context,
5583 status: interrupted ? "interrupted" : "failed",
5584 completedAt,
5585 ...(interrupted
5586 ? {}
5587 : {
5588 failure: makeProviderFailure({
5589 cause: cause === undefined ? undefined : Cause.squash(cause),
5590 class: "transport_error",
6010 const current = yield* Ref.get(activeTurn);
6011 const context =
6012 current?.nativeThreadId === closedQuery.nativeThreadId ? current : closedQuery.lastTurn;
6013 if (context === null) return;
6014 const interrupted =
6015 closedQuery.stopping || (yield* Ref.get(interruptedTurns)).has(context.providerTurnId);
6016 const subagents = yield* Ref.get(sessionSubagentsByTaskId);
6017 const buffered = (yield* Ref.get(wakeBuffers)).get(closedQuery.nativeThreadId)?.messages;
6018 if (current === context) {
6019 yield* finalizeActiveTurn({
6020 context,
6021 status: interrupted ? "interrupted" : "failed",
6022 completedAt: yield* DateTime.now,
6023 ...(interrupted
6024 ? {}
6025 : {
6026 failure: makeProviderFailure({
6027 cause: cause === undefined ? undefined : Cause.squash(cause),
6028 class: "transport_error",
6029 }),
55916030 }),
5592 }),
5593 });
6031 });
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 }
55946073 yield* Ref.update(interruptedTurns, (current) => {
55956074 const next = new Set(current);
55966075 next.delete(context.providerTurnId);
......@@ -5606,6 +6085,64 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
56066085 }
56076086 });
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
56096146 const bufferWakeMessage = Effect.fnUntraced(function* (wakeInput: {
56106147 readonly nativeThreadId: string;
56116148 readonly message: SDKMessage;
......@@ -6131,6 +6668,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
61316668 (yield* Ref.get(activeTurn)) ??
61326669 (yield* settledTurnForSubagentFrame(liveQuery.nativeThreadId, message));
61336670 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);
61346678 // task_notification must buffer wake evidence while still tracked
61356679 // on the roster; clearing first would drop the wake pin.
61366680 if (message.type === "system" && message.subtype === "task_notification") {
......@@ -6523,6 +7067,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
65237067 toolUseId: message.tool_use_id,
65247068 title: message.description,
65257069 });
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
65267078 yield* updateClaudeSubagentNode({
65277079 context,
65287080 taskId: message.task_id,
......@@ -6531,10 +7083,37 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
65317083 ...(model === undefined ? {} : { model }),
65327084 ...(launch?.effort === undefined ? {} : { effort: launch.effort }),
65337085 ...(owner === undefined ? {} : { owner }),
7086 ...(isWorkflow
7087 ? {
7088 workflowFrame: message,
7089 ...(workflowName === undefined ? {} : { workflowName }),
7090 }
7091 : {}),
7092
65347093 title: message.description,
65357094 status: "running",
65367095 reopen: true,
65377096 });
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 });
65387117 }
65397118 }
65407119
......@@ -6544,8 +7123,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
65447123 liveQuery.nativeThreadId,
65457124 message.task_id,
65467125 );
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;
65477132 if (
6548 progress.length > 0 &&
7133 (carriesWorkflowTelemetry || (progress.length > 0 && !isWorkflowCoordinator)) &&
65497134 !context.ignoredTaskIds.has(message.task_id) &&
65507135 !isBackgroundTask
65517136 ) {
......@@ -6553,7 +7138,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
65537138 context,
65547139 taskId: message.task_id,
65557140 ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }),
6556 progress,
7141 ...(progress.length === 0 ? {} : { progress }),
7142 workflowFrame: message,
65577143 status: "running",
65587144 });
65597145 }
......@@ -6607,6 +7193,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
66077193 context,
66087194 taskId: message.task_id,
66097195 ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }),
7196 // Terminal usage totals arrive here, after the last snapshot.
7197 workflowFrame: message,
66107198 result: message.summary,
66117199 status:
66127200 message.status === "completed"
......@@ -6673,10 +7261,25 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
66737261 }
66747262
66757263 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).
66797264 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 }
66807283 // A resume task_started reuses the resuming tool call's
66817284 // tool_use_id (e.g. SendMessage), whose tool_result only
66827285 // acknowledges delivery. Only the Agent launch's tool_result may
......@@ -6689,7 +7292,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
66897292 // A background Agent launch resolves its tool_use immediately
66907293 // with an async-launch ACK while the task keeps running; only
66917294 // the eventual task_notification terminalizes the subagent.
6692 if (isClaudeSubagentAsyncLaunchAck(output)) {
7295 if (
7296 !isClaudeToolResultError(toolResult) &&
7297 (runHandles !== undefined || isClaudeSubagentAsyncLaunchAck(output))
7298 ) {
66937299 continue;
66947300 }
66957301 const result = claudeSubagentResultText(output);
......@@ -6838,39 +7444,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
68387444 ? `${subagent.lastAssistantText ?? ""}\n${assistantText.text}`
68397445 : assistantText.text;
68407446 subagent.lastAssistantMessageId = nativeMessageId;
6841 const artifacts = makeSubagentConversationArtifacts({
6842 messageId: idAllocator.derive.messageFromProviderItem({
6843 driver: CLAUDE_PROVIDER,
6844 nativeItemId: assistantText.nativeItemId,
6845 }),
6846 turnItemId: idAllocator.derive.turnItemFromProviderItem({
6847 driver: CLAUDE_PROVIDER,
6848 nativeItemId: assistantText.nativeItemId,
6849 }),
7447 yield* emitSubagentConversationMessage({
7448 nativeItemId: assistantText.nativeItemId,
68507449 threadId: subagent.childThreadId,
68517450 rootNodeId: subagent.childRootNodeId,
6852 providerThreadId: null,
6853 providerTurnId: null,
6854 nativeItemRef: {
6855 driver: CLAUDE_PROVIDER,
6856 nativeId: assistantText.nativeItemId,
6857 strength: "strong",
6858 },
68597451 role: "assistant",
68607452 text: assistantText.text,
68617453 ordinal: ++subagent.nextChildItemOrdinal,
68627454 now,
68637455 });
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 });
68747456 return;
68757457 }
68767458 if (assistantText !== null && assistantText.text.length > 0) {
......@@ -7726,6 +8308,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
77268308 queryPolicyKey,
77278309 selectionKey: compiledSelection.queryIdentity,
77288310 closed,
8311 lastTurn: null,
77298312 promptEchoMode: "unknown",
77308313 openedPermissionMode: queryOptions.permissionMode,
77318314 permissionMode: queryOptions.permissionMode,
......@@ -7738,16 +8321,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
77388321 };
77398322 yield* Ref.set(queryContext, context);
77408323 yield* querySession.messages.pipe(
7741 Stream.runForEach((message) => {
7742 if (
7743 message.type === "system" &&
7744 (message.subtype === "init" || message.subtype === "status") &&
7745 message.permissionMode !== undefined
7746 ) {
7747 context.permissionMode = message.permissionMode;
7748 }
7749 return handleSdkMessage({ query: querySession, message });
7750 }),
8324 Stream.runForEach((message) =>
8325 sdkMessageLock.withPermits(1)(
8326 Effect.suspend(() => {
8327 if (
8328 message.type === "system" &&
8329 (message.subtype === "init" || message.subtype === "status") &&
8330 message.permissionMode !== undefined
8331 ) {
8332 context.permissionMode = message.permissionMode;
8333 }
8334 return handleSdkMessage({ query: querySession, message });
8335 }),
8336 ),
8337 ),
77518338 Effect.exit,
77528339 Effect.flatMap(
77538340 Effect.fnUntraced(function* (exit: ClaudeQueryStreamExit) {
......@@ -7767,6 +8354,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
77678354 );
77688355 if (ownsLiveQuery) {
77698356 yield* finalizeActiveTurnAfterQueryExit(
8357 context,
77708358 exit._tag === "Failure" ? exit.cause : undefined,
77718359 );
77728360 yield* endSubagentCallsOfClosedQuery(
......@@ -7885,17 +8473,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
78858473 uuid: promptUuid,
78868474 });
78878475 const querySession = yield* openQuery(turnInput, nativeThreadId);
7888 yield* Ref.set(activeTurn, context);
7889 yield* emitProviderEvent({
7890 type: "provider_turn.updated",
7891 driver: CLAUDE_PROVIDER,
7892 providerTurn: providerTurnPayload({
7893 context,
7894 status: "running",
7895 completedAt: null,
7896 }),
8476 const attachTurn = Effect.gen(function* () {
8477 querySession.lastTurn = context;
8478 yield* Ref.set(activeTurn, context);
8479 yield* emitProviderEvent({
8480 type: "provider_turn.updated",
8481 driver: CLAUDE_PROVIDER,
8482 providerTurn: providerTurnPayload({
8483 context,
8484 status: "running",
8485 completedAt: null,
8486 }),
8487 });
78978488 });
78988489 if (userMessage !== null) {
8490 yield* attachTurn;
78998491 // A user turn that races a wake leaves the buffer alone: the
79008492 // continuation run the worker queued behind this run drains it
79018493 // afterwards with correct attribution.
......@@ -7904,64 +8496,95 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
79048496 yield* querySession.query.offer(userMessage);
79058497 return;
79068498 }
7907 const drained = yield* Ref.modify(wakeBuffers, (current) => {
7908 const entry = current.get(nativeThreadId);
7909 if (entry === undefined) {
7910 return [[] as ReadonlyArray<SDKMessage>, current] as const;
7911 }
7912 const updated = new Map(current);
7913 updated.delete(nativeThreadId);
7914 return [entry.messages, updated] as const;
7915 });
7916 yield* Ref.update(requestedContinuations, (current) => {
7917 const updated = new Set(current);
7918 updated.delete(nativeThreadId);
7919 return updated;
7920 });
7921 if (drained.length === 0) {
7922 // Spurious continuation (buffer already lost with a recycled
7923 // session, or a duplicate request): settle immediately instead
7924 // of leaving a run waiting on a prompt that was never sent.
7925 const completedAt = yield* DateTime.now;
7926 yield* finalizeActiveTurn({ context, status: "completed", completedAt });
7927 return;
7928 }
7929 // Replay any result message last: a result finalizes the turn, and
7930 // replaying it before the rest would drop them back into the wake
7931 // buffer and request another continuation.
7932 const resultMessages = drained.filter((entry) => entry.type === "result");
7933 const opaqueReplayTombstones = taskIdSetForNativeThread(
7934 yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread),
7935 nativeThreadId,
7936 );
7937 const hasOpaqueTaskNotification = drained.some(
7938 (entry) =>
7939 entry.type === "system" &&
7940 entry.subtype === "task_notification" &&
7941 opaqueReplayTombstones.has(entry.task_id),
7942 );
7943 for (const entry of drained) {
7944 if (entry.type !== "result") {
7945 yield* handleSdkMessage({ query: querySession.query, message: entry });
7946 }
7947 }
7948 const lastResult = resultMessages.at(-1);
7949 if (lastResult !== undefined) {
7950 yield* handleSdkMessage({ query: querySession.query, message: lastResult });
7951 return;
7952 }
7953 // A drained `init` means Claude began the wake turn, so its output
7954 // may still be on the way: stay open for it.
7955 const hasNativeWakeFrame = drained.some(
7956 (entry) =>
7957 entry.type === "user" ||
7958 entry.type === "assistant" ||
7959 isClaudeTurnStartMessage(entry),
8499 // Transcript reads suspend replay. Hold live SDK frames until the whole
8500 // buffer has been applied so starts, completions and the root result stay ordered.
8501 yield* sdkMessageLock.withPermits(1)(
8502 Effect.gen(function* () {
8503 if (
8504 (yield* Ref.get(queryContext))?.query !== querySession.query ||
8505 querySession.stopping
8506 ) {
8507 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
8508 driver: CLAUDE_PROVIDER,
8509 detail: "Claude provider query ended before the continuation could start.",
8510 });
8511 }
8512 // Live frames can finish while this continuation waits for the permit.
8513 yield* attachTurn;
8514 // Exit and interrupt cleanup can run during transcript reads.
8515 // Keep pending outcomes visible until replay applies them.
8516 const drained = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? [];
8517 yield* Ref.update(requestedContinuations, (current) => {
8518 const updated = new Set(current);
8519 updated.delete(nativeThreadId);
8520 return updated;
8521 });
8522 if (drained.length === 0) {
8523 // Spurious continuation (buffer already lost with a recycled
8524 // session, or a duplicate request): settle immediately instead
8525 // of leaving a run waiting on a prompt that was never sent.
8526 const completedAt = yield* DateTime.now;
8527 yield* finalizeActiveTurn({ context, status: "completed", completedAt });
8528 return;
8529 }
8530 // Replay any result message last: a result finalizes the turn, and
8531 // replaying it before the rest would drop them back into the wake
8532 // buffer and request another continuation.
8533 const resultMessages = drained.filter((entry) => entry.type === "result");
8534 const opaqueReplayTombstones = taskIdSetForNativeThread(
8535 yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread),
8536 nativeThreadId,
8537 );
8538 const hasOpaqueTaskNotification = drained.some(
8539 (entry) =>
8540 entry.type === "system" &&
8541 entry.subtype === "task_notification" &&
8542 opaqueReplayTombstones.has(entry.task_id),
8543 );
8544 for (const entry of drained) {
8545 if (
8546 !(yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages.includes(entry)
8547 ) {
8548 continue;
8549 }
8550 if (entry.type !== "result") {
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 }),
79608587 );
7961 if (hasOpaqueTaskNotification && !hasNativeWakeFrame) {
7962 const completedAt = yield* DateTime.now;
7963 yield* finalizeActiveTurn({ context, status: "completed", completedAt });
7964 }
79658588 },
79668589 (effect, turnInput) =>
79678590 effect.pipe(
......@@ -8042,14 +8665,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
80428665 providerThreadId: turnInput.providerThread.id,
80438666 providerTurnId: turnInput.providerTurnId,
80448667 });
8045 yield* Ref.update(queryContext, (current) =>
8046 current?.query === existing.query ? null : current,
8668 const ownedLiveQuery = yield* Ref.modify(queryContext, (current) =>
8669 current?.query === existing.query ? [true, null] : [false, current],
80478670 );
8048 yield* finalizeActiveTurn({
8049 context: currentTurn,
8050 status: "interrupted",
8051 completedAt,
8052 });
8671 if (ownedLiveQuery) {
8672 existing.stopping = true;
8673 yield* finalizeActiveTurnAfterQueryExit(existing);
8674 yield* endSubagentCallsOfClosedQuery(existing.nativeThreadId, "interrupted");
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 }
80538683 yield* Deferred.succeed(existing.closed, undefined);
80548684 },
80558685 (effect, turnInput) =>
......@@ -8155,6 +8785,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
81558785 current?.query === existing.query ? [true, null] : [false, current],
81568786 );
81578787 if (ownedLiveQuery) {
8788 yield* finalizeActiveTurnAfterQueryExit(existing);
81588789 yield* endSubagentCallsOfClosedQuery(nativeThreadId, "interrupted");
81598790 }
81608791 yield* Deferred.succeed(existing.closed, undefined);
......@@ -8266,6 +8897,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
82668897 message: { ...turnInput.message, text: "/compact" },
82678898 }),
82688899 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 }),
82698930 interruptTurn,
82708931 stopSubagent: Effect.fn("ClaudeAdapterV2.stopSubagent")(function* (input) {
82718932 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* (
49764976 node: artifacts.node,
49774977 });
49784978 yield* emitProviderEvent({
4979 type: "runtime_request.updated",
4979 type: "turn_item.updated",
49804980 driver: CODEX_PROVIDER,
4981 threadId: artifacts.node.threadId,
4982 runtimeRequest: artifacts.request,
4981 turnItem: artifacts.turnItem,
49834982 });
49844983 yield* emitProviderEvent({
4985 type: "turn_item.updated",
4984 type: "runtime_request.updated",
49864985 driver: CODEX_PROVIDER,
4987 turnItem: artifacts.turnItem,
4986 threadId: artifacts.node.threadId,
4987 runtimeRequest: artifacts.request,
49884988 });
49894989 yield* Ref.update(finalAnswerItemIdsByTurn, (current) => {
49904990 const ids = current.get(payload.turnId);
......@@ -5083,15 +5083,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
50835083 node: artifacts.node,
50845084 });
50855085 yield* emitProviderEvent({
5086 type: "runtime_request.updated",
5086 type: "turn_item.updated",
50875087 driver: CODEX_PROVIDER,
5088 threadId: artifacts.node.threadId,
5089 runtimeRequest: artifacts.request,
5088 turnItem: artifacts.turnItem,
50905089 });
50915090 yield* emitProviderEvent({
5092 type: "turn_item.updated",
5091 type: "runtime_request.updated",
50935092 driver: CODEX_PROVIDER,
5094 turnItem: artifacts.turnItem,
5093 threadId: artifacts.node.threadId,
5094 runtimeRequest: artifacts.request,
50955095 });
50965096
50975097 const resolved = yield* Deferred.await(decision).pipe(
......@@ -5143,15 +5143,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
51435143 node: artifacts.node,
51445144 });
51455145 yield* emitProviderEvent({
5146 type: "runtime_request.updated",
5146 type: "turn_item.updated",
51475147 driver: CODEX_PROVIDER,
5148 threadId: artifacts.node.threadId,
5149 runtimeRequest: artifacts.request,
5148 turnItem: artifacts.turnItem,
51505149 });
51515150 yield* emitProviderEvent({
5152 type: "turn_item.updated",
5151 type: "runtime_request.updated",
51535152 driver: CODEX_PROVIDER,
5154 turnItem: artifacts.turnItem,
5153 threadId: artifacts.node.threadId,
5154 runtimeRequest: artifacts.request,
51555155 });
51565156
51575157 const resolved = yield* Deferred.await(decision).pipe(
......@@ -5204,15 +5204,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
52045204 node: artifacts.node,
52055205 });
52065206 yield* emitProviderEvent({
5207 type: "runtime_request.updated",
5207 type: "turn_item.updated",
52085208 driver: CODEX_PROVIDER,
5209 threadId: artifacts.node.threadId,
5210 runtimeRequest: artifacts.request,
5209 turnItem: artifacts.turnItem,
52115210 });
52125211 yield* emitProviderEvent({
5213 type: "turn_item.updated",
5212 type: "runtime_request.updated",
52145213 driver: CODEX_PROVIDER,
5215 turnItem: artifacts.turnItem,
5214 threadId: artifacts.node.threadId,
5215 runtimeRequest: artifacts.request,
52165216 });
52175217
52185218 const resolved = yield* Deferred.await(decision).pipe(
......@@ -5289,15 +5289,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
52895289 node: artifacts.node,
52905290 });
52915291 yield* emitProviderEvent({
5292 type: "runtime_request.updated",
5292 type: "turn_item.updated",
52935293 driver: CODEX_PROVIDER,
5294 threadId: artifacts.node.threadId,
5295 runtimeRequest: artifacts.request,
5294 turnItem: artifacts.turnItem,
52965295 });
52975296 yield* emitProviderEvent({
5298 type: "turn_item.updated",
5297 type: "runtime_request.updated",
52995298 driver: CODEX_PROVIDER,
5300 turnItem: artifacts.turnItem,
5299 threadId: artifacts.node.threadId,
5300 runtimeRequest: artifacts.request,
53015301 });
53025302
53035303 const resolved = yield* Deferred.await(decision).pipe(
......@@ -5348,15 +5348,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
53485348 node: artifacts.node,
53495349 });
53505350 yield* emitProviderEvent({
5351 type: "runtime_request.updated",
5351 type: "turn_item.updated",
53525352 driver: CODEX_PROVIDER,
5353 threadId: artifacts.node.threadId,
5354 runtimeRequest: artifacts.request,
5353 turnItem: artifacts.turnItem,
53555354 });
53565355 yield* emitProviderEvent({
5357 type: "turn_item.updated",
5356 type: "runtime_request.updated",
53585357 driver: CODEX_PROVIDER,
5359 turnItem: artifacts.turnItem,
5358 threadId: artifacts.node.threadId,
5359 runtimeRequest: artifacts.request,
53605360 });
53615361
53625362 const resolved = yield* Deferred.await(decision).pipe(
......@@ -5408,15 +5408,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
54085408 node: artifacts.node,
54095409 });
54105410 yield* emitProviderEvent({
5411 type: "runtime_request.updated",
5411 type: "turn_item.updated",
54125412 driver: CODEX_PROVIDER,
5413 threadId: artifacts.node.threadId,
5414 runtimeRequest: artifacts.request,
5413 turnItem: artifacts.turnItem,
54155414 });
54165415 yield* emitProviderEvent({
5417 type: "turn_item.updated",
5416 type: "runtime_request.updated",
54185417 driver: CODEX_PROVIDER,
5419 turnItem: artifacts.turnItem,
5418 threadId: artifacts.node.threadId,
5419 runtimeRequest: artifacts.request,
54205420 });
54215421
54225422 const resolved = yield* Deferred.await(decision).pipe(
......@@ -5466,15 +5466,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
54665466 node: artifacts.node,
54675467 });
54685468 yield* emitProviderEvent({
5469 type: "runtime_request.updated",
5469 type: "turn_item.updated",
54705470 driver: CODEX_PROVIDER,
5471 threadId: artifacts.node.threadId,
5472 runtimeRequest: artifacts.request,
5471 turnItem: artifacts.turnItem,
54735472 });
54745473 yield* emitProviderEvent({
5475 type: "turn_item.updated",
5474 type: "runtime_request.updated",
54765475 driver: CODEX_PROVIDER,
5477 turnItem: artifacts.turnItem,
5476 threadId: artifacts.node.threadId,
5477 runtimeRequest: artifacts.request,
54785478 });
54795479
54805480 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 = ({
4242 stopWithQueue,
4343 olderStart = false,
4444 stalledRun,
45 workflowMember = false,
4546}: {
4647 readonly failedStart?: boolean;
4748 readonly stopWithQueue?: "thread.stop" | "run.interrupt";
4849 readonly olderStart?: boolean;
50 readonly workflowMember?: boolean;
4951 readonly stalledRun?:
5052 | "missing-session"
5153 | "missing-session-terminal"
......@@ -488,23 +490,27 @@ const stopEarlierBackgroundWork = ({
488490 events: [
489491 // A subagent the reviewer started, which the provider records on the
490492 // reviewer's own thread.
491 {
492 id: EventId.make("nested-subagent-item"),
493 type: "turn-item.updated",
494 threadId: reviewerThreadId,
495 occurredAt: now,
496 payload: {
497 ...nested,
498 id: nestedItemId,
499 nodeId: nestedSubagentId,
500 providerTurnId: null,
501 nativeItemRef: null,
502 parentItemId: null,
503 ordinal: 1,
504 type: "subagent",
505 subagentId: nestedSubagentId,
506 },
507 },
493 ...(workflowMember
494 ? []
495 : [
496 {
497 id: EventId.make("nested-subagent-item"),
498 type: "turn-item.updated",
499 threadId: reviewerThreadId,
500 occurredAt: now,
501 payload: {
502 ...nested,
503 id: nestedItemId,
504 nodeId: nestedSubagentId,
505 providerTurnId: null,
506 nativeItemRef: null,
507 parentItemId: null,
508 ordinal: 1,
509 type: "subagent",
510 subagentId: nestedSubagentId,
511 },
512 } as const,
513 ]),
508514 {
509515 id: EventId.make("nested-subagent"),
510516 type: "subagent.updated",
......@@ -515,12 +521,36 @@ const stopEarlierBackgroundWork = ({
515521 id: nestedSubagentId,
516522 parentNodeId: reviewerRootId,
517523 createdBy: "agent",
518 nativeTaskRef: null,
524 nativeTaskRef: workflowMember
525 ? { driver, nativeId: "workflow:member", strength: "strong" }
526 : null,
519527 model: null,
520528 },
521529 },
522530 ],
523531 });
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 }
524554 // A settled run with its root node, attempt, provider turn and,
525555 // optionally, a command or native subagent it left running.
526556 const settledRun = (input: {
......@@ -883,10 +913,18 @@ const stopEarlierBackgroundWork = ({
883913 const reviewerThread = yield* orchestrator.getThreadProjection(reviewerThreadId);
884914 assert.equal(reviewerThread.nodes[0]?.status, "interrupted");
885915 assert.equal(reviewerThread.subagents[0]?.status, "interrupted");
886 assert.equal(
887 reviewerThread.turnItems.find((item) => item.id === nestedItemId)?.status,
888 "interrupted",
889 );
916 if (workflowMember) {
917 assert.isUndefined(reviewerThread.turnItems.find((item) => item.id === nestedItemId));
918 assert.equal(
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 }
890928 const testerThread = yield* orchestrator.getThreadProjection(tester.threadId);
891929 assert.equal(testerThread.nodes[0]?.status, "interrupted");
892930 }).pipe(
......@@ -905,6 +943,10 @@ it.effect("Stop reaches background work an earlier provider thread still runs",
905943 stopEarlierBackgroundWork({}),
906944);
907945
946it.effect("Stop settles workflow members without subagent turn items", () =>
947 stopEarlierBackgroundWork({ workflowMember: true }),
948);
949
908950it.effect(
909951 "Stop reaches earlier background work after the newest run fails before provider start",
910952 () => 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
158158 }),
159159 setModel: () => Effect.void,
160160 setPermissionMode: () => Effect.void,
161 stopTask: () => Effect.void,
161162 interrupt: Effect.die("automatic delivery must never interrupt"),
162163 close: Effect.void,
163164 }),
apps/server/src/orchestration-v2/Orchestrator.ts+18-1
......@@ -8421,7 +8421,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
84218421 OrchestrationV2ThreadProjection,
84228422 "nodes" | "subagents" | "turnItems"
84238423 >;
8424 readonly subagent: Extract<OrchestrationV2TurnItem, { readonly type: "subagent" }>;
8424 readonly subagent: Pick<
8425 Extract<OrchestrationV2TurnItem, { readonly type: "subagent" }>,
8426 "subagentId" | "origin" | "childThreadId" | "providerInstanceId"
8427 >;
84258428 }> = [{ projection: input.projection, subagent: item }];
84268429 for (const { projection, subagent } of dying) {
84278430 const ended = yield* endOrphanedNativeSubagent({
......@@ -8466,6 +8469,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
84668469 for (const nested of child.turnItems) {
84678470 if (nested.type === "subagent") dying.push({ projection: child, subagent: nested });
84688471 }
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 }
84698486 }
84708487 }
84718488 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
349349 }).pipe(Effect.provide(ProjectionStore.layerMemory)),
350350);
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
352400// Project A holds an active thread, an archived thread and a fork of a run in
353401// an archived project B thread, so a project-scoped snapshot still has to load
354402// a fork source that is outside the project and archived.
......@@ -501,6 +549,11 @@ it.effect("memory shell snapshots scope to one project", () =>
501549);
502550
503551it.layer(layerTest)("ProjectionStoreV2", (it) => {
552 it.effect(
553 "SQL recovery selects unfinished runless native subagents",
554 () => runlessNativeSubagentNeedsRecovery,
555 );
556
504557 it.effect(
505558 "keeps restart-cancelled work through a stale run.updated",
506559 () => restartCancelledWorkSurvivesStaleRunUpdate,
......@@ -2520,6 +2573,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
25202573 const now = yield* DateTime.now;
25212574 const settledThreadId = ThreadId.make("thread:recovery-candidates:settled");
25222575 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");
25232578 const rolledBackThreadId = yield* addRolledBackRecoveryCandidate("recovery-candidates");
25242579 const orphanedThreadId = yield* addOrphanedRecoveryCandidate("recovery-candidates");
25252580 const projectId = ProjectId.make("project:recovery-candidates");
......@@ -2551,7 +2606,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
25512606 deletedAt: null,
25522607 });
25532608
2554 for (const threadId of [settledThreadId, runningThreadId]) {
2609 for (const threadId of [settledThreadId, runningThreadId, memberThreadId, childThreadId]) {
25552610 yield* projectionStore.apply({
25562611 id: EventId.make(`event:recovery-candidates:${threadId}:created`),
25572612 type: "thread.created",
......@@ -2591,11 +2646,118 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
25912646 },
25922647 });
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
25942724 const recoveryThreadIds = yield* projectionStore.getRecoveryThreadIds("runtime");
25952725 assert.include(recoveryThreadIds, runningThreadId);
25962726 assert.include(recoveryThreadIds, orphanedThreadId);
2727 assert.include(recoveryThreadIds, memberThreadId);
2728 assert.include(recoveryThreadIds, childThreadId);
25972729 assert.notInclude(recoveryThreadIds, settledThreadId);
25982730 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);
25992761 }),
26002762 );
26012763
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts+84
......@@ -1589,6 +1589,90 @@ layer("ProviderEventIngestorV2", (it) => {
15891589 }),
15901590 );
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
15921676 it.effect("moves a native subagent's thread to the model its provider reports later", () =>
15931677 Effect.gen(function* () {
15941678 const now = yield* DateTime.now;
apps/server/src/orchestration-v2/ProviderEventIngestor.ts+31-4
......@@ -535,14 +535,37 @@ export const layer: Layer.Layer<
535535 const normalize: ProviderEventIngestorV2Shape["normalize"] = (input) =>
536536 Effect.gen(function* () {
537537 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;
539552 return [
540553 yield* makeDomainEvent(input, {
541 type: "thread.created",
542 threadId: input.event.appThread.id,
543 payload: input.event.appThread,
554 type: existing === null ? "thread.created" : "thread.metadata-updated",
555 threadId: offered.id,
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,
544566 }),
545567 ];
568 }
546569 case "provider_session.updated":
547570 return [
548571 yield* makeDomainEvent(input, {
......@@ -795,6 +818,10 @@ export const layer: Layer.Layer<
795818 );
796819 }),
797820 ),
821 (effect) =>
822 input.event.type === "app_thread.created"
823 ? threadCommands.withLock(input.event.appThread.id, effect)
824 : effect,
798825 ),
799826 });
800827 }),
apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts+91
......@@ -854,6 +854,97 @@ it.effect(
854854 },
855855);
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
857948it.effect(
858949 "clears persisted pendingBackgroundTasks and terminalizes stale background items on settled runs",
859950 () => {
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", () =>
165165 }),
166166);
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", () => {
169169 const threadId = ThreadId.make("thread:late-child");
170170 const rootProviderTurnId = ProviderTurnId.make("provider-turn:late-child");
171171 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
174174 attemptId: RunAttemptId.make("attempt:late-child"),
175175 providerThreadId: ProviderThreadId.make("provider-thread:late-child"),
176176 };
177 const childCreated = (childThreadId: ThreadId): ProviderAdapter.ProviderAdapterV2Event =>
177 const childCreated = (
178 childThreadId: ThreadId,
179 parentThreadId = threadId,
180 ): ProviderAdapter.ProviderAdapterV2Event =>
178181 ({
179182 type: "app_thread.created",
180183 driver,
181184 appThread: {
182185 id: childThreadId,
183186 lineage: {
184 parentThreadId: threadId,
187 parentThreadId,
185188 relationshipToParent: "subagent",
186189 rootThreadId: threadId,
187190 },
......@@ -200,6 +203,13 @@ it("leaves a child thread created after the root turn ended to the run that is l
200203 initial,
201204 );
202205 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);
203213 const [terminalAccepted, ended] = RunExecutionService.routeProviderEvent(
204214 {
205215 type: "turn.terminal",
......@@ -212,7 +222,7 @@ it("leaves a child thread created after the root turn ended to the run that is l
212222 threadDisposition: "reusable",
213223 },
214224 identity,
215 live,
225 withDescendant,
216226 );
217227 assert.isTrue(terminalAccepted);
218228 // 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
224234 );
225235 assert.isFalse(lateAccepted);
226236 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 );
227323});
228324
229325it("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
12251321 }),
12261322);
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", () =>
12291325 Effect.gen(function* () {
12301326 const threadId = ThreadId.make("thread:run-execution-late-child");
12311327 const childThreadId = ThreadId.make("thread:run-execution-late-child:child");
1328 const memberThreadId = ThreadId.make("thread:run-execution-late-child:member");
12321329 const runId = RunId.make("run:run-execution-late-child");
12331330 const attemptId = RunAttemptId.make("attempt:run-execution-late-child");
12341331 const providerInstanceId = ProviderInstanceId.make("codex");
......@@ -1268,9 +1365,15 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes",
12681365 Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({
12691366 ingestNormalized: (input) =>
12701367 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 }
12711374 if (
12721375 input.event.type === "message.updated" &&
1273 input.event.message.threadId === childThreadId
1376 input.event.message.threadId === memberThreadId
12741377 ) {
12751378 yield* Ref.update(order, (current) => [...current, "child-message"]);
12761379 yield* Deferred.succeed(childMessageIngested, undefined).pipe(Effect.ignore);
......@@ -1307,12 +1410,21 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes",
13071410 type: "subagent.updated",
13081411 driver,
13091412 subagent: {
1310 id: subagentNodeId,
1311 threadId,
1312 runId,
1313 status: "running",
1413 ...makeRunOwnedSubagentFixture({
1414 ids: backgroundScenarioIds("run-execution-late-child"),
1415 providerInstanceId,
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 },
13141426 },
1315 } as ProviderAdapter.ProviderAdapterV2Event,
1427 },
13161428 {
13171429 type: "provider_turn.updated",
13181430 driver,
......@@ -1335,12 +1447,24 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes",
13351447 failure: null,
13361448 threadDisposition: "reusable",
13371449 },
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,
13381462 {
13391463 type: "message.updated",
13401464 driver,
13411465 message: {
13421466 id: MessageId.make("message:run-execution-late-child:child"),
1343 threadId: childThreadId,
1467 threadId: memberThreadId,
13441468 runId: null,
13451469 nodeId: childNodeId,
13461470 role: "assistant",
......@@ -1428,7 +1552,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes",
14281552 Effect.timeoutOption("2 seconds"),
14291553 );
14301554 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"]);
14321556 }),
14331557);
14341558
......@@ -2614,11 +2738,42 @@ it.effect(
26142738 ids,
26152739 status: "running",
26162740 });
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 };
26172767 const runningChildTurnItem = makeLinkedChildTurnItemFixture({
26182768 ids,
26192769 driver,
26202770 type: "command_execution",
26212771 });
2772 const foreignChildTurnItem = {
2773 ...runningChildTurnItem,
2774 id: TurnItemId.make("turn-item:subagent-interrupt-cascade:foreign-child"),
2775 runId: foreignSubagent.runId,
2776 };
26222777 const suppressedChildAssistantTurnItem = makeLinkedChildTurnItemFixture({
26232778 ids: {
26242779 ...ids,
......@@ -2692,6 +2847,11 @@ it.effect(
26922847 driver,
26932848 turnItem: unrelatedChildTurnItem,
26942849 },
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 },
26952855 rootTerminalEvent(ids, "interrupted"),
26962856 // Late provider completion after interrupt must not be ingested.
26972857 {
......@@ -2758,7 +2918,7 @@ it.effect(
27582918 providerTurnId: ids.rootProviderTurnId,
27592919 } as OrchestrationV2RunAttempt,
27602920 attemptId: ids.attemptId,
2761 relatedThreadIds: [unrelatedChildThreadId],
2921 relatedThreadIds: [unrelatedChildThreadId, memberThreadId],
27622922 providerTurnOrdinal: 1,
27632923 message: {
27642924 messageId: MessageId.make("message:subagent-interrupt-cascade:user"),
......@@ -2810,8 +2970,39 @@ it.effect(
28102970 const runUpdatedIndex = events.findIndex((event) => event.type === "run.updated");
28112971 assert.isAtLeast(runUpdatedIndex, 0, "root run.updated must be written");
28122972
2813 assert.lengthOf(subagentEvents, 1);
2814 const terminalSubagent = subagentEvents[0];
2973 assert.lengthOf(subagentEvents, 2);
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 );
28153006 assert.isDefined(terminalSubagent);
28163007 assert.equal(terminalSubagent.payload.status, "interrupted");
28173008 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";
5353export interface ProviderEventRoutingState {
5454 readonly ownedThreadIds: ReadonlySet<ThreadId>;
5555 // 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.
5759 readonly rootTurnEnded: boolean;
60 /** Member thread id to the coordinator child thread that spawned it. */
61 readonly workflowMemberParents: ReadonlyMap<ThreadId, ThreadId>;
5862 readonly ownedProviderThreadIds: ReadonlySet<ProviderThreadId>;
5963 readonly ownedProviderTurnIds: ReadonlySet<ProviderTurnId>;
6064 readonly inheritedBackgroundTurnItems: ReadonlyMap<TurnItemId, OrchestrationV2Run["id"]>;
......@@ -334,6 +338,7 @@ export function makeProviderEventRoutingState(input: {
334338 return {
335339 ownedThreadIds: new Set([input.identity.threadId, ...(input.relatedThreadIds ?? [])]),
336340 rootTurnEnded: false,
341 workflowMemberParents: new Map(),
337342 ownedProviderThreadIds: new Set([
338343 input.identity.providerThreadId,
339344 ...(input.relatedProviderThreadIds ?? []),
......@@ -379,10 +384,12 @@ export function routeProviderEvent(
379384 return [true, state];
380385 }
381386 const isOwnedSubagent =
382 !state.rootTurnEnded &&
383387 event.appThread.lineage.relationshipToParent === "subagent" &&
384388 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);
386393 if (!isOwnedSubagent) {
387394 return [false, state];
388395 }
......@@ -417,8 +424,24 @@ export function routeProviderEvent(
417424 }
418425 return [true, addProviderThread(event.node.providerThreadId)];
419426 }
420 case "subagent.updated":
421 return [ownsRun(event.subagent.runId) || ownsChildThread(event.subagent.threadId), state];
427 case "subagent.updated": {
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 }
422445 case "message.updated":
423446 return [ownsRun(event.message.runId) || ownsChildThread(event.message.threadId), state];
424447 case "turn_item.updated": {
......@@ -1070,7 +1093,13 @@ export const layer: Layer.Layer<
10701093 const belongsToOwnedChildThread =
10711094 event.node.threadId !== input.run.threadId &&
10721095 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 ) {
10741103 return;
10751104 }
10761105 yield* Ref.update(openRunOwnedSubagents, (current) => {
......@@ -1108,7 +1137,11 @@ export const layer: Layer.Layer<
11081137 return next;
11091138 });
11101139 }
1111 if (belongsToOwnedChildThread && deliverable) {
1140 if (
1141 belongsToOwnedChildThread &&
1142 (event.turnItem.runId === null || belongsToRootRun) &&
1143 deliverable
1144 ) {
11121145 yield* Ref.update(openRunOwnedSubagents, (current) => {
11131146 const childTurnItems = new Map(current.childTurnItems);
11141147 if (isSettledTurnItemStatus(event.turnItem.status)) {
apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts+43-28
......@@ -388,7 +388,7 @@ function makeCompletingHandoffAdapter(
388388 };
389389}
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", () =>
392392 Effect.scoped(
393393 Effect.gen(function* () {
394394 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
404404
405405 const result = yield* Effect.gen(function* () {
406406 const orchestrator = yield* Orchestrator.OrchestratorV2;
407 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
408 const eventSink = yield* EventSink.EventSinkV2;
409
407410 yield* orchestrator.dispatch({
408411 type: "thread.create",
409412 createdBy: "user",
......@@ -418,6 +421,21 @@ it.live("restarts selection as a new attempt and retries after old-session clean
418421 branch: null,
419422 worktreePath: cwd,
420423 });
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 );
421439 yield* orchestrator.dispatch({
422440 type: "message.dispatch",
423441 createdBy: "user",
......@@ -430,11 +448,8 @@ it.live("restarts selection as a new attempt and retries after old-session clean
430448 modelSelection: initialSelection,
431449 dispatchMode: { type: "start_immediately" },
432450 });
433 for (let index = 0; index < 1_000; index += 1) {
434 const current = yield* orchestrator.getThreadProjection(threadId);
435 if (current.providerTurns.some((turn) => turn.status === "running")) break;
436 yield* Effect.sleep("5 millis");
437 }
451 yield* Fiber.join(running);
452 yield* worker.drain();
438453 const activeProjection = yield* orchestrator.getThreadProjection(threadId);
439454 assert.isTrue(activeProjection.providerTurns.some((turn) => turn.status === "running"));
440455 const activeRunId = activeProjection.runs[0]?.id;
......@@ -442,6 +457,20 @@ it.live("restarts selection as a new attempt and retries after old-session clean
442457 return yield* Effect.die("active restart test run is missing");
443458 }
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 );
445474 yield* orchestrator.dispatch({
446475 type: "message.dispatch",
447476 createdBy: "user",
......@@ -454,27 +483,12 @@ it.live("restarts selection as a new attempt and retries after old-session clean
454483 modelSelection: replacementSelection,
455484 dispatchMode: { type: "restart_active", targetRunId: activeRunId },
456485 });
457 for (let index = 0; index < 1_000; index += 1) {
458 const current = yield* orchestrator.getThreadProjection(threadId);
459 if (current.attempts.length === 2 && current.attempts[1]?.status === "completed") {
460 const captured = yield* Ref.get(state);
461 return { projection: current, captured };
462 }
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");
486 yield* Fiber.join(completed);
487 yield* worker.drain();
488 return {
489 projection: yield* orchestrator.getThreadProjection(threadId),
490 captured: yield* Ref.get(state),
491 };
478492 }).pipe(
479493 Effect.provide(
480494 ProviderReplayHarness.layerWithRegistry(
......@@ -501,7 +515,8 @@ it.live("restarts selection as a new attempt and retries after old-session clean
501515 ),
502516 "selection restart supersede must not project hard-Stop interrupt items",
503517 );
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");
505520 assert.isTrue(captured.failedReplacementOpen);
506521 // The old pooled process remains available to its other threads; this
507522 // thread moved to a freshly allocated replacement session.
apps/server/src/orchestration-v2/ThreadManagementService.test.ts+235
......@@ -5,25 +5,38 @@ import {
55 NodeId,
66 type OrchestrationV2Command,
77 type OrchestrationV2Run,
8 type OrchestrationV2Subagent,
9 type OrchestrationV2ProviderThread,
10 ProviderThreadId,
11 ProviderDriverKind,
12 ProviderSessionId,
813 type OrchestrationV2StoredEvent,
914 type OrchestrationV2ThreadProjection,
15 type OrchestrationV2ThreadShell,
1016 ProjectId,
1117 ProviderInstanceId,
1218 RunId,
1319 ThreadId,
1420} from "@t3tools/contracts";
1521import * as Deferred from "effect/Deferred";
22import * as DateTime from "effect/DateTime";
23import * as Option from "effect/Option";
1624import * as Duration from "effect/Duration";
1725import * as Effect from "effect/Effect";
1826import * as Fiber from "effect/Fiber";
1927import * as Layer from "effect/Layer";
2028import * as Queue from "effect/Queue";
29import * as Ref from "effect/Ref";
2130import * as Stream from "effect/Stream";
2231import * as TestClock from "effect/testing/TestClock";
2332
2433import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";
2534import * as Orchestrator from "./Orchestrator.ts";
35import * as ProviderSessionManager from "./ProviderSessionManager.ts";
36import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
2637import * as ThreadManagementService from "./ThreadManagementService.ts";
38import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
39import { DispatchModeLimit, type DispatchModeRefusal } from "./DispatchModeLimit.ts";
2740
2841it("stamps authoritative provenance on commands that create threads or messages", () => {
2942 const command: OrchestrationV2Command = {
......@@ -269,6 +282,7 @@ it.effect("classifies projection infrastructure failures separately from a missi
269282 cause: infrastructureCause,
270283 });
271284 const layerTest = ThreadManagementService.layer.pipe(
285 Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
272286 Layer.provide(
273287 Layer.mock(Orchestrator.OrchestratorV2)({
274288 getThreadProjection: () => Effect.fail(projectionError),
......@@ -302,6 +316,7 @@ it.effect("uses thread-not-found only after a projection loads outside the proje
302316 },
303317 } as OrchestrationV2ThreadProjection;
304318 const layerTest = ThreadManagementService.layer.pipe(
319 Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
305320 Layer.provide(
306321 Layer.mock(Orchestrator.OrchestratorV2)({
307322 getThreadProjection: () => Effect.succeed(projection),
......@@ -327,6 +342,7 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte
327342 cause: new Error("checkpoint import failed"),
328343 });
329344 const layerTest = ThreadManagementService.layerWithLegacyImporter.pipe(
345 Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
330346 Layer.provide(
331347 Layer.mergeAll(
332348 Layer.mock(Orchestrator.OrchestratorV2)({
......@@ -368,6 +384,7 @@ it.effect.each([
368384 runs: status === "missing" ? [] : [{ id: runId, status }],
369385 }) as unknown as OrchestrationV2ThreadProjection;
370386 const layerTest = ThreadManagementService.layer.pipe(
387 Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
371388 Layer.provide(
372389 Layer.mock(Orchestrator.OrchestratorV2)({
373390 getThreadEventSequence: () => Effect.succeed(0),
......@@ -434,6 +451,7 @@ it.effect("waitForThread reads the run again only when the run updates", () =>
434451 const stored = (sequence: number, event: object) =>
435452 ({ sequence, event: { threadId, ...event } }) as unknown as OrchestrationV2StoredEvent;
436453 const layerTest = ThreadManagementService.layer.pipe(
454 Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
437455 Layer.provide(
438456 Layer.mock(Orchestrator.OrchestratorV2)({
439457 getThreadEventSequence: () => Effect.succeed(0),
......@@ -474,6 +492,222 @@ it.effect("waitForThread reads the run again only when the run updates", () =>
474492 }),
475493);
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
477711it.effect.each([
478712 { status: "completed" as const, settles: true },
479713 { status: "failed" as const, settles: false },
......@@ -485,6 +719,7 @@ it.effect.each([
485719 const runId = RunId.make("run:thread-management:settle-after-run");
486720 const dispatched: Array<string> = [];
487721 const layerTest = ThreadManagementService.layer.pipe(
722 Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
488723 Layer.provide(
489724 Layer.mock(Orchestrator.OrchestratorV2)({
490725 getThreadEventSequence: () => Effect.succeed(0),
apps/server/src/orchestration-v2/ThreadManagementService.ts+112-4
......@@ -11,6 +11,8 @@ import {
1111 type ChatAttachment,
1212 CommandId,
1313 MessageId,
14 OrchestrationV2StopWorkflowError,
15 type OrchestrationV2StopWorkflowInput,
1416 type ModelSelection,
1517 type OrchestrationV2Actor,
1618 type OrchestrationV2Command,
......@@ -35,10 +37,14 @@ import * as Duration from "effect/Duration";
3537import * as Effect from "effect/Effect";
3638import * as Layer from "effect/Layer";
3739import * as Option from "effect/Option";
40import * as Ref from "effect/Ref";
3841import * as Schema from "effect/Schema";
3942import * as Stream from "effect/Stream";
4043
4144import * as Orchestrator from "./Orchestrator.ts";
45import * as ProviderSessionManager from "./ProviderSessionManager.ts";
46import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
47import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts";
4248import { projectTurnItemForDetail } from "./WireProjection.ts";
4349import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";
4450
......@@ -364,6 +370,13 @@ export interface ThreadManagementServiceShape {
364370 readonly interruptThread: (
365371 input: ThreadManagementInterruptInput,
366372 ) => 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>;
367380 /**
368381 * Sends `thread.stop` to every delegated task under a thread, depth first. Their command IDs
369382 * derive from `commandId`, so a retry repeats nothing that already stopped. A task that
......@@ -440,7 +453,9 @@ function latestSteerableRun(
440453const SETTLE_AFTER_RUN_WAIT_MS = 24 * 60 * 60 * 1_000;
441454
442455const make = Effect.gen(function* () {
456 const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor;
443457 const orchestrator = yield* Orchestrator.OrchestratorV2;
458 const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2;
444459 const layerScope = yield* Effect.scope;
445460 const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter;
446461
......@@ -841,6 +856,90 @@ const make = Effect.gen(function* () {
841856 return { type: "interrupt_requested", run: interruptibleRun, dispatch } as const;
842857 });
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
844943 const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = (input) =>
845944 Effect.gen(function* () {
846945 const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]);
......@@ -919,6 +1018,7 @@ const make = Effect.gen(function* () {
9191018 settleThread,
9201019 interruptThread,
9211020 stopDelegatedTasks,
1021 stopWorkflow,
9221022 getThreadEventSequence: orchestrator.getThreadEventSequence,
9231023 recoverDelegatedTask: orchestrator.recoverDelegatedTask,
9241024 delegatedTaskResultPending: orchestrator.delegatedTaskResultPending,
......@@ -938,11 +1038,19 @@ const layerLegacyV1ThreadImporterNoop = Layer.succeed(
9381038 }),
9391039);
9401040
941export const layer: Layer.Layer<ThreadManagementService, never, Orchestrator.OrchestratorV2> =
942 Layer.effect(ThreadManagementService, make).pipe(Layer.provide(layerLegacyV1ThreadImporterNoop));
1041export const layer: Layer.Layer<
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
9441050export const layerWithLegacyImporter: Layer.Layer<
9451051 ThreadManagementService,
9461052 never,
947 LegacyV1ThreadImporter.LegacyV1ThreadImporter | Orchestrator.OrchestratorV2
948> = Layer.effect(ThreadManagementService, make);
1053 | LegacyV1ThreadImporter.LegacyV1ThreadImporter
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(
240240);
241241
242242const 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 ),
244250);
245251export const layerProjectSetupScriptRunner = ProjectSetupScriptRunner.layer.pipe(
246252 Layer.provide(layerProjectService),
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+4-1
......@@ -242,7 +242,10 @@ export function layerProviderReplay<Transcript extends ProviderReplayTranscript,
242242 readonly replayGate?: ProviderReplayGate;
243243 } = {},
244244): Layer.Layer<
245 Orchestrator.OrchestratorV2 | EffectWorker.OrchestrationEffectWorkerV2 | EventSink.EventSinkV2,
245 | Orchestrator.OrchestratorV2
246 | EffectWorker.OrchestrationEffectWorkerV2
247 | EventSink.EventSinkV2
248 | ProviderSessionManager.ProviderSessionManagerV2,
246249 Error | MigrationError | PlatformError.PlatformError | SqlError
247250> {
248251 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(
5959 const approval = approvalItems[0]!;
6060 assert.equal(approval.runId, run.id);
6161 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);
6276 if (approval.type !== "approval_request") throw new Error("expected an approval item");
6377 assert.equal(approval.requestId, request.id);
6478 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(
7272 const approval = approvalItems[0]!;
7373 assert.equal(approval.runId, run.id);
7474 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);
7589 if (approval.type !== "approval_request") throw new Error("expected an approval item");
7690 assert.equal(approval.requestId, request.id);
7791 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 @@
22import * as NodeFS from "node:fs";
33import * as NodeOS from "node:os";
44import * as NodePath from "node:path";
5import * as NodeChildProcess from "node:child_process";
6import { OrchestrationWorkflowFileError } from "@t3tools/contracts";
7import * as HostProcess from "@t3tools/shared/HostProcess";
58import { it as effectIt } from "@effect/vitest";
69import * as Effect from "effect/Effect";
710import { afterAll, assert, describe } from "vite-plus/test";
......@@ -30,7 +33,18 @@ afterAll(() => {
3033 NodeFS.rmSync(outside, { force: true });
3134});
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
3448 effectIt.effect("serves a real script under the projects root", () =>
3549 Effect.gen(function* () {
3650 const result = yield* readWorkflowScript({ scriptPath });
......@@ -47,6 +61,12 @@ describe("readWorkflowScript containment", () => {
4761 readWorkflowScript({ scriptPath: scriptPath.replace(".js", ".ts") }),
4862 );
4963 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);
5070 }),
5171 );
5272
apps/server/src/orchestration-v2/workflowScriptQuery.ts+16-121
......@@ -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
191import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts";
202import * as Effect from "effect/Effect";
21import * as HostProcess from "@t3tools/shared/HostProcess";
22
23const SCRIPT_BYTE_CAP = 256 * 1024;
24
25function scriptsRoot(home: string): string {
26 return NodePath.join(home, ".claude", "projects");
27}
3import { readContainedWorkflowFile } from "./workflowFileRead.ts";
284
295export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: {
306 readonly scriptPath: string;
317}) {
32 const requested = input.scriptPath;
33
34 if (!NodePath.isAbsolute(requested) || NodePath.extname(requested) !== ".js") {
35 return yield* new OrchestrationGetWorkflowScriptError({
36 reason: "invalid-path",
37 scriptPath: requested,
38 });
39 }
40
41 const home = yield* HostProcess.HomeDirectory;
42 const root = yield* Effect.tryPromise({
43 try: () => NodeFSP.realpath(scriptsRoot(home)),
44 catch: (cause) =>
45 new OrchestrationGetWorkflowScriptError({
46 reason: "root-unavailable",
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 };
8 const file = yield* readContainedWorkflowFile({
9 path: input.scriptPath,
10 extension: ".js",
11 byteCap: 256 * 1024,
12 }).pipe(
13 Effect.mapError(
14 (error) =>
15 new OrchestrationGetWorkflowScriptError({
16 reason: error.reason === "wrong-extension" ? "not-js" : error.reason,
17 scriptPath: error.path,
18 cause: error,
19 }),
20 ),
21 );
22 return { scriptPath: file.path, contents: file.contents, truncated: file.truncated };
12823});
apps/server/src/relay/AgentAwarenessRelay.test.ts+1
......@@ -212,6 +212,7 @@ const makeTestRelay = Effect.fnUntraced(function* (
212212 settleAfterRun: unused,
213213 settleThread: unused,
214214 interruptThread: unused,
215 stopWorkflow: () => Effect.die("unused stopWorkflow"),
215216 stopDelegatedTasks: unused,
216217 getThreadEventSequence: unused,
217218 recoverDelegatedTask: unused,
apps/server/src/ws.ts+1
......@@ -1864,6 +1864,7 @@ const layerWsRpc = (
18641864 ),
18651865 ),
18661866 ),
1867 [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: (input) => threadManagement.stopWorkflow(input),
18671868 [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) =>
18681869 readWorkflowScript({ scriptPath: input.scriptPath }),
18691870 [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(() => ({
1818 configs: new Map<string, unknown>(),
1919 showTooltips: false,
2020 command: vi.fn().mockResolvedValue({ _tag: "Success" }),
21 workflowCommand: vi.fn().mockResolvedValue({ _tag: "Success" }),
22 toast: vi.fn(),
2123}));
2224
2325vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate }));
......@@ -30,7 +32,25 @@ vi.mock("../../state/entities", () => ({
3032vi.mock("../../lib/archivedThreadsState", () => ({
3133 useArchivedThreadSnapshots: () => ({ snapshots: [] }),
3234}));
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});
3454vi.mock("../ui/tooltip", () => ({
3555 Tooltip: ({ children }: { children: ReactNode }) => children,
3656 TooltipTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) =>
......@@ -49,17 +69,423 @@ afterEach(async () => {
4969 state.projects = [];
5070 state.configs.clear();
5171 state.showTooltips = false;
72 state.navigate.mockClear();
5273 state.command.mockClear();
74 state.workflowCommand.mockReset().mockResolvedValue({ _tag: "Success" });
75 state.toast.mockClear();
5376 state.projection = null;
5477});
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
56481it.each([
57 { driver: "codex", origin: "app_owned" },
58 { driver: "claudeAgent", origin: "app_owned" },
59 { driver: "claudeAgent", origin: "provider_native" },
482 { driver: "codex", origin: "app_owned", runId: null, canStop: true },
483 { driver: "claudeAgent", origin: "app_owned", runId: null, canStop: true },
484 { driver: "claudeAgent", origin: "provider_native", runId: "run-1", canStop: true },
485 { driver: "claudeAgent", origin: "provider_native", runId: null, canStop: false },
60486])(
61 "stops active $origin $driver subagents without opening their thread",
62 async ({ driver, origin }) => {
487 "offers individual Stop for supported $origin $driver subagents with run $runId",
488 async ({ driver, origin, runId, canStop }) => {
63489 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
64490 const parent = {
65491 id: "parent",
......@@ -76,6 +502,7 @@ it.each([
76502 childThreadId: "child",
77503 origin,
78504 threadId: "parent",
505 runId,
79506 nativeTaskRef: { driver, nativeId: "claude-task", strength: "strong" },
80507 driver,
81508 providerInstanceId: "codex",
......@@ -108,6 +535,12 @@ it.each([
108535 await act(async () => {
109536 renderer = create(panel);
110537 });
538 if (!canStop) {
539 expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(
540 0,
541 );
542 return;
543 }
111544 const stopButton = () => renderer.root.findByProps({ "aria-label": "Stop subagent Worker" });
112545 await act(async () => stopButton().props.onClick());
113546 expect(state.command).toHaveBeenCalledWith({
......@@ -397,11 +830,16 @@ it("shows readable models and only differing workspace details in agent tooltips
397830 };
398831 return read(renderer.root);
399832 };
400 expect(text()).toContain("My GPT · high");
401 state.projection = {
833 const rerender = async (source = child) => {
834 state.shells = [{ environmentId: "test", source: { ...source } }];
835 await act(async () => renderer.update(cloneElement(panel)));
836 };
837 const nativeProjection = {
402838 ...projection,
403839 subagents: [{ ...projection.subagents[0], origin: "provider_native" }],
404840 };
841 expect(text()).toContain("My GPT · high");
842 state.projection = nativeProjection;
405843 await act(async () => renderer.update(cloneElement(panel)));
406844 expect(text()).toContain("My GPT");
407845 expect(text()).not.toContain("My GPT · high");
......@@ -467,8 +905,7 @@ it("shows readable models and only differing workspace details in agent tooltips
467905 [undefined, ""],
468906 ] as const) {
469907 child.modelSelection.options = options;
470 state.shells = [{ environmentId: "test", source: { ...child } }];
471 await act(async () => renderer.update(cloneElement(panel)));
908 await rerender();
472909 expect(text()).toContain(`My GPT${expected}`);
473910 if (!expected) expect(text()).not.toContain("My GPT ·");
474911 }
......@@ -532,8 +969,7 @@ it("shows readable models and only differing workspace details in agent tooltips
532969 { id: "reasoningEffort", value: "high" },
533970 ...(value === undefined ? [] : [{ id: descriptor.id, value }]),
534971 ];
535 state.shells = [{ environmentId: "test", source: { ...child } }];
536 await act(async () => renderer.update(cloneElement(panel)));
972 await rerender();
537973 expect(text(true)).toContain("My GPT · high");
538974 expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/);
539975 expect(text()).toContain(`My GPT · ${iconLabel}high`);
......@@ -541,27 +977,11 @@ it("shows readable models and only differing workspace details in agent tooltips
541977 child.modelSelection.options = child.modelSelection.options.filter(
542978 ({ id }) => id !== "reasoningEffort",
543979 );
544 state.shells = [{ environmentId: "test", source: { ...child } }];
545 await act(async () => renderer.update(cloneElement(panel)));
980 await rerender();
546981 expect(text()).not.toContain(" · high");
547982 expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/);
548983 if (iconLabel) expect(text()).toContain(iconLabel);
549984 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 }
565985 }
566986 state.configs.set("test", {
567987 providers: [
......@@ -589,11 +1009,21 @@ it("shows readable models and only differing workspace details in agent tooltips
5891009 { id: "reasoningEffort", value: "high" },
5901010 { id: "serviceTier", value: "priority" },
5911011 ];
592 state.shells = [{ environmentId: "test", source: { ...child } }];
593 await act(async () => renderer.update(cloneElement(panel)));
1012 await rerender();
5941013 expect(text(true)).toContain("My GPT · Work account · high");
5951014 expect(text()).toContain("My GPT · Work account · Fast mode onhigh");
5961015 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 }
5971027 state.shells = [];
5981028 for (const status of ["running", "completed", "failed"] as const) {
5991029 state.projection = {
......@@ -629,21 +1059,14 @@ it("shows readable models and only differing workspace details in agent tooltips
6291059 state.projection = projection;
6301060 state.configs.set("test", speedConfig);
6311061 child.modelSelection.options = [{ id: "reasoningEffort", value: "high" }];
632 state.shells = [
633 {
634 environmentId: "test",
635 source: { ...child, modelSelection: { ...child.modelSelection, instanceId: "other" } },
636 },
637 ];
638 await act(async () => renderer.update(cloneElement(panel)));
1062 await rerender({ ...child, modelSelection: { ...child.modelSelection, instanceId: "other" } });
6391063 expect(text()).not.toContain("My GPT ·");
6401064 const config = state.configs.get("test");
6411065 state.configs.clear();
6421066 await act(async () => renderer.update(cloneElement(panel)));
6431067 expect(text()).toContain("GPT-5.4");
6441068 expect(text()).not.toContain("GPT-5.4 ·");
645 state.shells = [{ environmentId: "test", source: { ...child } }];
646 await act(async () => renderer.update(cloneElement(panel)));
1069 await rerender();
6471070 expect(text()).toContain("GPT-5.4 · high");
6481071 state.configs.set("test", config);
6491072 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
6511074 ...projection,
6521075 subagents: [{ ...projection.subagents[0], model }],
6531076 };
654 state.shells = [
655 {
656 environmentId: "test",
657 source: { ...child, modelSelection: { ...child.modelSelection, model: "custom/model-v1" } },
658 },
659 ];
660 await act(async () => renderer.update(cloneElement(panel)));
1077 await rerender({
1078 ...child,
1079 modelSelection: { ...child.modelSelection, model: "custom/model-v1" },
1080 });
6611081 expect(text().includes(`${model} · high`)).toBe(model === "custom/model-v1");
6621082 }
6631083
......@@ -692,16 +1112,14 @@ it("shows readable models and only differing workspace details in agent tooltips
6921112 state.projection = projection;
6931113
6941114 child.worktreePath = "/main/worktrees/checker";
695 state.shells = [{ environmentId: "test", source: { ...child } }];
696 await act(async () => renderer.update(cloneElement(panel)));
1115 await rerender();
6971116 expect(text()).toContain("Worktree");
6981117 expect(text()).toContain("checker");
6991118 expect(text()).not.toContain("/main/worktrees");
7001119 expect(text()).not.toContain("Project");
7011120
7021121 child.branch = "fix/checker";
703 state.shells = [{ environmentId: "test", source: { ...child } }];
704 await act(async () => renderer.update(cloneElement(panel)));
1122 await rerender();
7051123 expect(text()).toContain("Branch");
7061124 expect(text()).toContain("fix/checker");
7071125 expect(text()).not.toContain("Worktree");
......@@ -710,8 +1128,7 @@ it("shows readable models and only differing workspace details in agent tooltips
7101128 child.projectId = "other";
7111129 child.worktreePath = null;
7121130 child.branch = null;
713 state.shells = [{ environmentId: "test", source: { ...child } }];
714 await act(async () => renderer.update(cloneElement(panel)));
1131 await rerender();
7151132 expect(text()).toContain("Other project");
7161133 expect(text()).toContain("Workspace");
7171134 expect(text()).toContain("other");
apps/web/src/components/chat/ThreadRelationshipsControl.tsx+160-70
......@@ -1,3 +1,4 @@
1import { useAtomValue } from "@effect/atom-react";
12import { ThreadDetailsControl } from "./ThreadDetailsControl";
23import { ThreadHoverCardPopup } from "../ThreadHoverCard";
34import { ThreadDetailsSection } from "./ThreadDetailsSection";
......@@ -6,6 +7,7 @@ import { SubagentTooltipContent } from "./SubagentTooltipContent";
67import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
78import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
89import {
10 deriveWorkflowGroups,
911 projectedSubagentsToRuntime,
1012 type RuntimeSubagent,
1113} from "@t3tools/client-runtime/state/subagentRuntime";
......@@ -24,11 +26,12 @@ import {
2426 canDetachThreadProviderSession,
2527 resolveLatestMergeBackRun,
2628} from "@t3tools/client-runtime/state/thread-workflows";
27import type {
28 EnvironmentId,
29 OrchestrationV2Subagent,
30 OrchestrationV2ThreadShell,
31 ThreadId,
29import {
30 NodeId,
31 type EnvironmentId,
32 type OrchestrationV2Subagent,
33 type OrchestrationV2ThreadShell,
34 type ThreadId,
3235} from "@t3tools/contracts";
3336import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming";
3437import { groupBy } from "effect/Array";
......@@ -56,9 +59,11 @@ import {
5659 useThreadShells,
5760} from "../../state/entities";
5861import { threadEnvironment } from "../../state/threads";
62import { orchestrationEnvironment } from "../../state/orchestration";
5963import { useAtomCommand } from "../../state/use-atom-command";
6064import { AgentElapsed } from "./AgentElapsed";
6165import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadRelationshipIcon";
66import { ThreadLineageWorkflowRow } from "./ThreadLineageWorkflowRow";
6267
6368import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
6469import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
......@@ -97,11 +102,13 @@ export function ThreadLineageRowList(props: {
97102 component: this sits inside an already scrolling panel, where a
98103 max-height-only virtual viewport measures badly. Every row is a focusable
99104 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.
101108 */}
102109 <ul
103110 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)]"
105112 >
106113 {props.children}
107114 </ul>
......@@ -136,6 +143,7 @@ function ThreadLineageGroup(props: {
136143 <div>
137144 {props.label ? (
138145 <CollapsibleSectionHeader
146 variant="panel"
139147 expanded={expanded}
140148 onClick={() => setExpanded(!expanded)}
141149 accessory={
......@@ -217,28 +225,39 @@ export function ThreadRelationshipsPanel(props: {
217225 const ref = scopeThreadRef(props.environmentId, props.threadId);
218226 const projection = useThreadProjection(ref)?.projection ?? null;
219227 const providers = useServerConfigs().get(props.environmentId)?.providers;
220 const subagentsByThreadId = useMemo(
221 () =>
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 ),
228 const runtimeSubagents = useMemo(
229 () => projectedSubagentsToRuntime(projection?.subagents ?? []),
240230 [projection?.subagents],
241231 );
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 );
242261 const threadShells = useThreadShells();
243262 const projects = useProjects().filter((project) => project.environmentId === props.environmentId);
244263 const archived = useArchivedThreadSnapshots([props.environmentId]);
......@@ -260,8 +279,12 @@ export function ThreadRelationshipsPanel(props: {
260279 const mergeBack = useAtomCommand(threadEnvironment.mergeBack);
261280 const stopSession = useAtomCommand(threadEnvironment.stopSession);
262281 const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn);
282 const stopWorkflowCommand = useAtomCommand(orchestrationEnvironment.stopWorkflow);
283 const canOperateWorkflow = useAtomValue(
284 orchestrationEnvironment.stopWorkflow.permissionAtom(props.environmentId),
285 );
263286 const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null);
264 const [stoppingThreadId, setStoppingThreadId] = useState<ThreadId | null>(null);
287 const [stoppingId, setStoppingId] = useState<string | null>(null);
265288 const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection);
266289 const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection);
267290 const relationshipRows = useMemo(
......@@ -271,8 +294,17 @@ export function ThreadRelationshipsPanel(props: {
271294 rows: immediateThreadRelationships(graph, props.threadId),
272295 currentThreadId: props.threadId,
273296 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 } };
274306 }),
275 [graph, mergeTargetThreadId, props.threadId],
307 [graph, mergeTargetThreadId, props.threadId, subagentsByThreadId, workflowGroupsById],
276308 );
277309 const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null;
278310 const canDetach = projection ? canDetachThreadProviderSession(projection) : false;
......@@ -341,8 +373,8 @@ export function ThreadRelationshipsPanel(props: {
341373 childThreadId: ThreadId,
342374 agent: Pick<OrchestrationV2Subagent, "id" | "origin" | "threadId">,
343375 ) => {
344 if (stoppingThreadId !== null) return;
345 setStoppingThreadId(childThreadId);
376 if (stoppingId !== null) return;
377 setStoppingId(childThreadId);
346378 const result = await interruptTurn({
347379 environmentId: props.environmentId,
348380 input:
......@@ -350,12 +382,25 @@ export function ThreadRelationshipsPanel(props: {
350382 ? { threadId: agent.threadId, subagentId: agent.id }
351383 : { threadId: childThreadId },
352384 });
353 setStoppingThreadId(null);
385 setStoppingId(null);
354386 if (result._tag === "Failure") {
355387 toastManager.add({ type: "error", title: "Could not stop subagent" });
356388 }
357389 };
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
359404 const parentTitle =
360405 mergeTargetThreadId === null
361406 ? null
......@@ -400,33 +445,41 @@ export function ThreadRelationshipsPanel(props: {
400445 const isSubagent = edge.kind === "subagent";
401446 const isMergeTarget = threadId === mergeTargetThreadId;
402447 const isParent = isParentThreadRelationship(edge, props.threadId);
403 const status = threadRelationshipRowStatus(graph, { threadId, edge });
448 const rowStatus = threadRelationshipRowStatus(graph, { threadId, edge });
404449 const RelationshipIcon = isParent
405450 ? CornerLeftUpIcon
406451 : isSubagent
407452 ? BotIcon
408453 : GitForkIcon;
409454 const relationship = relationshipLabel(edge, props.threadId);
410 const agent = currentSubagent(
411 isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined,
412 node?.thread,
413 );
414 const failed = status === "failed" || status === "error";
455 const projectedAgent =
456 isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined;
457 const workflowGroup =
458 projectedAgent === undefined
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";
415465 const canStop =
416466 agent &&
417467 (agent.origin === "app_owned" ||
418468 (agent.origin === "provider_native" &&
419469 agent.driver === "claudeAgent" &&
470 agent.runId != null &&
420471 agent.nativeTaskRef?.strength === "strong" &&
421472 agent.nativeTaskRef.nativeId !== null &&
422473 ["pending", "running", "waiting"].includes(agent.nativeStatus))) &&
423474 agent.startedAt &&
424475 ["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 : "";
428476 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,
430483 isSubagent,
431484 });
432485 const provider = providers?.find(
......@@ -435,6 +488,14 @@ export function ThreadRelationshipsPanel(props: {
435488 (agent?.providerInstanceId ?? node?.thread?.providerInstanceId),
436489 );
437490 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 : "";
438499 const project = projects.find((project) => project.id === node?.thread?.projectId);
439500 const relationshipHint = node?.missing
440501 ? "This related thread is unavailable"
......@@ -468,7 +529,7 @@ export function ThreadRelationshipsPanel(props: {
468529 driver={isSubagent && !isParent ? providerDriver : undefined}
469530 provider={provider}
470531 fallbackIcon={RelationshipIcon}
471 status={status}
532 status={rowStatus}
472533 />
473534 <span className="min-w-0 flex-1">
474535 <span className="block truncate text-left text-sm font-medium leading-4 text-foreground/85">
......@@ -478,23 +539,68 @@ export function ThreadRelationshipsPanel(props: {
478539 {agent ? null : (
479540 <ArrowRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
480541 )}
481 {/* One trailing item keeps room for the title: the icon dot already
482 carries status, so an agent with a known time shows only that. */}
483 {agent && !failed && deriveSubagentElapsedMs(agent, 0) !== null ? (
542 {agent &&
543 !failed &&
544 (deriveSubagentElapsedMs(agent, 0) !== null || canStopWorkflow) ? (
484545 <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}`}
486547 >
487548 <AgentElapsed agent={agent} compact />
488549 </span>
489550 ) : !isMergeTarget ? (
490551 <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 }
492557 >
493 {threadRelationshipStatusLabel(status)}
558 {threadRelationshipStatusLabel(rowStatus)}
494559 </span>
495560 ) : null}
496561 </>
497562 );
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 }
498604 return (
499605 <li key={threadId} className="group relative flex h-8 items-center rounded-lg">
500606 {isMergeTarget ? (
......@@ -507,7 +613,7 @@ export function ThreadRelationshipsPanel(props: {
507613 size="sm"
508614 variant="ghost"
509615 part="primary"
510 aria-label={`${threadTitle} ${threadRelationshipStatusLabel(status)}`}
616 aria-label={`${threadTitle} ${threadRelationshipStatusLabel(rowStatus)}`}
511617 disabled={node?.missing === true}
512618 onClick={() => openThread(threadId)}
513619 />
......@@ -553,27 +659,11 @@ export function ThreadRelationshipsPanel(props: {
553659 </TooltipPopup>
554660 </Tooltip>
555661 <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)}
557663 </span>
558664 </div>
559665 ) : (
560 <Tooltip>
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>
666 relationshipLink
577667 )}
578668 {canStop && agent ? (
579669 <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: {
586676 part="icon"
587677 tone="destructive"
588678 aria-label={`Stop subagent ${threadTitle}`}
589 disabled={stoppingThreadId !== null}
679 disabled={stoppingId !== null}
590680 onClick={() => void stopSubagent(threadId, agent)}
591681 />
592682 }
593683 >
594 {stoppingThreadId === threadId ? (
684 {stoppingId === threadId ? (
595685 <LoaderCircleIcon aria-hidden className="size-3 animate-spin" />
596686 ) : (
597687 <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({
1414 children,
1515 expanded,
1616 tone = "muted",
17 variant = "default",
1718 accessory,
1819 ...buttonProps
1920}: Omit<ComponentProps<"button">, "className" | "style" | "aria-expanded"> & {
2021 expanded: boolean;
2122 tone?: keyof typeof tones;
23 variant?: "default" | "panel";
2224 accessory?: ReactNode;
2325}) {
2426 return (
......@@ -27,7 +29,8 @@ export function CollapsibleSectionHeader({
2729 type="button"
2830 aria-expanded={expanded}
2931 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",
3134 tones[tone].label,
3235 )}
3336 >
packages/client-runtime/src/state/orchestration.ts+4
......@@ -44,6 +44,10 @@ export function createOrchestrationEnvironmentAtoms<R, E>(
4444 staleTimeMs: 300_000,
4545 idleTtlMs: 300_000,
4646 }),
47 stopWorkflow: createEnvironmentRpcCommand(runtime, {
48 label: "environment-data:orchestration:stop-workflow",
49 tag: ORCHESTRATION_V2_WS_METHODS.stopWorkflow,
50 }),
4751 // Keyed by the item revision, so a live row refetches as its output grows.
4852 turnItem: createEnvironmentRpcQueryAtomFamily(runtime, {
4953 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 @@
22 * Subagent status helpers shared by web and mobile, and the runtime shape the
33 * web agent rows render.
44 */
5import { groupBy } from "effect/Array";
56import * as DateTime from "effect/DateTime";
6import type { OrchestrationV2Subagent, OrchestrationV2TurnItem } from "@t3tools/contracts";
7import type {
8 OrchestrationV2Subagent,
9 OrchestrationV2SubagentWorkflow,
10 OrchestrationV2TurnItem,
11} from "@t3tools/contracts";
712import { isOrchestrationV2WorkActive } from "@t3tools/contracts";
813
914export type RuntimeSubagentStatus =
......@@ -36,11 +41,12 @@ export interface SubagentWorkflowPhase {
3641 readonly title: string;
3742}
3843
44// Optional-and-undefined, matching the contract's own optionals.
3945export interface SubagentRunHandles {
40 readonly runId?: string;
41 readonly scriptPath?: string;
42 readonly transcriptDir?: string;
43 readonly sessionUrl?: string;
46 readonly runId?: string | undefined;
47 readonly scriptPath?: string | undefined;
48 readonly transcriptDir?: string | undefined;
49 readonly sessionUrl?: string | undefined;
4450}
4551
4652export interface RuntimeSubagent {
......@@ -66,6 +72,8 @@ export interface RuntimeSubagent {
6672 readonly workflowName: string | null;
6773 readonly phases: ReadonlyArray<SubagentWorkflowPhase>;
6874 readonly runHandles: SubagentRunHandles | null;
75 /** The thread this agent owns, when it has one. */
76 readonly childThreadId: string | null;
6977 readonly recentActivity: ReadonlyArray<SubagentActivityEntry>;
7078 /** First retained observation, used as the roster's stable display order. */
7179 readonly firstSeenAt: string;
......@@ -91,18 +99,105 @@ export function isActiveSubagentStatus(status: RuntimeSubagentStatus): boolean {
9199 return isOrchestrationV2WorkActive(status);
92100}
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 */
99102export function isLiveSubagentTurnItem(item: OrchestrationV2TurnItem): boolean {
100103 return item.type === "subagent" && isOrchestrationV2WorkActive(item.status);
101104}
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
103199/**
104 * Projects orchestration-v2 subagent entities into the runtime shape the web
105 * agent rows render.
200 * Projects subagents and workflow members into the runtime roster.
106201 */
107202export function projectedSubagentsToRuntime(
108203 subagents: ReadonlyArray<{
......@@ -112,18 +207,23 @@ export function projectedSubagentsToRuntime(
112207 readonly model: string | null;
113208 readonly status: OrchestrationV2Subagent["status"];
114209 readonly progress?: string | undefined;
210 readonly childThreadId?: string | null | undefined;
211 readonly workflow?: OrchestrationV2SubagentWorkflow | undefined;
115212 readonly result: string | null;
116213 readonly startedAt: DateTime.Utc | null;
117214 readonly completedAt: DateTime.Utc | null;
118215 readonly updatedAt: DateTime.Utc;
119216 }>,
120217): ReadonlyArray<RuntimeSubagent> {
121 return subagents.map((subagent) => {
218 return subagents.flatMap((subagent) => {
122219 const updatedAt = DateTime.formatIso(subagent.updatedAt);
123220 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 = {
125225 id: subagent.id,
126 kind: "subagent" as const,
226 kind: workflow === undefined ? "subagent" : "workflow",
127227 title:
128228 subagent.title ??
129229 (subagent.prompt.length > 80 ? `${subagent.prompt.slice(0, 77)}...` : subagent.prompt),
......@@ -132,7 +232,7 @@ export function projectedSubagentsToRuntime(
132232 effort: null,
133233 status: subagent.status,
134234 activationCount: 1,
135 usage: null,
235 usage: workflow === undefined ? null : workflowUsage(workflow),
136236 progress: subagent.progress ?? null,
137237 lastToolName: null,
138238 result: subagent.result,
......@@ -143,14 +243,78 @@ export function projectedSubagentsToRuntime(
143243 phaseIndex: null,
144244 phaseTitle: null,
145245 attempt: null,
146 workflowName: null,
147 phases: [],
148 runHandles: null,
246 workflowName: workflow?.name ?? null,
247 phases: workflow?.phases ?? [],
248 runHandles,
249 childThreadId: subagent.childThreadId ?? null,
149250 recentActivity: [],
150 firstSeenAt: startedAt ?? updatedAt,
251 firstSeenAt,
151252 startedAt,
152253 completedAt: subagent.completedAt === null ? null : DateTime.formatIso(subagent.completedAt),
153254 updatedAt,
154255 } 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 };
155319 });
156320}
packages/client-runtime/src/t3ToolSummary.test.ts+17
......@@ -248,4 +248,21 @@ describe("summarizeT3ToolCalls", () => {
248248 ]).label,
249249 ).toBe("Requested deletion of 1 scheduled task");
250250 });
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 });
251268});
packages/client-runtime/src/t3ToolSummary.ts+7
......@@ -168,6 +168,13 @@ export function summarizeT3ToolCalls(
168168 quantity(countEntities(threadIds), "thread"),
169169 );
170170 break;
171 case "workflow-stop":
172 label = phrase(
173 "Requested stops for",
174 "stop",
175 quantity(countEntities(entityIds("subagentId")), "workflow"),
176 );
177 break;
171178 case "task-status":
172179 label = phrase("Checked", "check", `task status ${times}`);
173180 break;
packages/client-runtime/src/work-log/presentation.test.ts+42
......@@ -308,6 +308,7 @@ describe("resolveWorkEntryToolPresentation", () => {
308308 ["t3_thread_fork", "Forking this thread", "Requested a fork of this thread"],
309309 ["t3_thread_send_attachments", "Sending attachments", "Sent attachments"],
310310 ["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"],
311312 ])("labels %s through its lifecycle", (tool, running, completed) => {
312313 expect(resolveWorkEntryToolPresentation({ label: `T3-code.${tool}` })?.displayName).toBe(
313314 running,
......@@ -320,6 +321,47 @@ describe("resolveWorkEntryToolPresentation", () => {
320321 ).toBe(completed);
321322 });
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
323365 it("summarizes project tools from MCP arguments and results without claiming failed effects", () => {
324366 const entry: WorkLogPresentationEntry = {
325367 id: "clone",
packages/client-runtime/src/work-log/presentation.ts+1
......@@ -609,6 +609,7 @@ function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction)
609609 case "thread-create":
610610 case "thread-send":
611611 case "thread-interrupt":
612 case "workflow-stop":
612613 case "schedule-create":
613614 case "schedule-update":
614615 case "schedule-delete":
packages/contracts/src/clientRpcPermissions.ts+2
......@@ -6,6 +6,7 @@ import {
66 AuthSourceControlWriteScope,
77 type AuthEnvironmentScope,
88} from "./auth.ts";
9import { ORCHESTRATION_V2_WS_METHODS } from "./orchestrationV2.ts";
910import { WS_METHODS } from "./rpc.ts";
1011
1112/** Incremental client enforcement; the server still authorizes every request. */
......@@ -43,6 +44,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = {
4344 [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope,
4445 [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope,
4546 [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope,
47 [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope,
4648} as const;
4749export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES;
4850
packages/contracts/src/orchestrationV2.test.ts+64
......@@ -36,6 +36,7 @@ import {
3636 OrchestrationV2ShellSnapshot,
3737 OrchestrationV2SubscribeThreadInput,
3838 OrchestrationV2Subagent,
39 OrchestrationV2SubagentJson,
3940 OrchestrationV2ThreadHistoryPage,
4041 OrchestrationV2ThreadProjection,
4142 OrchestrationV2ThreadStreamItem,
......@@ -112,6 +113,7 @@ const decodeOrchestrationV2Checkpoint = Schema.decodeUnknownSync(OrchestrationV2
112113const decodeOrchestrationV2DomainEvent = Schema.decodeUnknownSync(OrchestrationV2DomainEvent);
113114const decodeProviderReplayTranscript = Schema.decodeUnknownSync(ProviderReplayTranscript);
114115const decodeOrchestrationV2Subagent = Schema.decodeUnknownSync(OrchestrationV2Subagent);
116const encodeOrchestrationV2SubagentJson = Schema.encodeSync(OrchestrationV2SubagentJson);
115117const decodeOrchestrationV2ThreadProjection = Schema.decodeUnknownSync(
116118 OrchestrationV2ThreadProjection,
117119);
......@@ -929,6 +931,68 @@ describe("orchestration V2 contracts", () => {
929931 expect(turnItem.progress).toBe("Inspecting package metadata");
930932 });
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
932996 it("decodes app-owned subagent parent-wake policies", () => {
933997 const appOwnedSubagent = {
934998 id: "node-subagent-2",
packages/contracts/src/orchestrationV2.ts+155
......@@ -664,6 +664,86 @@ export const OrchestrationV2ExecutionNode = Schema.Struct({
664664});
665665export 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
667747export const OrchestrationV2Subagent = Schema.Struct({
668748 id: NodeId,
669749 threadId: ThreadId,
......@@ -698,6 +778,8 @@ export const OrchestrationV2Subagent = Schema.Struct({
698778 "interrupted",
699779 ]),
700780 progress: Schema.optional(Schema.String),
781 /** Present only on a workflow coordinator task; absent on ordinary subagents. */
782 workflow: Schema.optional(OrchestrationV2SubagentWorkflow),
701783 result: Schema.NullOr(Schema.String),
702784 startedAt: Schema.NullOr(Schema.DateTimeUtc),
703785 completedAt: Schema.NullOr(Schema.DateTimeUtc),
......@@ -3163,6 +3245,7 @@ export const ORCHESTRATION_V2_WS_METHODS = {
31633245 getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot",
31643246 getThreadProjection: "orchestration.getThreadProjection",
31653247 getWorkflowScript: "orchestration.getWorkflowScript",
3248 stopWorkflow: "orchestration.stopWorkflow",
31663249 getTurnItem: "orchestration.getTurnItem",
31673250 launchThread: "orchestration.launchThread",
31683251 subscribeArchivedShell: "orchestration.subscribeArchivedShell",
......@@ -3448,11 +3531,41 @@ export class OrchestrationV2ThreadLaunchError extends Schema.TaggedError<Orchest
34483531 },
34493532) {}
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
34513563export const OrchestrationV2RpcError = Schema.Union([
34523564 OrchestrationV2DispatchCommandError,
34533565 OrchestrationV2GetThreadProjectionError,
34543566 OrchestrationV2GetShellSnapshotError,
34553567 OrchestrationV2ThreadLaunchError,
3568 OrchestrationV2StopWorkflowError,
34563569]);
34573570export type OrchestrationV2RpcError = typeof OrchestrationV2RpcError.Type;
34583571
......@@ -3473,6 +3586,44 @@ export const OrchestrationV2GetWorkflowScriptResult = Schema.Struct({
34733586export type OrchestrationV2GetWorkflowScriptResult =
34743587 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
34763627export const OrchestrationV2GetTurnItemInput = Schema.Struct({
34773628 threadId: ThreadId,
34783629 itemId: TurnItemId,
......@@ -3601,6 +3752,10 @@ export const OrchestrationV2RpcSchemas = {
36013752 input: OrchestrationV2GetThreadProjectionInput,
36023753 output: OrchestrationV2ThreadProjection,
36033754 },
3755 stopWorkflow: {
3756 input: OrchestrationV2StopWorkflowInput,
3757 output: Schema.Void,
3758 },
36043759 getWorkflowScript: {
36053760 input: OrchestrationV2GetWorkflowScriptInput,
36063761 output: OrchestrationV2GetWorkflowScriptResult,
packages/contracts/src/orchestratorMcp.ts+1
......@@ -399,6 +399,7 @@ export const OrchestratorMcpThreadTimelineItem = Schema.Struct({
399399 visibility: Schema.Literals(["local", "inherited", "synthetic"]),
400400 sourceThreadId: ThreadId,
401401 itemId: TurnItemId,
402 subagentId: Schema.optional(NodeId),
402403 runId: Schema.NullOr(RunId),
403404 messageId: Schema.NullOr(MessageId),
404405 createdBy: Schema.NullOr(OrchestrationV2Actor),
packages/contracts/src/rpc.ts+8
......@@ -208,6 +208,7 @@ import {
208208import {
209209 ORCHESTRATION_V2_WS_METHODS,
210210 OrchestrationGetWorkflowScriptError,
211 OrchestrationV2StopWorkflowError,
211212 OrchestrationV2DispatchCommandError,
212213 OrchestrationV2GetShellSnapshotError,
213214 OrchestrationV2GetThreadProjectionError,
......@@ -1662,6 +1663,12 @@ const WsOrchestrationV2GetThreadProjectionRpc = Rpc.make(
16621663 },
16631664);
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
16651672const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make(
16661673 ORCHESTRATION_V2_WS_METHODS.getWorkflowScript,
16671674 {
......@@ -2032,6 +2039,7 @@ export const WsRpcGroup = RpcGroup.make(
20322039 WsSubscribeResourceTelemetryRpc,
20332040 WsOrchestrationV2DispatchCommandRpc,
20342041 WsOrchestrationV2GetWorkflowScriptRpc,
2042 WsOrchestrationV2StopWorkflowRpc,
20352043 WsOrchestrationV2GetTurnItemRpc,
20362044 WsOrchestrationV2GetTurnDiffRpc,
20372045 WsOrchestrationV2GetFullThreadDiffRpc,
packages/provider-core/src/server/ProviderAdapter.ts+4
......@@ -595,6 +595,10 @@ export interface ProviderAdapterV2SessionRuntime {
595595 readonly steerTurn: (
596596 input: ProviderAdapterV2SteerInput,
597597 ) => Effect.Effect<void, ProviderAdapterV2Error>;
598 readonly stopTask?: (input: {
599 readonly providerThread: OrchestrationV2ProviderThread;
600 readonly taskId: string;
601 }) => Effect.Effect<void, ProviderAdapterV2Error>;
598602 readonly interruptTurn: (
599603 input: ProviderAdapterV2InterruptInput,
600604 ) => Effect.Effect<void, ProviderAdapterV2Error>;
packages/shared/src/t3McpToolPresentation.ts+5
......@@ -21,6 +21,7 @@ export type T3McpToolSummaryAction =
2121 | "thread-send"
2222 | "thread-wait"
2323 | "thread-interrupt"
24 | "workflow-stop"
2425 | "thread-configuration"
2526 | "thread-configure"
2627 | "thread-fork"
......@@ -138,6 +139,10 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = {
138139 t3_thread_read: tool(["Read", "Reading", "Read", "a T3 thread"], "thread-read"),
139140 t3_thread_send: tool(["Send", "Sending", "Sent", "to a T3 thread"], "thread-send"),
140141 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 ),
141146 t3_thread_interrupt: tool(
142147 ["Interrupt", "Interrupting", "Requested an interrupt of", "a T3 thread"],
143148 "thread-interrupt",