diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index d46609edce7782b96361382a5522e899bf94e394..ce90ad31fea83f633233d151b8b0b6079ec3cc6b 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -8,6 +8,7 @@ import { ProviderInstanceId, ThreadId, AuthOrchestrationOperateScope, + ORCHESTRATION_V2_WS_METHODS, AuthOrchestrationReadScope, AuthSourceControlWriteScope, AuthPreviewOperateScope, @@ -35,6 +36,12 @@ describe("RPC authorization scopes", () => { expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys())); }); + it("requires operate access to stop a workflow", () => { + expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.stopWorkflow)).toBe( + AuthOrchestrationOperateScope, + ); + }); + it("authorizes background policy reporting and observation deliberately", () => { expect(requiredScopeForRpcMethod(WS_METHODS.serverReportClientActivity)).toBe( AuthOrchestrationReadScope, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 405c9eee2eea9dd2a62cecb43683ae5bec5c67e5..7d67d92f4f713a09f1aef2f04abbebf7bea2a352 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -46,6 +46,7 @@ export const RPC_REQUIRED_SCOPES = { ...CLIENT_GUARDED_RPC_SCOPES, [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, + [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index fb5153669d4a66c5c043cb00cf08275246b134ee..bb4bedb74e01a7144de3a6e6fd7c5faaa77116c6 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -879,6 +879,7 @@ function timelineItem(input: { visibility: input.row.visibility, sourceThreadId: input.row.sourceThreadId, itemId: input.row.sourceItemId, + ...(input.row.item.type === "subagent" ? { subagentId: input.row.item.subagentId } : {}), runId: input.row.item.runId, messageId, createdBy: message?.createdBy ?? null, diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index a938ba7fd9992a8eb1f59fd3b44f9f621684e496..e2b058da506833b1f48619fb0dcf0d888c78a7d5 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -8,9 +8,14 @@ import { ChatImageAttachment, CommandId, EnvironmentId, + NodeId, + OrchestratorMcpThreadReadResult, + ProviderDriverKind, ProviderInstanceId, RunId, ThreadId, + TurnItemId, + type OrchestrationV2ProjectedTurnItem, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -69,6 +74,7 @@ import { import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; const decodeMcpAttachmentInput = Schema.decodeUnknownEffect(McpAttachmentInput); +const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadReadResult); // Registration asks for every service the thread tools declare; these cases call none that use them. const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe( @@ -663,37 +669,142 @@ function scheduledTask(id: string, runtimeMode: "auto" | "full-access"): never { } as never; } -it.effect("a caller cannot interrupt a thread that runs above its own modes", () => - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const result = yield* server - .callTool({ name: "t3_thread_interrupt", arguments: { threadId: "full-access-thread" } }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect(declaredFailure(result)).toMatchObject({ code: "runtime_mode_escalation_denied" }); - }).pipe( - Effect.provide( - McpHttpServer.layerOrchestratorToolkit.pipe( - Layer.provideMerge(McpServer.McpServer.layer), - Layer.provide(NodeCrypto.layer), - Layer.provide( - Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell: () => - Effect.succeed({ projectId: "project-a", deletedAt: null } as never), - getProjectThreadRecords: () => - Effect.succeed({ - thread: { - id: ThreadId.make("full-access-thread"), +it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( + "a caller cannot use %s on a thread that runs above its own modes", + (toolName) => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ + name: toolName, + arguments: { + threadId: "full-access-thread", + ...(toolName === "t3_workflow_stop" ? { subagentId: "workflow-coordinator" } : {}), + }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(declaredFailure(result)).toMatchObject({ code: "runtime_mode_escalation_denied" }); + }).pipe( + Effect.provide( + McpHttpServer.layerOrchestratorToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => + Effect.succeed({ projectId: "project-a", + deletedAt: null, runtimeMode: "full-access", interactionMode: "default", - deletedAt: null, - }, - runs: [], - } as never), - interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"), + } as never), + getProjectThreadRecords: () => + Effect.succeed({ + thread: { + id: ThreadId.make("full-access-thread"), + projectId: "project-a", + runtimeMode: "full-access", + interactionMode: "default", + deletedAt: null, + }, + runs: [], + } as never), + stopWorkflow: () => Effect.die("workflow stop must not dispatch above the ceiling"), + interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"), + }), + ), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + ), + ), + ), +); + +it.effect("discovers workflow stop targets and requires orchestration capability", () => { + const threadId = ThreadId.make("workflow-parent"); + const shell = McpToolAccessTestkit.liveThreadShell(threadId); + const subagentId = NodeId.make("workflow-coordinator"); + const itemId = TurnItemId.make("workflow-coordinator-activity"); + const row: OrchestrationV2ProjectedTurnItem = { + position: 0, + visibility: "local", + sourceThreadId: threadId, + sourceItemId: itemId, + item: { + id: itemId, + threadId, + runId: shell.activeRunId, + nodeId: subagentId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "running", + title: "Workflow: checkout review", + startedAt: shell.createdAt, + completedAt: null, + updatedAt: shell.updatedAt, + type: "subagent", + subagentId, + origin: "provider_native", + driver: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + childThreadId: ThreadId.make("workflow-child"), + prompt: "Review checkout", + result: null, + }, + }; + const stopped: Array<{ readonly threadId: ThreadId; readonly subagentId: string }> = []; + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const scope = clientScope("full-access"); + const read = yield* server + .callTool({ name: "t3_thread_read", arguments: { threadId, view: "activity" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(read.isError).toBe(false); + const result = yield* decodeThreadReadResult(read.structuredContent); + expect(result.items[0]?.subagentId).toBe(subagentId); + const call = (invocation: McpInvocationContext.McpInvocationScope) => + server + .callTool({ + name: "t3_workflow_stop", + arguments: { threadId, subagentId: result.items[0]?.subagentId }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + const denied = yield* call({ ...scope, capabilities: new Set(["worktree"]) }); + expect(declaredFailure(denied)).toMatchObject({ code: "capability_denied" }); + expect(stopped).toEqual([]); + const allowed = yield* call(scope); + expect(allowed.isError).toBe(false); + expect(stopped).toEqual([{ threadId: "workflow-parent", subagentId: "workflow-coordinator" }]); + }).pipe( + Effect.provide( + McpHttpServer.layerOrchestratorToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), + getProjectThreadRecords: () => + Effect.succeed(McpToolAccessTestkit.idleThreadProjection(shell)), + getTimelinePage: () => Effect.succeed({ items: [row], totalItems: 1, hasMore: false }), + stopWorkflow: (input) => + Effect.sync(() => { + stopped.push(input); + }), }), ), Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), @@ -703,8 +814,8 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", () Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), ), ), - ), -); + ); +}); it.effect("only the caller that prepared a pending upload can discard it", () => Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index e71b3d13888af17c333b1ef59142dacd52bcc97a..fa84ce59eebcbbd4ff07f7da100bcdebe072254e 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -1,6 +1,8 @@ +import { OrchestratorMcpFailure } from "@t3tools/contracts"; import { OrchestratorToolkit } from "./tools.ts"; import * as Effect from "effect/Effect"; +import { readCaller } from "../../threadAccess.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; @@ -35,6 +37,26 @@ const handlers = { return yield* service.cancelTask(scope, input); }), ), + t3_workflow_stop: McpToolAccess.writesThreads( + (input) => [input.threadId], + (input) => + Effect.gen(function* () { + const { threads } = yield* readCaller(); + return yield* threads.stopWorkflow(input).pipe( + Effect.as({}), + Effect.mapError( + (cause) => + new OrchestratorMcpFailure({ + code: + cause.reason === "unavailable" || cause.reason === "stop-failed" + ? "orchestration_error" + : "invalid_request", + message: cause.message, + }), + ), + ); + }), + ), schedule_task: McpToolAccess.startsThreads( // A scheduled task runs with the caller's own modes. () => ({}), diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 71ae2f892de52daa1e60e591c029acdee430293c..3248618a2de08ffb38803ef0c99ac58122d0518d 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -1,5 +1,6 @@ import { OrchestratorMcpCapabilitiesResult, + OrchestrationV2StopWorkflowInput, OrchestratorMcpCreateThreadsInput, OrchestratorMcpCreateThreadsResult, OrchestratorMcpDelegateTaskInput, @@ -31,6 +32,7 @@ import { ThreadMetadataMcpUpdateResult, } from "@t3tools/contracts"; import { Tool, Toolkit } from "effect/ai"; +import * as Schema from "effect/Schema"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -100,6 +102,18 @@ const TaskCancelTool = Tool.make("task_cancel", { .annotate(Tool.Title, "Cancel delegated task") .annotate(Tool.Destructive, true); +const WorkflowStopTool = Tool.make("t3_workflow_stop", { + description: + "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.", + parameters: OrchestrationV2StopWorkflowInput, + success: Schema.Struct({}), + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Stop a workflow") + .annotate(Tool.Destructive, true); + export const ScheduleTaskTool = Tool.make("schedule_task", { description: "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( DelegateTaskTool, TaskStatusTool, TaskCancelTool, + WorkflowStopTool, ScheduleTaskTool, ListScheduledTasksTool, UpdateScheduledTaskTool, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index db52eb31acf011a4dfc22202a56e84117de12962..69b8b3d325ab771a8dba322e6570bbb7a7699d94 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -17,6 +17,7 @@ type WsRpcMethod = RpcGroup.Rpcs["_tag"]; const RPC_AGGREGATES = { [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2", [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: "orchestration", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d80e76072388e5bf2659f8106bbbef728b8e68f5..6a9a5c20edee4616488776a9a1be47f79df4bd7a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1,6 +1,8 @@ -// @effect-diagnostics nodeBuiltinImport:off - the fake Claude CLI hands the SDK Node streams. +// @effect-diagnostics nodeBuiltinImport:off -- workflow fixtures use files and the fake Claude CLI uses Node streams import * as NodeEvents from "node:events"; +import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeStream from "node:stream"; import type { @@ -44,6 +46,7 @@ import { ThreadId, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -1024,6 +1027,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1183,6 +1187,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1302,6 +1307,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1527,6 +1533,7 @@ describe("ClaudeAdapterV2 attachments", () => { }), setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, }), @@ -1671,6 +1678,7 @@ describe("ClaudeAdapterV2 attachments", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -1765,6 +1773,7 @@ describe("ClaudeAdapterV2 native fork", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -2056,6 +2065,7 @@ describe("ClaudeAdapterV2 native session identity", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, }; @@ -2309,6 +2319,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly interrupt?: Effect.Effect; readonly stopTask?: (taskId: string) => Effect.Effect; readonly environment?: NodeJS.ProcessEnv; + readonly subagentLaunchToolUseId?: string; + readonly nativeSessionId?: string; readonly settings?: ClaudeSettings; // A CLI process opened after the first streams from its own queue, so the // first one can exit (Queue.shutdown) and a later turn can start another. @@ -2323,24 +2335,30 @@ describe("ClaudeAdapterV2 background wake turns", () => { const sdkMessages = yield* Queue.unbounded(); const processQueues: Array> = []; const processedMessages = new WeakMap>(); - const offerAndWait = Effect.fnUntraced(function* (message: SDKMessage) { + const offerAndWait = Effect.fnUntraced(function* ( + message: SDKMessage, + offered?: Deferred.Deferred, + ) { const processed = yield* Deferred.make(); processedMessages.set(message, processed); yield* Queue.offer(sdkMessages, message); + if (offered !== undefined) yield* Deferred.succeed(offered, undefined); yield* Deferred.await(processed); }); const offeredMessages: Array = []; const permissionModeChanges: Array = []; const continuationRequests: Array = []; - const subagentReceipts = - yield* Queue.unbounded< - Extract - >(); + const continuationReceipts = + yield* Queue.unbounded(); const terminalReceipts = yield* Queue.unbounded< Extract >(); + const subagentReceipts = + yield* Queue.unbounded< + Extract + >(); const systemNoticeReceipts = yield* Queue.unbounded< Extract @@ -2359,10 +2377,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: (request) => Effect.sync(() => { continuationRequests.push(request); - }), + }).pipe(Effect.andThen(Queue.offer(continuationReceipts, request))), }, queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + allocateSessionId: Effect.succeed(options?.nativeSessionId ?? WAKE_NATIVE_SESSION), open: (input) => Effect.gen(function* () { openedOptions = input.options; @@ -2377,6 +2395,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: options?.stopTask ?? (() => Effect.void), interrupt: Effect.void, close: Queue.shutdown(processMessages), }; @@ -2409,13 +2428,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { Effect.sync(() => { permissionModeChanges.push(mode); }), - ...(options?.stopTask === undefined ? {} : { stopTask: options.stopTask }), + stopTask: options?.stopTask ?? (() => Effect.void), interrupt: options?.interrupt ?? Effect.void, close: options?.close?.(sdkMessages) ?? Effect.void, }; }), forkSession: () => Effect.die("unused forkSession"), - subagentLaunchToolUseId: () => Effect.succeed(null), + subagentLaunchToolUseId: () => Effect.succeed(options?.subagentLaunchToolUseId ?? null), assertComplete: Effect.void, }, }); @@ -2436,7 +2455,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.gen(function* () { events.push(event); - if (event.type === "subagent.updated") yield* Queue.offer(subagentReceipts, event); + if (event.type === "subagent.updated") { + yield* Queue.offer(subagentReceipts, event); + } if (event.type === "turn.terminal") { yield* Queue.offer(terminalReceipts, event); } @@ -2474,6 +2495,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }, permissionModeChanges, continuationRequests, + continuationReceipts, events, terminalReceipts, subagentReceipts, @@ -4709,6 +4731,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, // The first CLI process keeps streaming until the test ends // it, so Stop stays parked waiting for it to exit. @@ -4968,6 +4991,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(queue), }; @@ -6547,6 +6571,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const harness = yield* makeWakeHarnessWithOptions({ close: (sdkMessages) => Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), + stopTask: () => Effect.void, interrupt: Deferred.succeed(interruptStarted, undefined), }); const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -6608,6 +6633,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const harness = yield* makeWakeHarnessWithOptions({ close: (sdkMessages) => Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), + stopTask: () => Effect.void, interrupt: Deferred.succeed(interruptStarted, undefined), }); const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -9692,6 +9718,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, // End this process stream so openQuery can replace it. close: Queue.shutdown(sdkMessages), @@ -9881,6 +9908,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -10122,6 +10150,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -10320,6 +10349,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -10502,6 +10532,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -10640,6 +10671,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), }; @@ -10874,6 +10906,2345 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + const WORKFLOW_TASK_ID = "task-workflow-run"; + const WORKFLOW_TOOL_USE_ID = "toolu-workflow-run"; + const makeWorkflowHarnessWithOptions = ( + options?: Parameters[0], + ) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const configDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-wf-" }); + const transcriptDir = NodePath.join(configDir, "projects", "workflow"); + yield* fileSystem.makeDirectory(transcriptDir, { recursive: true }); + yield* fileSystem.writeFileString( + NodePath.join(transcriptDir, "agent-a1.jsonl"), + '{"type":"assistant","message":{"role":"assistant","id":"msg_a1","content":[{"type":"text","text":"A1 from the transcript"}]}}\n', + ); + const harness = yield* makeWakeHarnessWithOptions({ + ...options, + environment: { ...options?.environment, CLAUDE_CONFIG_DIR: configDir }, + }); + return { ...harness, transcriptDir }; + }); + const makeWorkflowHarness = makeWorkflowHarnessWithOptions(); + + const workflowToolUse = claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + content: [ + { + type: "tool_use", + id: WORKFLOW_TOOL_USE_ID, + name: "Workflow", + input: { script: "alpha.js" }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000001001", + session_id: WAKE_NATIVE_SESSION, + }); + const workflowTaskStarted = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: WORKFLOW_TASK_ID, + tool_use_id: WORKFLOW_TOOL_USE_ID, + description: "Run the alpha workflow", + task_type: "local_workflow", + workflow_name: "probe-wf", + uuid: "00000000-0000-4000-8000-000000001002", + session_id: WAKE_NATIVE_SESSION, + }); + const workflowLaunchAck = (transcriptDir: string) => + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: WORKFLOW_TOOL_USE_ID, + content: [{ type: "text", text: "Workflow launched." }], + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000001003", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { + taskType: "local_workflow", + runId: "wf_probe", + transcriptDir, + }, + }); + const workflowSnapshot = (input: { + readonly uuid: string; + readonly state: "queued" | "start" | "done"; + readonly secondState?: "queued" | "start" | "done"; + readonly startedAt?: number; + readonly label?: string; + readonly prompt?: string; + readonly model?: string; + readonly attempt?: number; + readonly omitResult?: boolean; + }) => + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: WORKFLOW_TASK_ID, + tool_use_id: WORKFLOW_TOOL_USE_ID, + description: "Alpha: alpha:one", + workflow_progress: [ + { type: "workflow_phase", index: 1, title: "Alpha" }, + { + type: "workflow_agent", + index: 1, + label: input.label ?? "alpha:one", + agentId: "a1", + state: input.state, + phaseIndex: 1, + phaseTitle: "Alpha", + model: input.model ?? "claude-opus-5[1m]", + promptPreview: input.prompt ?? "Reply with exactly: A1", + ...(input.startedAt === undefined ? {} : { startedAt: input.startedAt }), + ...(input.attempt === undefined ? {} : { attempt: input.attempt }), + ...(input.state === "done" && input.omitResult !== true + ? { resultPreview: "A1 excerpt" } + : {}), + }, + { + type: "workflow_agent", + index: 2, + label: "alpha:two", + agentId: "a2", + state: input.secondState ?? input.state, + phaseIndex: 1, + phaseTitle: "Alpha", + promptPreview: "Reply with exactly: A2", + ...(input.state === "done" ? { resultPreview: "A2 excerpt" } : {}), + }, + ], + uuid: input.uuid, + session_id: WAKE_NATIVE_SESSION, + }); + const workflowNotification = (input: { + readonly uuid: string; + readonly status: "completed" | "failed" | "stopped"; + }) => + claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: WORKFLOW_TASK_ID, + tool_use_id: WORKFLOW_TOOL_USE_ID, + status: input.status, + summary: "Workflow finished.", + output_file: "/tmp/workflow.output", + uuid: input.uuid, + session_id: WAKE_NATIVE_SESSION, + }); + const workflowCoordinatorEvents = ( + events: ReadonlyArray, + ) => + events.filter( + ( + event, + ): event is Extract => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === WORKFLOW_TASK_ID, + ); + const workflowMemberEvents = ( + events: ReadonlyArray, + index: number, + ) => + events.filter( + ( + event, + ): event is Extract => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId?.endsWith(`:agent:${index}`) === true, + ); + const threadMessages = ( + events: ReadonlyArray, + threadId: ThreadId | null | undefined, + ) => + events.filter( + ( + event, + ): event is Extract => + event.type === "message.updated" && event.message.threadId === threadId, + ); + + const launchWorkflow = Effect.fnUntraced(function* (input: { + readonly harness: Effect.Success; + readonly snapshotUuid: string; + readonly state?: "queued" | "start"; + }) { + yield* input.harness.offerAndWait(workflowToolUse); + yield* input.harness.offerAndWait(workflowTaskStarted); + yield* input.harness.offerAndWait(workflowLaunchAck(input.harness.transcriptDir)); + yield* input.harness.offerAndWait( + workflowSnapshot({ uuid: input.snapshotUuid, state: input.state ?? "start" }), + ); + yield* awaitUntil( + () => workflowMemberEvents(input.harness.events, 2).length === 1, + "workflow members projected", + ); + }); + + it.effect.each(["Agent", "Workflow"] as const)( + "keeps a workflow running after its %s launch reply without an async flag", + (toolName) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-workflow-launch-${toolName}`), + text: "Run the workflow.", + attachments: [], + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowToolUse, + message: { + model: "claude-sonnet-4-6", + content: [ + { type: "tool_use", id: WORKFLOW_TOOL_USE_ID, name: toolName, input: {} }, + ], + }, + }), + ); + yield* harness.offerAndWait(workflowTaskStarted); + yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); + yield* harness.offerAndWait( + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001200", state: "start" }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001201", result: "Launched." }), + ); + yield* Queue.take(harness.terminalReceipts); + assert.equal( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, + "running", + ); + assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + const toolItems = harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TOOL_USE_ID + ? [event.turnItem.status] + : [], + ); + assert.deepEqual(toolItems, toolName === "Workflow" ? ["running", "completed"] : []); + yield* harness.offerAndWait( + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000001202", + status: "completed", + }), + ); + yield* Queue.take(harness.continuationReceipts); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-workflow-complete-${toolName}`), + text: "Workflow finished.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001205", result: "Finished." }), + ); + yield* Queue.take(harness.terminalReceipts); + assert.equal( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, + "completed", + ); + assert.equal( + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, + "completed", + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("scopes workflow member artifacts to the provider thread", () => + Effect.scoped( + Effect.gen(function* () { + const memberArtifacts: Array> = []; + const memberThreads: Array = []; + for (const nativeSessionId of ["workflow-session-a", "workflow-session-b"]) { + const harness = yield* makeWorkflowHarnessWithOptions({ nativeSessionId }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-${nativeSessionId}`), + text: "Run the workflow.", + attachments: [], + }), + ); + for (const frame of [ + workflowToolUse, + workflowTaskStarted, + workflowLaunchAck(harness.transcriptDir), + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001203", state: "done" }), + ]) { + yield* harness.offerAndWait(claudeSdkFrame({ ...frame, session_id: nativeSessionId })); + } + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001204", result: "Done." }), + session_id: nativeSessionId, + }), + ); + yield* Queue.take(harness.terminalReceipts); + const member = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; + assert.isDefined(member); + memberThreads.push(member?.childThreadId); + const ids = harness.events.flatMap((event) => { + if (event.type === "subagent.updated" && event.subagent.id === member?.id) + return [event.subagent.id]; + if (event.type === "node.updated" && event.node.threadId === member?.childThreadId) + return [event.node.id]; + if ( + event.type === "message.updated" && + event.message.threadId === member?.childThreadId + ) + return [event.message.id]; + if ( + event.type === "turn_item.updated" && + event.turnItem.threadId === member?.childThreadId + ) + return [event.turnItem.id]; + return []; + }); + assert.isAtLeast(new Set(ids).size, 6); + memberArtifacts.push(ids); + } + assert.notEqual(memberThreads[0], memberThreads[1]); + assert.deepEqual( + memberArtifacts[0]?.filter((id) => memberArtifacts[1]?.includes(id)), + [], + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("stops only a live workflow task and waits for its stopped notification", () => + Effect.scoped( + Effect.gen(function* () { + const stoppedTasks: Array = []; + let interrupted = false; + let closed = false; + const harness = yield* makeWorkflowHarnessWithOptions({ + stopTask: (taskId) => + Effect.sync(() => { + stoppedTasks.push(taskId); + }), + interrupt: Effect.sync(() => { + interrupted = true; + }), + close: () => + Effect.sync(() => { + closed = true; + }), + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-stop"), + text: "Run the workflow.", + attachments: [], + }), + ); + yield* harness.offerAndWait(workflowToolUse); + yield* harness.offerAndWait(workflowTaskStarted); + yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001100", + state: "start", + }), + ); + let member = yield* Queue.take(harness.subagentReceipts); + while (!member.subagent.nativeTaskRef?.nativeId?.endsWith(":agent:2")) { + member = yield* Queue.take(harness.subagentReceipts); + } + const stopTask = harness.runtime.stopTask!; + for (const taskId of ["missing-task", `${WORKFLOW_TASK_ID}:agent:1`]) { + const exit = yield* stopTask({ providerThread: harness.providerThread, taskId }).pipe( + Effect.exit, + ); + assert.isTrue(Exit.isFailure(exit)); + } + const wrongThread = { + ...harness.providerThread, + nativeThreadRef: { + driver: harness.providerThread.driver, + nativeId: "wrong-session", + strength: "strong" as const, + }, + }; + assert.isTrue( + Exit.isFailure( + yield* stopTask({ + providerThread: wrongThread, + taskId: WORKFLOW_TASK_ID, + }).pipe(Effect.exit), + ), + ); + assert.deepEqual(stoppedTasks, []); + yield* stopTask({ providerThread: harness.providerThread, taskId: WORKFLOW_TASK_ID }); + assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]); + assert.isFalse(interrupted); + assert.isFalse(closed); + assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, "running"); + yield* harness.offerAndWait( + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000001101", + status: "stopped", + }), + ); + member = yield* Queue.take(harness.subagentReceipts); + while (!member.subagent.nativeTaskRef?.nativeId?.endsWith(":agent:2")) { + member = yield* Queue.take(harness.subagentReceipts); + } + assert.equal( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, + "cancelled", + ); + for (const index of [1, 2]) { + assert.equal( + workflowMemberEvents(harness.events, index).at(-1)?.subagent.status, + "cancelled", + ); + } + assert.isTrue( + Exit.isFailure( + yield* stopTask({ + providerThread: harness.providerThread, + taskId: WORKFLOW_TASK_ID, + }).pipe(Effect.exit), + ), + ); + assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("projects each workflow member as its own subagent thread", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + const now = yield* DateTime.now; + const coordinatorTurnItems = () => + harness.events.filter( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID, + ); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-workflow-members"), + text: "Run the alpha workflow.", + attachments: [], + }), + ); + yield* launchWorkflow({ + harness, + snapshotUuid: "00000000-0000-4000-8000-000000001004", + state: "queued", + }); + + const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; + const first = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; + const second = workflowMemberEvents(harness.events, 2).at(-1)?.subagent; + assert.equal(first?.status, "pending"); + assert.isNull(first?.startedAt); + assert.equal(second?.status, "pending"); + assert.isNull(second?.startedAt); + const coordinatorThread = harness.events.find( + (event) => + event.type === "app_thread.created" && + event.appThread.id === coordinator?.childThreadId, + ); + assert.equal( + coordinatorThread?.type === "app_thread.created" + ? coordinatorThread.appThread.title + : null, + "Workflow · probe-wf", + ); + assert.equal(first?.title, "alpha:one"); + assert.equal(first?.prompt, "Reply with exactly: A1"); + assert.equal(first?.model, "claude-opus-5[1m]"); + // Members hang off the coordinator's own thread, not the launching run's. + assert.equal(first?.threadId, coordinator?.childThreadId); + assert.equal(second?.threadId, coordinator?.childThreadId); + assert.notEqual(first?.childThreadId, second?.childThreadId); + + assert.equal( + harness.events.find( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "app_thread.created" } + > => event.type === "app_thread.created" && event.appThread.id === first?.childThreadId, + )?.appThread.title, + "alpha:one", + ); + const memberNodes = harness.events.filter( + ( + event, + ): event is Extract => + event.type === "node.updated" && + event.node.nativeItemRef?.nativeId === first?.nativeTaskRef?.nativeId, + ); + assert.deepEqual( + memberNodes.map((event) => event.node.kind), + ["subagent", "root_turn"], + ); + assert.deepEqual( + memberNodes.map((event) => [event.node.status, event.node.startedAt]), + [ + ["pending", null], + ["pending", null], + ], + ); + assert.deepEqual( + threadMessages(harness.events, first?.childThreadId).map((event) => event.message.text), + ["Reply with exactly: A1"], + ); + assert.deepEqual( + threadMessages(harness.events, second?.childThreadId).map((event) => event.message.text), + ["Reply with exactly: A2"], + ); + + yield* TestClock.adjust("60 seconds"); + const memberStartedAt = DateTime.add(now, { seconds: 30 }); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001007", + state: "start", + secondState: "queued", + startedAt: DateTime.toEpochMillis(memberStartedAt), + }), + ); + let started = yield* Queue.take(harness.subagentReceipts); + while (started.subagent.id !== first?.id || started.subagent.status !== "running") { + started = yield* Queue.take(harness.subagentReceipts); + } + assert.deepEqual(started.subagent.startedAt, memberStartedAt); + const startedRoot = harness.events.findLast( + (event) => event.type === "node.updated" && event.node.threadId === first?.childThreadId, + ); + assert.equal( + startedRoot?.type === "node.updated" ? startedRoot.node.status : null, + "running", + ); + assert.deepEqual( + startedRoot?.type === "node.updated" ? startedRoot.node.startedAt : null, + memberStartedAt, + ); + + const turnItemsBeforeRepeat = coordinatorTurnItems().length; + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001005", + state: "start", + secondState: "queued", + }), + ); + // The coordinator's turn item is emitted after its members are + // projected, so a re-emitted member would already be in `events`. + yield* awaitUntil( + () => coordinatorTurnItems().length > turnItemsBeforeRepeat, + "repeated workflow snapshot", + ); + assert.lengthOf(workflowMemberEvents(harness.events, 1), 2); + assert.lengthOf(workflowMemberEvents(harness.events, 2), 1); + + const firstThreadId = first?.childThreadId; + const firstThreadEvents = () => + harness.events.filter( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "app_thread.created" } + > => event.type === "app_thread.created" && event.appThread.id === firstThreadId, + ); + const turnItemsBeforeMetadata = coordinatorTurnItems().length; + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001017", + state: "start", + secondState: "queued", + label: "alpha:review", + prompt: "Review the change", + model: "claude-sonnet-4-6", + }), + ); + yield* awaitUntil( + () => coordinatorTurnItems().length > turnItemsBeforeMetadata, + "updated workflow member metadata", + ); + assert.equal( + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title, + "alpha:review", + ); + assert.equal( + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.prompt, + "Review the change", + ); + assert.equal( + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.model, + "claude-sonnet-4-6", + ); + assert.equal(firstThreadEvents().at(-1)?.appThread.title, "alpha:review"); + assert.equal( + firstThreadEvents().at(-1)?.appThread.modelSelection.model, + "claude-sonnet-4-6", + ); + assert.deepEqual( + firstThreadEvents().at(-1)?.appThread.createdAt, + firstThreadEvents()[0]?.appThread.createdAt, + ); + assert.equal( + threadMessages(harness.events, firstThreadId).at(-1)?.message.text, + "Review the change", + ); + + yield* TestClock.adjust("10 seconds"); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001018", + state: "done", + secondState: "queued", + omitResult: true, + }), + ); + let finished = yield* Queue.take(harness.subagentReceipts); + while (finished.subagent.id !== first?.id || finished.subagent.status !== "completed") { + finished = yield* Queue.take(harness.subagentReceipts); + } + const completedAt = finished.subagent.completedAt; + assert.deepEqual(completedAt, DateTime.add(now, { seconds: 70 })); + yield* TestClock.adjust("60 seconds"); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001019", + state: "done", + secondState: "queued", + }), + ); + let excerpt = yield* Queue.take(harness.subagentReceipts); + while (excerpt.subagent.id !== first?.id || excerpt.subagent.result !== "A1 excerpt") { + excerpt = yield* Queue.take(harness.subagentReceipts); + } + assert.deepEqual(excerpt.subagent.completedAt, completedAt); + const finishedRoot = harness.events.findLast( + (event) => event.type === "node.updated" && event.node.threadId === firstThreadId, + ); + assert.deepEqual( + finishedRoot?.type === "node.updated" ? finishedRoot.node.completedAt : undefined, + completedAt, + ); + + yield* harness.offerAndWait( + workflowNotification({ uuid: "00000000-0000-4000-8000-000000001008", status: "stopped" }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001006", + result: "Launched the workflow.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + const stopped = workflowMemberEvents(harness.events, 2).at(-1)?.subagent; + assert.equal(stopped?.status, "cancelled"); + assert.isNull(stopped?.startedAt); + const stoppedRoot = harness.events.findLast( + (event) => event.type === "node.updated" && event.node.threadId === second?.childThreadId, + ); + assert.equal( + stoppedRoot?.type === "node.updated" ? stoppedRoot.node.status : null, + "cancelled", + ); + assert.isNull( + stoppedRoot?.type === "node.updated" ? stoppedRoot.node.startedAt : undefined, + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("projects workflow member threads after resuming a restarted session", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWorkflowHarnessWithOptions({ + subagentLaunchToolUseId: WORKFLOW_TOOL_USE_ID, + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-workflow-resume"), + text: "Continue the workflow.", + attachments: [], + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowToolUse, + message: { + model: "claude-sonnet-4-6", + content: [ + { type: "tool_use", id: "toolu-resume-workflow", name: "SendMessage", input: {} }, + ], + }, + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowTaskStarted, + tool_use_id: "toolu-resume-workflow", + prompt: "Continue the workflow.", + }), + ); + yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); + yield* harness.offerAndWait( + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001040", state: "start" }), + ); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001041", result: "Resumed." }), + ); + yield* Queue.take(harness.terminalReceipts); + const member = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; + const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; + assert.isDefined(member); + assert.equal(member?.status, "running"); + assert.isTrue( + harness.events.some( + (event) => + event.type === "app_thread.created" && event.appThread.id === member?.childThreadId, + ), + ); + assert.deepEqual( + threadMessages(harness.events, member?.childThreadId).map((event) => event.message.text), + ["Reply with exactly: A1"], + ); + assert.isFalse( + harness.events.some( + (event) => + event.type === "app_thread.created" && + event.appThread.id === coordinator?.childThreadId, + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("does not republish a workflow roster on progress-only frames", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-workflow-progress-only"), + text: "Run the alpha workflow.", + attachments: [], + }), + ); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001120" }); + const coordinatorCount = () => workflowCoordinatorEvents(harness.events).length; + const offerProgressOnly = (index: number) => + Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: WORKFLOW_TASK_ID, + tool_use_id: WORKFLOW_TOOL_USE_ID, + description: "Member is working", + uuid: `00000000-0000-4000-8000-${String(1200 + index).padStart(12, "0")}`, + session_id: WAKE_NATIVE_SESSION, + }), + ); + + const inTurnCount = coordinatorCount(); + for (let index = 0; index < 5; index++) yield* offerProgressOnly(index); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001121", state: "start" }), + ); + yield* awaitUntil(() => coordinatorCount() > inTurnCount, "in-turn workflow snapshot"); + assert.equal(coordinatorCount(), inTurnCount + 1); + + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001122", + result: "Launched the workflow.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + + const betweenTurnCount = coordinatorCount(); + for (let index = 5; index < 10; index++) yield* offerProgressOnly(index); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001123", state: "done" }), + ); + yield* awaitUntil( + () => coordinatorCount() > betweenTurnCount, + "between-turn workflow snapshot", + ); + assert.equal(coordinatorCount(), betweenTurnCount + 1); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("keeps resumed workflow tools open after stale interrupted cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const clock = yield* Clock.Clock; + const cleanupPaused = yield* Deferred.make(); + const releaseCleanup = yield* Deferred.make(); + let readsUntilPause = 0; + yield* Effect.gen(function* () { + const harness = yield* makeWorkflowHarnessWithOptions({ + close: Queue.shutdown, + freshQueueOnReopen: true, + }); + const oldAttempt = RunAttemptId.make("attempt-workflow-stale-cleanup-old"); + const oldBash = "toolu-workflow-stale-cleanup-old-bash"; + const newBash = "toolu-workflow-stale-cleanup-new-bash"; + const resumeTool = "toolu-workflow-stale-cleanup-resume"; + const toolUpdates = (nativeId: string) => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.nativeItemRef?.nativeId === nativeId + ? [event.turnItem] + : [], + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: oldAttempt, + text: "Run the workflow.", + attachments: [], + }), + ); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001150" }); + for (const frame of makeSubagentAssistantFrames({ + parentToolUseId: WORKFLOW_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001151", + bashToolUseId: oldBash, + })) { + yield* harness.offerAndWait(frame); + } + yield* awaitUntil(() => toolUpdates(oldBash).length === 1, "old workflow tool running"); + readsUntilPause = 2; + const stop = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${oldAttempt}`, + }), + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(cleanupPaused); + yield* TestClock.adjust("10 seconds"); + yield* Fiber.join(stop); + yield* Queue.take(harness.terminalReceipts); + const oldToolCount = toolUpdates(oldBash).length; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-stale-cleanup-new"), + text: "Resume the workflow.", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + const resumedQueue = harness.processQueues[1]!; + yield* Queue.offer( + resumedQueue, + claudeSdkFrame({ + ...workflowTaskStarted, + tool_use_id: resumeTool, + prompt: "Resume the workflow.", + uuid: "00000000-0000-4000-8000-000000001152", + }), + ); + for (const frame of makeSubagentAssistantFrames({ + parentToolUseId: resumeTool, + uuid: "00000000-0000-4000-8000-000000001153", + bashToolUseId: newBash, + })) { + yield* Queue.offer(resumedQueue, frame); + } + yield* awaitUntil( + () => toolUpdates(newBash).length === 1, + "resumed workflow tool running", + ); + yield* Deferred.succeed(releaseCleanup, undefined); + yield* Queue.take(harness.terminalReceipts); + assert.lengthOf(toolUpdates(oldBash), oldToolCount); + assert.deepEqual( + toolUpdates(newBash).map((item) => item.status), + ["running"], + ); + yield* Queue.offer( + resumedQueue, + makeSubagentToolResultFrame({ + parentToolUseId: resumeTool, + uuid: "00000000-0000-4000-8000-000000001154", + toolUseId: newBash, + }), + ); + yield* awaitUntil( + () => toolUpdates(newBash).at(-1)?.status === "completed", + "resumed workflow tool completed", + ); + assert.equal(toolUpdates(newBash).at(-1)?.output, "ok"); + }).pipe( + Effect.provideService(Clock.Clock, { + ...clock, + currentTimeMillis: Effect.gen(function* () { + if (readsUntilPause > 0 && --readsUntilPause === 0) { + yield* Deferred.succeed(cleanupPaused, undefined); + yield* Deferred.await(releaseCleanup); + } + return yield* clock.currentTimeMillis; + }), + }), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect.each(["root result", "query close"] as const)( + "keeps a waiting continuation inactive until replay can handle %s", + (arrival) => + Effect.scoped( + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const digestStarted = yield* Deferred.make(); + const releaseDigest = yield* Deferred.make(); + const closeStarted = yield* Deferred.make(); + let pauseDigest = false; + yield* Effect.gen(function* () { + const harness = yield* makeWorkflowHarnessWithOptions({ + freshQueueOnReopen: true, + close: (messages) => + Deferred.succeed(closeStarted, undefined).pipe( + Effect.andThen(Queue.shutdown(messages)), + ), + }); + const start = (attemptId: string, ordinal: number, continuation: boolean) => + harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: DateTime.makeUnsafe(0), + attemptId: RunAttemptId.make(attemptId), + providerTurnOrdinal: ordinal, + text: "Continue working.", + attachments: [], + ...(continuation + ? { messageCreatedBy: "agent", messageCreationSource: "provider" } + : {}), + }), + ); + yield* start("attempt-workflow-waiting-launch", 1, false); + yield* launchWorkflow({ + harness, + snapshotUuid: "00000000-0000-4000-8000-000000003200", + }); + const fileSystem = yield* FileSystem.FileSystem; + const liveTranscriptDir = NodePath.join(harness.transcriptDir, "live"); + yield* fileSystem.makeDirectory(liveTranscriptDir); + yield* fileSystem.writeFileString( + NodePath.join(liveTranscriptDir, "agent-a1.jsonl"), + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Live member answer"}]}}\n', + ); + const liveTaskId = "task-workflow-live-while-waiting"; + const liveToolUseId = "toolu-workflow-live-while-waiting"; + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + content: [{ type: "tool_use", id: liveToolUseId, name: "Workflow", input: {} }], + }, + parent_tool_use_id: null, + session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000003201", + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowTaskStarted, + task_id: liveTaskId, + tool_use_id: liveToolUseId, + uuid: "00000000-0000-4000-8000-000000003202", + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { type: "tool_result", tool_use_id: liveToolUseId, content: "Launched." }, + ], + }, + tool_use_result: { + taskType: "local_workflow", + runId: "wf_live_while_waiting", + transcriptDir: liveTranscriptDir, + }, + parent_tool_use_id: null, + session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000003203", + }), + ); + const liveProgress = (state: "start" | "done", uuid: string) => + claudeSdkFrame({ + ...workflowSnapshot({ state, uuid }), + task_id: liveTaskId, + tool_use_id: liveToolUseId, + }); + yield* harness.offerAndWait( + liveProgress("start", "00000000-0000-4000-8000-000000003204"), + ); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000003205", + result: "Launched both workflows.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.offerAndWait( + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000003206", + status: "completed", + }), + ); + yield* Queue.take(harness.continuationReceipts); + yield* harness.offerAndWait(wakeTurnInit); + pauseDigest = true; + const progress = yield* harness + .offerAndWait(liveProgress("done", "00000000-0000-4000-8000-000000003207")) + .pipe(Effect.forkScoped); + yield* Deferred.await(digestStarted); + const replay = yield* start("attempt-workflow-waiting-replay", 2, true).pipe( + Effect.forkScoped({ startImmediately: true }), + ); + if (arrival === "query close") { + const stop = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: "turn:attempt-workflow-waiting-replay", + }), + requestRuntimeRestart: true, + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(closeStarted); + yield* Deferred.succeed(releaseDigest, undefined); + yield* Fiber.join(progress); + yield* Fiber.join(stop); + assert.isTrue(Exit.isFailure(yield* Fiber.await(replay))); + } else { + const offered = yield* Deferred.make(); + const result = yield* harness + .offerAndWait(wakeResult, offered) + .pipe(Effect.forkScoped); + yield* Deferred.await(offered); + yield* Deferred.succeed(releaseDigest, undefined); + yield* Fiber.join(progress); + yield* Fiber.join(replay); + yield* Fiber.join(result); + yield* Queue.take(harness.terminalReceipts); + assert.lengthOf(harness.continuationRequests, 1); + } + yield* start("attempt-workflow-waiting-next-user", 3, false); + }).pipe( + Effect.provideService(Crypto.Crypto, { + ...crypto, + digest: (algorithm, data) => + Effect.gen(function* () { + if (pauseDigest) { + pauseDigest = false; + yield* Deferred.succeed(digestStarted, undefined); + yield* Deferred.await(releaseDigest); + } + return yield* crypto.digest(algorithm, data); + }), + }), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect.each(["registry", "transcript"] as const)( + "does not replay stale idle progress over a completed workflow after awaiting %s", + (boundary) => + Effect.scoped( + Effect.gen(function* () { + const clock = yield* Clock.Clock; + const crypto = yield* Crypto.Crypto; + const progressRead = yield* Deferred.make(); + const releaseProgress = yield* Deferred.make(); + let pauseNextRead = false; + yield* Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-idle-race-launch"), + text: "Run the workflow.", + attachments: [], + }), + ); + yield* launchWorkflow({ + harness, + snapshotUuid: "00000000-0000-4000-8000-000000001060", + }); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001061", + result: "Launched.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.offerAndWait( + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000001062", + status: "completed", + }), + ); + if (boundary === "registry") { + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001063", result: "Done." }), + ); + } + const continuationInput = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-idle-race-continuation"), + text: "Continue.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }); + pauseNextRead = true; + const progress = yield* harness + .offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001064", + state: boundary === "transcript" ? "done" : "start", + secondState: "start", + }), + ) + .pipe(Effect.forkScoped); + yield* Deferred.await(progressRead); + const continuation = yield* harness.runtime + .startTurn(continuationInput) + .pipe(Effect.forkScoped); + yield* Deferred.succeed(releaseProgress, undefined); + yield* Fiber.join(progress); + yield* Fiber.join(continuation); + if (boundary === "registry") { + yield* Queue.take(harness.terminalReceipts); + } else { + const lastMemberId = workflowMemberEvents(harness.events, 2)[0]?.subagent.id; + while (true) { + const event = yield* Queue.take(harness.subagentReceipts); + if (event.subagent.id === lastMemberId && event.subagent.status === "completed") + break; + } + } + if (boundary === "transcript") { + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001065", result: "Done." }), + ); + yield* Queue.take(harness.terminalReceipts); + } + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.equal( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, + "completed", + ); + const member = workflowMemberEvents(harness.events, 2).at(-1)?.subagent; + assert.equal(member?.status, "completed"); + const root = harness.events.findLast( + (event) => + event.type === "node.updated" && event.node.threadId === member?.childThreadId, + ); + assert.equal(root?.type === "node.updated" ? root.node.status : null, "completed"); + const item = harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID, + ); + assert.equal( + item?.type === "turn_item.updated" ? item.turnItem.status : null, + "completed", + ); + }).pipe( + Effect.provideService(Clock.Clock, { + ...clock, + currentTimeMillis: Effect.gen(function* () { + if (boundary === "registry" && pauseNextRead) { + pauseNextRead = false; + yield* Deferred.succeed(progressRead, undefined); + yield* Deferred.await(releaseProgress); + } + return yield* clock.currentTimeMillis; + }), + }), + Effect.provideService(Crypto.Crypto, { + ...crypto, + digest: (algorithm, data) => + Effect.gen(function* () { + if (boundary === "transcript" && pauseNextRead) { + pauseNextRead = false; + yield* Deferred.succeed(progressRead, undefined); + yield* Deferred.await(releaseProgress); + } + return yield* crypto.digest(algorithm, data); + }), + }), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect.each([ + "root result", + "resume after root result", + "completion before buffered start", + ] as const)("orders %s arriving during workflow transcript replay", (arrival) => + Effect.scoped( + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const digestStarted = yield* Deferred.make(); + const releaseDigest = yield* Deferred.make(); + const liveFrameOffered = yield* Deferred.make(); + let pauseDigest = false; + yield* Effect.gen(function* () { + const harness = yield* makeWorkflowHarnessWithOptions({ close: Queue.shutdown }); + const taskId = "task-resumed-during-workflow-replay"; + const taskStart = (toolUseId: string, prompt: string, uuid: string) => + claudeSdkFrame({ + ...workflowTaskStarted, + task_id: taskId, + task_type: "local_agent", + is_backgrounded: true, + subagent_type: "general-purpose", + spawn_depth: 1, + workflow_name: undefined, + tool_use_id: toolUseId, + prompt, + uuid, + }); + const taskFinished = (toolUseId: string, summary: string, uuid: string) => + claudeSdkFrame({ + ...workflowNotification({ uuid, status: "completed" }), + task_id: taskId, + tool_use_id: toolUseId, + summary, + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-replay-launch"), + text: "Run the workflow and a background subagent.", + attachments: [], + }), + ); + yield* harness.offerAndWait(workflowToolUse); + yield* harness.offerAndWait(workflowTaskStarted); + yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); + yield* harness.offerAndWait( + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000003001", state: "start" }), + ); + if (arrival !== "completion before buffered start") { + yield* harness.offerAndWait( + taskStart("toolu-initial", "Initial work.", "00000000-0000-4000-8000-000000003002"), + ); + } + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000003003", + result: "Launched both tasks.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.offerAndWait( + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000003004", + status: "completed", + }), + ); + yield* Queue.take(harness.continuationReceipts); + if (arrival === "completion before buffered start") { + yield* harness.offerAndWait( + taskStart("toolu-initial", "Initial work.", "00000000-0000-4000-8000-000000003005"), + ); + } else { + yield* harness.offerAndWait( + taskFinished( + "toolu-initial", + "Initial result.", + "00000000-0000-4000-8000-000000003005", + ), + ); + } + if (arrival === "root result") { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "init", + session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000003006", + }), + ); + } + pauseDigest = true; + const replay = yield* harness.runtime + .startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-replay-continuation"), + providerTurnOrdinal: 2, + text: "Continue.", + attachments: [], + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ) + .pipe(Effect.forkScoped); + yield* Deferred.await(digestStarted); + const liveFrames = yield* Effect.gen(function* () { + if (arrival === "completion before buffered start") { + yield* harness.offerAndWait( + taskFinished( + "toolu-initial", + "Initial result.", + "00000000-0000-4000-8000-000000003007", + ), + liveFrameOffered, + ); + return; + } + yield* harness.offerAndWait( + claudeSdkFrame({ + ...makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000003008", + result: "Native wake turn finished.", + }), + origin: { kind: "task-notification" }, + num_turns: 1, + }), + liveFrameOffered, + ); + if (arrival === "resume after root result") { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "init", + session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000003009", + }), + ); + yield* harness.offerAndWait( + taskStart( + "toolu-latest", + "Latest live resume.", + "00000000-0000-4000-8000-000000003010", + ), + ); + } + }).pipe(Effect.forkScoped); + yield* Deferred.await(liveFrameOffered); + yield* Deferred.succeed(releaseDigest, undefined); + yield* Fiber.join(replay); + yield* Fiber.join(liveFrames); + if (arrival === "resume after root result") { + yield* Queue.take(harness.terminalReceipts); + yield* Queue.take(harness.continuationReceipts); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-replay-resume-continuation"), + providerTurnOrdinal: 3, + text: "Continue.", + attachments: [], + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + } + if (arrival !== "root result") { + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000003011", + result: "Workflow answer delivered.", + }), + ); + } + yield* Queue.take(harness.terminalReceipts); + const latest = harness.events + .flatMap((event) => + event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === taskId + ? [event.subagent] + : [], + ) + .at(-1); + if (arrival === "resume after root result") { + assert.equal(latest?.status, "running"); + assert.equal(latest?.prompt, "Latest live resume."); + assert.isNull(latest?.result); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + } else { + assert.equal(latest?.status, "completed"); + assert.equal(latest?.result, "Initial result."); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.lengthOf(harness.continuationRequests, 1); + // The native wake turn ended once; no extra provider result will + // arrive to settle a mistakenly opened continuation. + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-replay-next-user"), + providerTurnOrdinal: 3, + text: "Next task.", + attachments: [], + }), + ); + } + }).pipe( + Effect.provideService(Crypto.Crypto, { + ...crypto, + digest: (algorithm, data) => + Effect.gen(function* () { + if (pauseDigest) { + pauseDigest = false; + yield* Deferred.succeed(digestStarted, undefined); + yield* Deferred.await(releaseDigest); + } + return yield* crypto.digest(algorithm, data); + }), + }), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("preserves buffered workflow outcomes when interrupted during replay", () => + Effect.scoped( + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const digestStarted = yield* Deferred.make(); + const releaseDigest = yield* Deferred.make(); + let pauseDigest = false; + yield* Effect.gen(function* () { + const harness = yield* makeWorkflowHarnessWithOptions({ close: Queue.shutdown }); + const secondTaskId = "task-workflow-buffered-completion"; + const secondToolUseId = "toolu-workflow-buffered-completion"; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-buffered-launch"), + text: "Run both workflows.", + attachments: [], + }), + ); + yield* launchWorkflow({ + harness, + snapshotUuid: "00000000-0000-4000-8000-000000003100", + }); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowTaskStarted, + task_id: secondTaskId, + tool_use_id: secondToolUseId, + uuid: "00000000-0000-4000-8000-000000003101", + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000003102", + state: "start", + }), + task_id: secondTaskId, + tool_use_id: secondToolUseId, + }), + ); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000003103", + result: "Launched both workflows.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.offerAndWait( + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000003104", + status: "completed", + }), + ); + yield* Queue.take(harness.continuationReceipts); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowNotification({ + uuid: "00000000-0000-4000-8000-000000003105", + status: "completed", + }), + task_id: secondTaskId, + tool_use_id: secondToolUseId, + }), + ); + const continuationInput = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-buffered-replay"), + providerTurnOrdinal: 2, + text: "Continue.", + attachments: [], + messageCreatedBy: "agent", + messageCreationSource: "provider", + }); + pauseDigest = true; + const replay = yield* harness.runtime + .startTurn(continuationInput) + .pipe(Effect.forkScoped); + yield* Deferred.await(digestStarted); + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${continuationInput.attemptId}`, + }), + }); + yield* Deferred.succeed(releaseDigest, undefined); + yield* Fiber.join(replay); + const second = harness.events + .flatMap((event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === secondTaskId + ? [event.subagent] + : [], + ) + .at(-1); + assert.equal(second?.status, "completed"); + assert.equal(second?.result, "Workflow finished."); + }).pipe( + Effect.provideService(Crypto.Crypto, { + ...crypto, + digest: (algorithm, data) => + Effect.gen(function* () { + if (pauseDigest) { + pauseDigest = false; + yield* Deferred.succeed(digestStarted, undefined); + yield* Deferred.await(releaseDigest); + } + return yield* crypto.digest(algorithm, data); + }), + }), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect.each([ + ["api_error", false], + ["aborted_streaming", false], + ["api_error", true], + ] as const)( + "stops the run's live workflow after root result %s, continuation=%s", + ([terminalReason, continuation]) => + Effect.scoped( + Effect.gen(function* () { + const stoppedTasks: Array = []; + const harness = yield* makeWorkflowHarnessWithOptions({ + stopTask: (taskId) => Effect.sync(() => void stoppedTasks.push(taskId)), + }); + const now = yield* DateTime.now; + const turnInput = { + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-workflow-root-failure"), + text: "Run the workflow.", + attachments: [], + }; + yield* harness.runtime.startTurn(makeClaudeTestTurnInput(turnInput)); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001050" }); + if (continuation) { + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001051", + result: "Launched.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowTaskStarted, + uuid: "00000000-0000-4000-8000-000000001052", + }), + ); + } + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001053", + result: "Root turn ended.", + terminalReason, + isError: terminalReason === "api_error", + }), + ); + if (continuation) { + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + ...turnInput, + attemptId: RunAttemptId.make("attempt-claude-workflow-root-failure-wake"), + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + } + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, terminalReason === "api_error" ? "failed" : "interrupted"); + assert.equal(terminal.threadDisposition, "reusable"); + // The run following the workflow (its launcher, or the run a resumed + // workflow was re-attributed to) stops following it, so it is stopped + // instead of left running out of sight. + const settled = terminalReason === "api_error" ? "failed" : "cancelled"; + assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, settled); + for (const index of [1, 2]) { + assert.equal( + workflowMemberEvents(harness.events, index).at(-1)?.subagent.status, + settled, + ); + } + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect.each([ + ["stopped", false, false, false], + ["failed", false, false, false], + ["completed", false, false, false], + ["stopped", true, false, false], + ["failed", true, false, false], + ["completed", true, false, false], + ["interrupted", false, false, false], + ["interrupted", true, false, false], + ["interrupt_timeout", true, false, false], + ["stop_buffered", true, false, false], + ["stop_buffered", true, false, "buffered"], + ["stop_buffered", true, false, "live"], + ["drain_buffered", true, false, "live"], + ["drain_buffered", true, false, "live_after_buffered"], + ["query_failed", false, false, false], + ["query_failed", true, false, false], + ["query_failed", true, false, "buffered"], + ["query_failed", true, true, false], + ] as const)( + "projects workflow members on %s, between turns=%s, after an unrelated failed turn=%s, resume=%s", + ([status, betweenTurns, afterFailedTurn, resumed]) => + Effect.scoped( + Effect.gen(function* () { + const interrupted = status === "interrupted" || status === "interrupt_timeout"; + const liveResume = resumed === "live" || resumed === "live_after_buffered"; + const expected = + status === "drain_buffered" + ? resumed === "live_after_buffered" + ? "completed" + : "running" + : status === "stopped" || interrupted || (status === "stop_buffered" && resumed) + ? "cancelled" + : status === "query_failed" + ? "failed" + : status === "stop_buffered" + ? "completed" + : status; + const harness = yield* makeWorkflowHarnessWithOptions({ + close: status === "interrupt_timeout" ? () => Effect.void : Queue.shutdown, + }); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-workflow-terminal"), + text: "Run the workflow.", + attachments: [], + }), + ); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001010" }); + if (betweenTurns) { + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001013", + result: "Launched.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + if (afterFailedTurn || status === "interrupt_timeout") { + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-unrelated-failed"), + providerTurnOrdinal: 2, + text: "Unrelated follow-up.", + attachments: [], + }), + ); + if (afterFailedTurn) { + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001015", + result: "Unrelated follow-up failed.", + terminalReason: "api_error", + isError: true, + }), + ); + yield* Queue.take(harness.terminalReceipts); + } + } + if (status === "query_failed") { + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001014", + state: "start", + label: "Updated between turns", + }), + ); + } + } + const bufferedCompletion = + status === "stop_buffered" || status === "drain_buffered" || resumed; + if ((!interrupted && status !== "query_failed") || resumed) + yield* Queue.offer( + harness.sdkMessages, + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000001011", + status: bufferedCompletion ? "completed" : status, + }), + ); + if (betweenTurns && ((status !== "query_failed" && !interrupted) || resumed)) { + yield* Queue.take(harness.continuationReceipts); + } + if (resumed) { + if (resumed === "live_after_buffered") { + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowTaskStarted, + tool_use_id: "toolu-workflow-earlier-resume", + prompt: "Earlier buffered resume.", + uuid: "00000000-0000-4000-8000-000000001019", + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowNotification({ + uuid: "00000000-0000-4000-8000-000000001020", + status: "completed", + }), + tool_use_id: "toolu-workflow-earlier-resume", + }), + ); + } + if (liveResume) { + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-workflow-live-resume"), + providerTurnOrdinal: 2, + text: "Continue the workflow.", + attachments: [], + }), + ); + } + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowTaskStarted, + tool_use_id: "toolu-workflow-resume", + prompt: "Continue the workflow.", + uuid: "00000000-0000-4000-8000-000000001016", + }), + ); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001017", + state: "start", + attempt: 2, + label: "Updated between turns", + }), + ); + if (liveResume) { + if (resumed === "live_after_buffered") { + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowNotification({ + uuid: "00000000-0000-4000-8000-000000001021", + status: "completed", + }), + tool_use_id: "toolu-workflow-resume", + summary: "Latest workflow result.", + }), + ); + } + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001018", + result: "Resumed the workflow.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + } + } + if (betweenTurns && status !== "query_failed" && !interrupted && !bufferedCompletion) { + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-workflow-terminal-wake"), + text: "Workflow finished.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + } + if (interrupted || status === "stop_buffered") { + const interrupt = harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: + status === "interrupt_timeout" + ? "turn:attempt-unrelated-failed" + : liveResume + ? "turn:attempt-workflow-live-resume" + : "turn:attempt-workflow-terminal", + }), + ...(betweenTurns ? { requestRuntimeRestart: true } : {}), + }); + if (status === "interrupt_timeout") { + const stop = yield* interrupt.pipe(Effect.forkScoped); + yield* TestClock.adjust("10 seconds"); + yield* Fiber.join(stop); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted"); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + } else { + yield* interrupt; + } + } else if (status === "query_failed") { + yield* Queue.shutdown(harness.sdkMessages); + } else if (status === "drain_buffered") { + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-workflow-terminal-wake"), + text: "Workflow finished.", + attachments: [], + providerTurnOrdinal: 3, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + assert.equal( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, + expected, + ); + } else { + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001012", + result: "Finished.", + }), + ); + } + if ( + betweenTurns && + (status === "query_failed" || interrupted || status === "stop_buffered") + ) { + // The launching turn already ended. Await the last member's + // terminal event, which follows its root node update. + const lastMemberId = workflowMemberEvents(harness.events, 2)[0]?.subagent.id; + while (true) { + const update = yield* Queue.take(harness.subagentReceipts); + if (update.subagent.id === lastMemberId && update.subagent.status !== "running") + break; + } + assert.isFalse(yield* harness.hasPendingBackgroundWork); + if (status === "query_failed") { + assert.equal( + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title, + "Updated between turns", + ); + } + } else { + yield* Queue.take(harness.terminalReceipts); + } + assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, expected); + if (status === "drain_buffered") + assert.equal(yield* harness.hasPendingBackgroundWork, expected === "running"); + if (resumed === "live_after_buffered") { + assert.equal( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.result, + "Latest workflow result.", + ); + } + if (resumed) { + assert.notEqual( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.result, + "Workflow finished.", + ); + } + for (const index of [1, 2]) { + const updates = workflowMemberEvents(harness.events, index); + const member = updates.at(-1)?.subagent; + assert.equal(member?.status, expected); + assert.deepEqual(member?.startedAt, updates[0]?.subagent.startedAt); + const root = harness.events.findLast( + (event) => + event.type === "node.updated" && event.node.threadId === member?.childThreadId, + ); + assert.equal(root?.type === "node.updated" ? root.node.status : null, expected); + } + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("reads a transcript that appears after a terminal workflow notification", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-workflow-late-terminal-answer"), + text: "Run the alpha workflow.", + attachments: [], + }), + ); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001030" }); + const secondThreadId = workflowMemberEvents(harness.events, 2)[0]?.subagent.childThreadId; + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001031", state: "done" }), + ); + yield* awaitUntil( + () => threadMessages(harness.events, secondThreadId).length === 2, + "workflow member excerpt", + ); + const notification = workflowNotification({ + uuid: "00000000-0000-4000-8000-000000001032", + status: "completed", + }); + yield* Queue.offer(harness.sdkMessages, notification); + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID && + event.turnItem.status === "completed", + ), + "terminal workflow notification", + ); + NodeFS.writeFileSync( + NodePath.join(harness.transcriptDir, "agent-a2.jsonl"), + '{"type":"assistant","message":{"role":"assistant","id":"msg_late","content":[{"type":"text","text":"A2 final answer"}]}}\n', + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ ...notification, uuid: "00000000-0000-4000-8000-000000001033" }), + ); + yield* awaitUntil( + () => + threadMessages(harness.events, secondThreadId).at(-1)?.message.text === + "A2 final answer", + "late terminal transcript answer", + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001034", result: "Done." }), + ); + yield* Queue.take(harness.terminalReceipts); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("answers settled workflow members from a transcript, or the snapshot excerpt", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-workflow-settled"), + text: "Run the alpha workflow in the background.", + attachments: [], + }), + ); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001007" }); + const firstThreadId = workflowMemberEvents(harness.events, 1)[0]?.subagent.childThreadId; + const secondThreadId = workflowMemberEvents(harness.events, 2)[0]?.subagent.childThreadId; + const currentAssistantAnswers = () => [ + ...new Map( + threadMessages(harness.events, firstThreadId) + .filter((event) => event.message.role === "assistant") + .map((event) => [event.message.id, event.message.text]), + ).values(), + ]; + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001008", + result: "Launched the workflow.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-unrelated-failure"), + text: "Do another task.", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001014", + result: "Another task failed.", + terminalReason: "api_error", + isError: true, + }), + ); + yield* Queue.take(harness.terminalReceipts); + assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, "running"); + assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running"); + + // The run outlives the turn that launched it, so this snapshot arrives + // with no turn context at all. + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001009", state: "done" }), + ); + yield* awaitUntil( + () => threadMessages(harness.events, secondThreadId).length === 2, + "settled workflow member answers", + ); + + const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; + assert.deepEqual( + coordinator?.workflow?.agents.map((member) => member.state), + ["completed", "completed"], + ); + assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "completed"); + assert.equal(workflowMemberEvents(harness.events, 2).at(-1)?.subagent.status, "completed"); + assert.deepEqual( + threadMessages(harness.events, firstThreadId).map((event) => event.message.text), + ["Reply with exactly: A1", "A1 from the transcript"], + ); + assert.deepEqual( + threadMessages(harness.events, secondThreadId).map((event) => event.message.text), + ["Reply with exactly: A2", "Partial answer from workflow progress:\n\nA2 excerpt"], + ); + const coordinatorEventsBeforeRepeat = workflowCoordinatorEvents(harness.events).length; + const memberEventsBeforeRepeat = workflowMemberEvents(harness.events, 2).length; + NodeFS.appendFileSync( + NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), + '{"type":"assistant","message":{"role":"assistant","id":"msg_a1","content":[{"type":"text","text":"A1 continued"}]}}\n', + ); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001015", state: "done" }), + ); + yield* awaitUntil( + () => workflowCoordinatorEvents(harness.events).length > coordinatorEventsBeforeRepeat, + "repeated settled workflow snapshot", + ); + yield* awaitUntil( + () => + threadMessages(harness.events, firstThreadId).at(-1)?.message.text === + "A1 from the transcript\n\nA1 continued", + "continued workflow transcript answer", + ); + assert.lengthOf(workflowMemberEvents(harness.events, 2), memberEventsBeforeRepeat); + assert.lengthOf(threadMessages(harness.events, secondThreadId), 2); + assert.equal( + threadMessages(harness.events, firstThreadId).at(-1)?.message.text, + "A1 from the transcript\n\nA1 continued", + ); + NodeFS.writeFileSync( + NodePath.join(harness.transcriptDir, "agent-a2.jsonl"), + '{"type":"assistant","message":{"role":"assistant","id":"msg_a2","content":[{"type":"text","text":"A2 from the transcript"}]}}\n', + ); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001016", state: "done" }), + ); + yield* awaitUntil( + () => threadMessages(harness.events, secondThreadId).length === 3, + "late workflow transcript answer", + ); + assert.lengthOf(workflowMemberEvents(harness.events, 2), memberEventsBeforeRepeat); + assert.equal( + threadMessages(harness.events, secondThreadId).at(-1)?.message.text, + "A2 from the transcript", + ); + const previousStartedAt = workflowMemberEvents(harness.events, 1).at(-1)?.subagent + .startedAt; + yield* TestClock.adjust(1_000); + NodeFS.writeFileSync( + NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), + [ + '{"type":"assistant","message":{"role":"assistant","id":"msg_first","content":[{"type":"text","text":"First answer"}]}}', + '{"type":"assistant","message":{"role":"assistant","id":"msg_second","content":[{"type":"text","text":"Second answer"}]}}', + ].join("\n") + "\n", + ); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001018", + state: "done", + attempt: 2, + }), + ); + yield* awaitUntil( + () => currentAssistantAnswers().join("\n\n").includes("Second answer"), + "two-turn workflow member answer", + ); + assert.equal(currentAssistantAnswers().join("\n\n"), "First answer\n\nSecond answer"); + assert.notDeepEqual( + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.startedAt, + previousStartedAt, + ); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001019", + state: "start", + attempt: 3, + }), + ); + yield* awaitUntil( + () => workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status === "running", + "retried workflow member running", + ); + assert.notInclude(currentAssistantAnswers().join("\n\n"), "First answer"); + assert.notInclude(currentAssistantAnswers().join("\n\n"), "Second answer"); + const answerEventsDuringRetry = threadMessages(harness.events, firstThreadId).length; + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001020", + state: "start", + attempt: 3, + }), + ); + NodeFS.writeFileSync( + NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), + '{"type":"assistant","message":{"role":"assistant","id":"msg_retry","content":[{"type":"text","text":"A1 retry answer"}]}}\n', + ); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001021", + state: "done", + attempt: 3, + }), + ); + yield* awaitUntil( + () => + threadMessages(harness.events, firstThreadId).at(-1)?.message.text === + "A1 retry answer", + "retried workflow member answer", + ); + assert.deepEqual(currentAssistantAnswers(), ["A1 retry answer"]); + assert.lengthOf(threadMessages(harness.events, firstThreadId), answerEventsDuringRetry + 1); + NodeFS.rmSync(NodePath.join(harness.transcriptDir, "agent-a1.jsonl")); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001022", + state: "start", + attempt: 4, + }), + ); + yield* awaitUntil( + () => currentAssistantAnswers().at(-1) === "Retry in progress.", + "retry progress answer", + ); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001023", + state: "done", + attempt: 4, + omitResult: true, + }), + ); + yield* awaitUntil( + () => currentAssistantAnswers().at(-1) === "No answer from this workflow attempt.", + "empty retry answer", + ); + assert.deepEqual(currentAssistantAnswers(), ["No answer from this workflow attempt."]); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + describe("native goals", () => { const syntheticFrame = (uuid: string, text: string) => claudeSdkFrame({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index 730ed8a575f2cbb83a66ecb9b2759552c117368b..de9254aeaf71c65a49192b675557df102eacd54e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -17,8 +17,8 @@ import { type ProviderReplayTranscript, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -242,6 +242,7 @@ interface ClaudeSubagentFoundFrame { } type ClaudeOutboundFrame = + | { readonly type: "query.stop_task"; readonly taskId: string } | ClaudeQueryOpenFrame | ClaudePromptOfferFrame | ClaudeQuerySetModelFrame @@ -825,6 +826,10 @@ function makeReplayQueryRunner( mode, }); }), + stopTask: (taskId) => + replayEffect(() => { + assertNextOutboundFrame({ type: "query.stop_task", taskId }); + }), interrupt: replayEffect(() => { assertNextOutboundFrame({ type: "query.interrupt" }); }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index da344c202ff0f3971e0df0a6a02b1bcf3a0b1c98..b0f4a23a788474d88dcadd3ed24327399a7e3a20 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4,6 +4,15 @@ import { formatReadToolLabel, formatSearchToolLabel, } from "@t3tools/shared/toolActivity"; +import { field, text } from "../unknownField.ts"; +import { readWorkflowAgentAnswers } from "../workflowAgentAnswers.ts"; +import { + CLAUDE_WORKFLOW_TASK_TYPE, + hasClaudeWorkflowSnapshot, + mergeClaudeWorkflowProgress, + parseClaudeWorkflowRunHandles, +} from "./claudeWorkflowProgress.ts"; + import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts"; import { @@ -58,6 +67,9 @@ import { type OrchestrationV2RuntimeRequest, type OrchestrationV2UserInputQuestion, type OrchestrationV2Subagent, + type OrchestrationV2AppThread, + type OrchestrationV2SubagentWorkflow, + type OrchestrationV2WorkflowRunHandles, type OrchestrationV2TurnItem, type OrchestrationV2WebSearchResult, type ProviderApprovalDecision, @@ -66,15 +78,18 @@ import { type ProviderRequestKind, type ProviderUserInputAnswers, type ProviderThreadId, + type RunId, type ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; +import * as Hex from "effect/encoding/Hex"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -83,6 +98,7 @@ import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -365,7 +381,7 @@ export interface ClaudeAgentSdkQuerySession { readonly setPermissionMode: ( mode: PermissionMode, ) => Effect.Effect; - readonly stopTask?: (taskId: string) => Effect.Effect; + readonly stopTask: (taskId: string) => Effect.Effect; readonly interrupt: Effect.Effect; readonly close: Effect.Effect; } @@ -513,6 +529,14 @@ export type ClaudeAgentSdkProtocolLogEvent = readonly mode: PermissionMode; }; } + | { + readonly direction: "outgoing"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "query.stop_task"; + readonly taskId: string; + }; + } | { readonly direction: "outgoing"; readonly stage: "decoded"; @@ -809,7 +833,15 @@ export const layerQueryRunner: Layer.Layer< Effect.tryPromise({ try: () => queryRuntime.stopTask(taskId), catch: (cause) => queryRunnerError(cause, "stopTask"), - }), + }).pipe( + Effect.tap(() => + logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { type: "query.stop_task", taskId }, + }), + ), + ), interrupt: Effect.tryPromise({ try: () => queryRuntime.interrupt(), catch: (cause) => queryRunnerError(cause, "interrupt"), @@ -1534,6 +1566,12 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: } satisfies SDKUserMessage; }); +const sha256Hex = Effect.fnUntraced(function* (crypto: Crypto.Crypto, value: string) { + return Hex.encode( + yield* crypto.digest("SHA-256", new TextEncoder().encode(value)).pipe(Effect.orDie), + ); +}); + export function isClaudePromptUuid(value: string): value is NonNullable { return /^[^-]+-[^-]+-[^-]+-[^-]+-[^-]+$/.test(value); } @@ -2908,6 +2946,12 @@ interface ActiveClaudeSubagent { // owning subagent's node for a subagent another subagent started. readonly rootNodeId: OrchestrationV2ExecutionNode["id"]; readonly childThreadId: ThreadId; + /** + * The child thread object itself, not just its id: workflow members are + * parented to it long after the launching turn has settled, and building a + * child thread needs the whole parent. + */ + childThread: OrchestrationV2AppThread | null; readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; readonly turnItemId: OrchestrationV2TurnItem["id"]; readonly turnItemOrdinal: number; @@ -2932,6 +2976,8 @@ interface ClaudeLiveQueryContext { readonly queryPolicyKey: string; readonly selectionKey: string; readonly closed: Deferred.Deferred; + // Retained for process-exit cleanup even when its last turn failed. + lastTurn: ActiveClaudeTurnContext | null; // Whether this CLI process echoes a prompt's uuid on the first frame of // the turn answering it ("early") or only on its result. Learned from the // first prompt turn. "acknowledged": the CLI confirmed it took a prompt's @@ -3334,6 +3380,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const events = yield* Queue.unbounded(); const activeTurn = yield* Ref.make(null); + const sdkMessageLock = yield* Semaphore.make(1); + // Survives turn settle: members mostly finish after the launching + // turn has ended, and each transition must be emitted exactly once. + const workflowMemberStates = yield* Ref.make( + new Map< + string, + { + readonly status: OrchestrationV2Subagent["status"]; + readonly attempt: number; + /** Digest of the last emitted transcript answer, not the text: it can run to the transcript cap. */ + readonly transcriptAnswerDigest: string | undefined; + readonly transcriptReadAttempts: number; + readonly finalTranscriptRead: boolean; + readonly result: string | undefined; + readonly label: string; + readonly prompt: string; + readonly model: string; + readonly createdAt: DateTime.Utc; + readonly startedAt: DateTime.Utc | null; + readonly completedAt: DateTime.Utc | null; + } + >(), + ); const interruptedTurns = yield* Ref.make(new Set()); const steeredTurns = yield* Ref.make(new Set()); const queryContext = yield* Ref.make(null); @@ -4335,6 +4404,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const ids = claudeSubagentIds(resume.context, resume.taskId); const now = yield* DateTime.now; const subagent: ActiveClaudeSubagent = { + childThread: null, task: { id: ids.nodeId, threadId: resume.context.input.threadId, @@ -4385,6 +4455,341 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); }); + /** + * Stamps each member with the thread it owns. Derived from the + * coordinator's own child thread so the id inherits its provider-thread + * scoping, and done here so the snapshot the clients read carries the + * same id the projection emits. + */ + const withWorkflowMemberThreadIds = ( + workflow: OrchestrationV2SubagentWorkflow, + coordinatorChildThreadId: ThreadId, + ): OrchestrationV2SubagentWorkflow => ({ + ...workflow, + agents: workflow.agents.map((member) => ({ + ...member, + childThreadId: idAllocator.derive.threadFromProviderThread({ + driver: CLAUDE_PROVIDER, + nativeThreadId: `${coordinatorChildThreadId}:agent:${member.index}`, + }), + })), + }); + + /** Emits one conversation message into a subagent's own thread. */ + const emitSubagentConversationMessage = Effect.fnUntraced(function* (input: { + readonly nativeItemId: string; + readonly threadId: ThreadId; + readonly senderThreadId?: ThreadId; + readonly rootNodeId: OrchestrationV2ExecutionNode["id"]; + readonly role: "user" | "assistant"; + readonly text: string; + readonly ordinal: number; + readonly now: DateTime.Utc; + }) { + const artifacts = makeSubagentConversationArtifacts({ + messageId: idAllocator.derive.messageFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: input.nativeItemId, + }), + turnItemId: idAllocator.derive.turnItemFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: input.nativeItemId, + }), + threadId: input.threadId, + ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), + rootNodeId: input.rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: { + driver: CLAUDE_PROVIDER, + nativeId: input.nativeItemId, + strength: "strong", + }, + role: input.role, + text: input.text, + ordinal: input.ordinal, + now: input.now, + }); + yield* emitProviderEvent({ + type: "message.updated", + driver: CLAUDE_PROVIDER, + message: artifacts.message, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: artifacts.turnItem, + }); + }); + + /** + * Projects a workflow's members as ordinary subagents. + * + * A member is not a provider task — it exists only inside the + * coordinator's progress snapshot — but everything the user expects + * from a subagent (a thread to open, a prompt, an answer) is expressed + * by the same events, so this emits exactly the sequence + * updateClaudeSubagentNode emits rather than inventing a parallel + * shape. Members hang off the coordinator's thread, not the run's, so + * a fan-out of fifty does not bury the parent's lineage. + */ + const projectClaudeWorkflowMembers = Effect.fnUntraced(function* (input: { + readonly coordinator: ActiveClaudeSubagent; + readonly workflow: OrchestrationV2SubagentWorkflow; + }) { + const parentThread = input.coordinator.childThread; + const taskId = input.coordinator.task.nativeTaskRef?.nativeId; + if (parentThread === null || taskId === undefined || taskId === null) return; + const now = yield* DateTime.now; + const seen = yield* Ref.get(workflowMemberStates); + const transcriptDir = input.workflow.runHandles?.transcriptDir; + + for (const member of input.workflow.agents) { + if ((yield* Ref.get(sessionSubagentsByTaskId)).get(taskId) !== input.coordinator) + return; + const memberKey = `${input.coordinator.childThreadId}:agent:${member.index}`; + const previous = seen.get(memberKey); + const status = + (member.state === "queued" || member.state === "running") && + input.coordinator.task.status !== "running" + ? input.coordinator.task.status + : member.state === "queued" + ? "pending" + : member.state; + const settled = status !== "pending" && status !== "running"; + const prompt = member.prompt ?? member.label; + const model = member.model ?? parentThread.modelSelection.model; + const attempt = member.attempt ?? previous?.attempt ?? 1; + const restarted = previous !== undefined && attempt > previous.attempt; + const createdAt = previous?.createdAt ?? now; + const startedAt = + status === "pending" + ? null + : member.startedAt !== undefined + ? DateTime.makeUnsafe(member.startedAt) + : ((restarted ? null : previous?.startedAt) ?? + (status === "running" ? now : null)); + const completedAt = settled + ? ((restarted ? null : previous?.completedAt) ?? now) + : null; + const changed = + restarted || + previous?.status !== status || + !Equal.equals(previous?.startedAt, startedAt) || + previous?.result !== member.result || + previous?.label !== member.label || + previous?.prompt !== prompt || + previous?.model !== model; + const agentId = member.agentId; + const readTranscript = + settled && + transcriptDir !== undefined && + agentId !== undefined && + (input.coordinator.task.status === "running" + ? (restarted ? 0 : (previous?.transcriptReadAttempts ?? 0)) < 3 + : restarted || previous?.finalTranscriptRead !== true); + if (!changed && !readTranscript) continue; + + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: memberKey, + }); + const childRootNodeId = idAllocator.derive.nodeFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: `${memberKey}:thread-root`, + }); + const childThreadId = member.childThreadId; + if (childThreadId === undefined) continue; + + if ( + previous === undefined || + previous.label !== member.label || + previous.model !== model + ) { + yield* emitProviderEvent({ + type: "app_thread.created", + driver: CLAUDE_PROVIDER, + appThread: makeSubagentChildThread({ + parentThread, + childThreadId, + parentNodeId: nodeId, + activeProviderThreadId: null, + providerInstanceId: parentThread.providerInstanceId, + modelSelection: { instanceId: parentThread.providerInstanceId, model }, + title: member.label, + now: createdAt, + createdBy: "agent", + creationSource: "provider", + }), + }); + } + if (previous === undefined || previous.prompt !== prompt) { + yield* emitSubagentConversationMessage({ + nativeItemId: `${memberKey}:prompt`, + threadId: childThreadId, + rootNodeId: childRootNodeId, + role: "user", + text: prompt, + ordinal: 100, + now, + }); + } + if (restarted && !settled) { + yield* emitSubagentConversationMessage({ + nativeItemId: `${memberKey}:answer:200`, + threadId: childThreadId, + rootNodeId: childRootNodeId, + role: "assistant", + text: "Retry in progress.", + ordinal: 200, + now, + }); + } + + const task = { + id: nodeId, + threadId: parentThread.id, + runId: null, + parentNodeId: input.coordinator.childRootNodeId, + origin: "provider_native" as const, + createdBy: "agent" as const, + driver: CLAUDE_PROVIDER, + providerInstanceId: parentThread.providerInstanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: { + driver: CLAUDE_PROVIDER, + nativeId: memberKey, + strength: "strong" as const, + }, + prompt, + title: member.label, + model, + status, + result: member.result ?? null, + startedAt, + completedAt, + updatedAt: now, + } satisfies OrchestrationV2Subagent; + + const nodeBase = { + runId: null, + status, + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: task.nativeTaskRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt, + }; + if (changed) { + for (const node of [ + { + ...nodeBase, + id: nodeId, + threadId: parentThread.id, + parentNodeId: input.coordinator.childRootNodeId, + rootNodeId: input.coordinator.childRootNodeId, + kind: "subagent" as const, + }, + { + ...nodeBase, + id: childRootNodeId, + threadId: childThreadId, + parentNodeId: null, + rootNodeId: childRootNodeId, + kind: "root_turn" as const, + }, + ]) { + yield* emitProviderEvent({ type: "node.updated", driver: CLAUDE_PROVIDER, node }); + } + yield* emitProviderEvent({ + type: "subagent.updated", + driver: CLAUDE_PROVIDER, + subagent: task, + }); + } + + let transcriptAnswerDigest = restarted ? undefined : previous?.transcriptAnswerDigest; + let transcript: ReadonlyArray = []; + if (settled) { + // The member's own transcript, when the run left one: a missing or + // unreadable file is expected (a run predating run-handle capture, + // a member that never started) and falls back to the excerpt. + transcript = readTranscript + ? yield* readWorkflowAgentAnswers({ + transcriptDir, + agentId, + ...(adapterOptions.environment.CLAUDE_CONFIG_DIR === undefined + ? {} + : { + configDir: path.resolve( + session.cwd, + adapterOptions.environment.CLAUDE_CONFIG_DIR, + ), + }), + }).pipe(Effect.orElseSucceed((): ReadonlyArray => [])) + : []; + const transcriptAnswer = transcript.length > 0 ? transcript.join("\n\n") : undefined; + const answerDigest = + transcriptAnswer === undefined + ? undefined + : yield* sha256Hex(crypto, transcriptAnswer); + // A continuation may settle or resume this workflow during the read. + if ((yield* Ref.get(sessionSubagentsByTaskId)).get(taskId) !== input.coordinator) + return; + const previousDigest = transcriptAnswerDigest; + if (answerDigest !== undefined) transcriptAnswerDigest = answerDigest; + const answer = + transcriptAnswer ?? + (member.result !== undefined && changed && previousDigest === undefined + ? `Partial answer from workflow progress:\n\n${member.result}` + : attempt > 1 && changed && previousDigest === undefined + ? "No answer from this workflow attempt." + : undefined); + if ( + answer !== undefined && + (transcriptAnswer === undefined || restarted || answerDigest !== previousDigest) + ) { + yield* emitSubagentConversationMessage({ + nativeItemId: `${memberKey}:answer:200`, + threadId: childThreadId, + rootNodeId: childRootNodeId, + role: "assistant", + text: answer, + ordinal: 200, + now, + }); + } + } + + yield* Ref.update(workflowMemberStates, (current) => + new Map(current).set(memberKey, { + status, + attempt, + result: member.result, + label: member.label, + prompt, + model, + transcriptAnswerDigest, + transcriptReadAttempts: + (restarted ? 0 : (previous?.transcriptReadAttempts ?? 0)) + + (readTranscript ? 1 : 0), + finalTranscriptRead: + (!restarted && previous?.finalTranscriptRead === true) || + (readTranscript && + transcript.length > 0 && + input.coordinator.task.status !== "running"), + createdAt, + startedAt, + completedAt, + }), + ); + } + }); + const updateClaudeSubagentNode = Effect.fnUntraced(function* (input: { readonly context: ActiveClaudeTurnContext; readonly taskId: string; @@ -4397,24 +4802,39 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly effort?: string; readonly owner?: ActiveClaudeSubagent; readonly progress?: string; + /** Raw SDK frame, folded into the coordinator's workflow snapshot. */ + readonly workflowFrame?: unknown; + readonly workflowName?: string; + readonly workflowRunHandles?: OrchestrationV2WorkflowRunHandles; readonly result?: string; readonly status: Extract< OrchestrationV2ExecutionNode["status"], "running" | "completed" | "failed" | "cancelled" >; readonly reopen?: boolean; + readonly onlyIfRunningInRun?: RunId | null; }) { // The session registry lets a wake-replay turn (fresh context maps) // hydrate a subagent that was created by an earlier, settled turn. + const registeredSubagent = (yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId); + // Terminal cleanup must include snapshots received after the turn settled. const existingSubagent = - input.context.subagentsByTaskId.get(input.taskId) ?? - (input.toolUseId === undefined - ? undefined - : input.context.subagentsByToolUseId.get(input.toolUseId)) ?? - (yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId); + input.onlyIfRunningInRun !== undefined + ? registeredSubagent + : (input.context.subagentsByTaskId.get(input.taskId) ?? + (input.toolUseId === undefined + ? undefined + : input.context.subagentsByToolUseId.get(input.toolUseId)) ?? + registeredSubagent); if (existingSubagent === undefined && input.status !== "running") { return; } + if ( + input.onlyIfRunningInRun !== undefined && + (existingSubagent?.task.status !== "running" || + existingSubagent.task.runId !== input.onlyIfRunningInRun) + ) + return; // Status is monotone with one exception: task_started for a known // task id is an authoritative CLI lifecycle event (SendMessage to a // completed subagent resumes it and re-emits task_started with the @@ -4433,23 +4853,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { return; } - // A call the subagent left open never returns once it ends. It ends - // first, while the run that owns the child thread still takes its - // events. - if (existingSubagent !== undefined && input.status !== "running") { - const openCalls = [...subagentToolCalls.values()].filter( - (toolCall) => toolCall.threadId === existingSubagent.childThreadId, - ); - yield* endToolCalls({ - context: input.context, - toolCalls: openCalls, - status: input.status === "cancelled" ? "interrupted" : "failed", - completedAt: yield* DateTime.now, - }); - for (const toolCall of openCalls) { - subagentToolCalls.delete(toolCall.nativeItemId); - } - } // A task_started under a tool call other than the current run's is // a resume: SendMessage re-emits task_started for the same task id // under its own tool_use_id, with the sent message as the prompt. @@ -4493,6 +4896,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existingSubagent.task, ) : existingSubagent.task; + // Workflow telemetry is cumulative: fold this frame into whatever the + // coordinator already carried, so a usage-only frame cannot blank the + // member roster the user is watching. + const workflow = mergeClaudeWorkflowProgress({ + previous: priorTask?.workflow, + ...(input.workflowName === undefined ? {} : { name: input.workflowName }), + ...(input.workflowRunHandles === undefined + ? {} + : { runHandles: input.workflowRunHandles }), + message: input.workflowFrame, + }); + const workflowWithThreads = + workflow === undefined + ? undefined + : withWorkflowMemberThreadIds(workflow, childThreadId); const effort = existingSubagent === undefined ? (input.effort ?? @@ -4552,6 +4970,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } : {}), ...(input.progress === undefined ? {} : { progress: input.progress }), + ...(workflowWithThreads === undefined ? {} : { workflow: workflowWithThreads }), ...(input.result === undefined ? {} : { result: input.result }), ...(isReopen ? { startedAt: now } : {}), completedAt: input.status === "running" ? null : (priorTask?.completedAt ?? now), @@ -4568,6 +4987,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existingSubagent === undefined ? (input.toolUseId ?? null) : (resumeToolUseId ?? existingSubagent.runToolUseId), + childThread: existingSubagent?.childThread ?? null, effort, nextChildItemOrdinal: existingSubagent?.nextChildItemOrdinal ?? 100, resultItemOrdinal: existingSubagent?.resultItemOrdinal ?? null, @@ -4579,16 +4999,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( lastAssistantMessageId: input.reopen === true ? null : (existingSubagent?.lastAssistantMessageId ?? null), } satisfies ActiveClaudeSubagent; - input.context.subagentsByTaskId.set(input.taskId, subagent); - if (input.toolUseId !== undefined) { - input.context.subagentsByToolUseId.set(input.toolUseId, subagent); - const toolUseId = input.toolUseId; - yield* Ref.update(sessionSubagentTaskIdsByToolUseId, (current) => - current.get(toolUseId) === input.taskId - ? current - : new Map(current).set(toolUseId, input.taskId), - ); - } // The same terminal protection, applied atomically: a concurrent // fiber (live stream vs continuation drain) may have terminalized // the registry entry after this update's lookup read it. A resume @@ -4596,20 +5006,51 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // is still the terminal generation the lookup resolved; if a // concurrent fiber installed a newer terminal entry meanwhile, the // re-open must not clobber its result. - yield* Ref.update(sessionSubagentsByTaskId, (current) => { + const accepted = yield* Ref.modify(sessionSubagentsByTaskId, (current) => { const registered = current.get(input.taskId); if ( - registered !== undefined && - registered.task.status !== "running" && - input.status === "running" && - !(isReopen && registered === existingSubagent) + (input.onlyIfRunningInRun !== undefined && + (registered?.task.status !== "running" || + registered.task.runId !== input.onlyIfRunningInRun)) || + (registered !== undefined && + registered.task.status !== "running" && + input.status === "running" && + !(isReopen && registered === existingSubagent)) ) { - return current; + return [false, current] as const; } - return new Map(current).set(input.taskId, subagent); + return [true, new Map(current).set(input.taskId, subagent)] as const; }); + if (!accepted) return; + // A call the subagent left open never returns once it ends. It ends + // first, while the run that owns the child thread still takes its + // events. + if (existingSubagent !== undefined && input.status !== "running") { + const openCalls = [...subagentToolCalls.values()].filter( + (toolCall) => toolCall.threadId === existingSubagent.childThreadId, + ); + yield* endToolCalls({ + context: input.context, + toolCalls: openCalls, + status: input.status === "cancelled" ? "interrupted" : "failed", + completedAt: yield* DateTime.now, + }); + for (const toolCall of openCalls) { + subagentToolCalls.delete(toolCall.nativeItemId); + } + } + input.context.subagentsByTaskId.set(input.taskId, subagent); + if (input.toolUseId !== undefined) { + input.context.subagentsByToolUseId.set(input.toolUseId, subagent); + const toolUseId = input.toolUseId; + yield* Ref.update(sessionSubagentTaskIdsByToolUseId, (current) => + current.get(toolUseId) === input.taskId + ? current + : new Map(current).set(toolUseId, input.taskId), + ); + } - if (existingSubagent === undefined) { + if (subagent.childThread === null) { const childThread = makeSubagentChildThread({ parentThread: input.context.input.appThread, childThreadId, @@ -4620,18 +5061,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( title: subagentThreadTitle({ parentTitle: input.context.input.appThread.title, prompt: task.prompt, - title: task.title, + title: + workflowWithThreads === undefined + ? task.title + : `Workflow · ${workflowWithThreads.name ?? task.title ?? "Untitled"}`, ordinal: input.context.subagentsByTaskId.size, }), now, createdBy: "agent", creationSource: "provider", }); - yield* emitProviderEvent({ - type: "app_thread.created", - driver: CLAUDE_PROVIDER, - appThread: childThread, - }); + if (existingSubagent === undefined) { + yield* emitProviderEvent({ + type: "app_thread.created", + driver: CLAUDE_PROVIDER, + appThread: childThread, + }); + } + subagent.childThread = childThread; } if (lifecycleChanged) { @@ -4695,46 +5142,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? null : `${nativeItemId}:prompt:${resumeToolUseId}`; if (promptNativeItemId !== null) { - const promptArtifacts = makeSubagentConversationArtifacts({ + yield* emitSubagentConversationMessage({ + nativeItemId: promptNativeItemId, senderThreadId: input.context.input.threadId, - messageId: idAllocator.derive.messageFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: promptNativeItemId, - }), - turnItemId: idAllocator.derive.turnItemFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: promptNativeItemId, - }), threadId: childThreadId, rootNodeId: childRootNodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: { - driver: CLAUDE_PROVIDER, - nativeId: promptNativeItemId, - strength: "strong", - }, role: "user", text: task.prompt, ordinal: existingSubagent === undefined ? 100 : ++subagent.nextChildItemOrdinal, now, }); - yield* emitProviderEvent({ - type: "message.updated", - driver: CLAUDE_PROVIDER, - message: promptArtifacts.message, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CLAUDE_PROVIDER, - turnItem: promptArtifacts.turnItem, - }); } yield* emitProviderEvent({ type: "subagent.updated", driver: CLAUDE_PROVIDER, subagent: task, }); + if (workflowWithThreads !== undefined) { + yield* projectClaudeWorkflowMembers({ + coordinator: subagent, + workflow: workflowWithThreads, + }); + if ((yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId) !== subagent) return; + } yield* emitProviderEvent({ type: "turn_item.updated", driver: CLAUDE_PROVIDER, @@ -4782,39 +5212,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const resultNativeItemId = `${nativeItemId}:result`; const resultItemOrdinal = subagent.resultItemOrdinal ?? ++subagent.nextChildItemOrdinal; subagent.resultItemOrdinal = resultItemOrdinal; - const resultArtifacts = makeSubagentConversationArtifacts({ - messageId: idAllocator.derive.messageFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: resultNativeItemId, - }), - turnItemId: idAllocator.derive.turnItemFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: resultNativeItemId, - }), + yield* emitSubagentConversationMessage({ + nativeItemId: resultNativeItemId, threadId: childThreadId, rootNodeId: childRootNodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: { - driver: CLAUDE_PROVIDER, - nativeId: resultNativeItemId, - strength: "strong", - }, role: "assistant", text: input.result, ordinal: resultItemOrdinal, now, }); - yield* emitProviderEvent({ - type: "message.updated", - driver: CLAUDE_PROVIDER, - message: resultArtifacts.message, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CLAUDE_PROVIDER, - turnItem: resultArtifacts.turnItem, - }); } }); @@ -5281,6 +5687,33 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly result?: SDKResultMessage; }) { yield* reasoningDeltas.flushTurn(input.context.nativeTurnId); + if (input.status !== "completed") { + // The run stops following its subagents once its root turn fails or is + // interrupted, so a workflow it follows is stopped rather than left + // running where Lineage can no longer see or stop it. + const liveQuery = yield* Ref.get(queryContext); + for (const [taskId, subagent] of yield* Ref.get(sessionSubagentsByTaskId)) { + if ( + subagent.task.workflow === undefined || + subagent.task.status !== "running" || + subagent.task.runId !== input.context.input.runId + ) + continue; + if (liveQuery !== null && !liveQuery.stopping) { + // Best effort and off the terminal path: an unanswered stop must + // not keep the turn from ending. + yield* liveQuery.query + .stopTask(taskId) + .pipe(Effect.timeout("15 seconds"), Effect.ignore, Effect.forkIn(sessionScope)); + } + yield* updateClaudeSubagentNode({ + context: input.context, + taskId, + status: input.status === "failed" ? "failed" : "cancelled", + onlyIfRunningInRun: input.context.input.runId, + }); + } + } // A subagent still running in the background keeps its open calls: // their results arrive after this turn. One left by an earlier CLI // process is gone and never reports them. @@ -5288,14 +5721,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const runningChildThreadIds = new Set( [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].flatMap((subagent) => subagent.task.status === "running" && + (input.status === "completed" || + (liveQuery !== null && subagent.task.runId !== input.context.input.runId)) && liveQuery?.subagentsFromEarlierProcesses.has(subagent) !== true ? [subagent.childThreadId] : [], ), ); const endingSubagentCalls = [...subagentToolCalls.values()].filter( - (toolCall) => - input.status !== "completed" || !runningChildThreadIds.has(toolCall.threadId), + (toolCall) => !runningChildThreadIds.has(toolCall.threadId), ); yield* endToolCalls({ context: input.context, @@ -5570,27 +6004,72 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const finalizeActiveTurnAfterQueryExit = Effect.fnUntraced(function* ( + closedQuery: ClaudeLiveQueryContext, cause?: Cause.Cause, ) { - const context = yield* Ref.get(activeTurn); - if (context === null) { - return; - } - const completedAt = yield* DateTime.now; - const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId); - yield* finalizeActiveTurn({ - context, - status: interrupted ? "interrupted" : "failed", - completedAt, - ...(interrupted - ? {} - : { - failure: makeProviderFailure({ - cause: cause === undefined ? undefined : Cause.squash(cause), - class: "transport_error", + const current = yield* Ref.get(activeTurn); + const context = + current?.nativeThreadId === closedQuery.nativeThreadId ? current : closedQuery.lastTurn; + if (context === null) return; + const interrupted = + closedQuery.stopping || (yield* Ref.get(interruptedTurns)).has(context.providerTurnId); + const subagents = yield* Ref.get(sessionSubagentsByTaskId); + const buffered = (yield* Ref.get(wakeBuffers)).get(closedQuery.nativeThreadId)?.messages; + if (current === context) { + yield* finalizeActiveTurn({ + context, + status: interrupted ? "interrupted" : "failed", + completedAt: yield* DateTime.now, + ...(interrupted + ? {} + : { + failure: makeProviderFailure({ + cause: cause === undefined ? undefined : Cause.squash(cause), + class: "transport_error", + }), }), - }), - }); + }); + } else { + yield* resetBackgroundTaskStateForNativeThreadProcess(closedQuery.nativeThreadId); + } + // A workflow can outlive its launching turn, but not its CLI process. + // Apply buffered outcomes before Stop can discard their wake buffer. + // Capture ownership before finalization, which may yield to a new process. + for (const [taskId, subagent] of subagents) { + if ( + subagent.task.workflow === undefined || + subagent.task.status !== "running" || + closedQuery.subagentsFromEarlierProcesses.has(subagent) + ) + continue; + const latestLifecycle = buffered?.findLast( + ( + message, + ): message is Extract< + SDKMessage, + { readonly subtype: "task_started" | "task_notification" } + > => + message.type === "system" && + (message.subtype === "task_started" || message.subtype === "task_notification") && + message.task_id === taskId, + ); + // A later start resumes the same task and invalidates its older outcome. + const completion = + latestLifecycle?.subtype === "task_notification" ? latestLifecycle : undefined; + yield* updateClaudeSubagentNode({ + context, + taskId, + status: completion + ? claudeTaskOutcome(completion.status) + : interrupted + ? "cancelled" + : "failed", + ...(completion === undefined + ? {} + : { workflowFrame: completion, result: completion.summary }), + onlyIfRunningInRun: subagent.task.runId, + }); + } yield* Ref.update(interruptedTurns, (current) => { const next = new Set(current); next.delete(context.providerTurnId); @@ -5606,6 +6085,64 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } }); + /** + * Refreshes a background workflow coordinator's entity between turns. + * Deliberately narrow: only a registered coordinator that this frame + * carries a snapshot for is touched, and only `subagent.updated` is + * emitted, because node and turn-item ids need a turn context this path + * does not have. Status is left alone — `task_notification` owns the + * lifecycle, and it has its own turn-aware path. + * These updates only reach the projection while the launching run is still + * ingesting, which holds because a running coordinator keeps that run open. + */ + const applyWorkflowProgressWithoutTurn = Effect.fnUntraced(function* (message: SDKMessage) { + if (message.type !== "system" || message.subtype !== "task_progress") return; + if (!hasClaudeWorkflowSnapshot(message)) return; + const taskId = message.task_id; + const registered = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId); + if ( + registered === undefined || + registered.task.workflow === undefined || + registered.task.status !== "running" + ) + return; + const workflow = mergeClaudeWorkflowProgress({ + previous: registered.task.workflow, + message, + }); + if (workflow === undefined) return; + const now = yield* DateTime.now; + const progress = text(message.description); + const workflowWithThreads = withWorkflowMemberThreadIds( + workflow, + registered.childThreadId, + ); + const task = { + ...registered.task, + ...(progress === undefined ? {} : { progress }), + workflow: workflowWithThreads, + updatedAt: now, + } satisfies OrchestrationV2Subagent; + const coordinator = { ...registered, task }; + const accepted = yield* Ref.modify(sessionSubagentsByTaskId, (current) => { + const latest = current.get(taskId); + if (latest !== registered || latest.task.status !== "running") { + return [false, current] as const; + } + return [true, new Map(current).set(taskId, coordinator)] as const; + }); + if (!accepted) return; + yield* emitProviderEvent({ + type: "subagent.updated", + driver: CLAUDE_PROVIDER, + subagent: task, + }); + yield* projectClaudeWorkflowMembers({ + coordinator, + workflow: workflowWithThreads, + }); + }); + const bufferWakeMessage = Effect.fnUntraced(function* (wakeInput: { readonly nativeThreadId: string; readonly message: SDKMessage; @@ -6131,6 +6668,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (yield* Ref.get(activeTurn)) ?? (yield* settledTurnForSubagentFrame(liveQuery.nativeThreadId, message)); if (context === null) { + // A workflow is normally launched in the background, so most of its + // run happens after the turn that started it has settled. Nothing + // here can build turn items or nodes without a turn context, but the + // coordinator entity is what Lineage renders, so refresh it from the + // session registry and a live run keeps reporting its phases, members + // and usage instead of freezing at turn end. + yield* applyWorkflowProgressWithoutTurn(message); // task_notification must buffer wake evidence while still tracked // on the roster; clearing first would drop the wake pin. if (message.type === "system" && message.subtype === "task_notification") { @@ -6523,6 +7067,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolUseId: message.tool_use_id, title: message.description, }); + // A workflow coordinator is marked as one the moment it starts, so + // Lineage can expand it before the first member spawns rather than + // after the first progress snapshot. + const isWorkflow = + claudeTaskTypeFromSdkMessage(message) === CLAUDE_WORKFLOW_TASK_TYPE; + // `meta.name` from the workflow script; only workflow tasks carry it. + const workflowName = isWorkflow ? text(field(message, "workflow_name")) : undefined; + yield* updateClaudeSubagentNode({ context, taskId: message.task_id, @@ -6531,10 +7083,37 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...(model === undefined ? {} : { model }), ...(launch?.effort === undefined ? {} : { effort: launch.effort }), ...(owner === undefined ? {} : { owner }), + ...(isWorkflow + ? { + workflowFrame: message, + ...(workflowName === undefined ? {} : { workflowName }), + } + : {}), + title: message.description, status: "running", reopen: true, }); + // A resume supersedes earlier buffered starts and outcomes for this task. + // Replaying a start preserves the completion buffered after it. + yield* Ref.update(wakeBuffers, (current) => { + const entry = current.get(liveQuery.nativeThreadId); + if (entry === undefined) return current; + const replayIndex = entry.messages.indexOf(message); + const messages = entry.messages.filter( + (buffered, index) => + !( + (replayIndex === -1 || index < replayIndex) && + buffered.type === "system" && + (buffered.subtype === "task_started" || + buffered.subtype === "task_notification") && + buffered.task_id === message.task_id + ), + ); + return messages.length === entry.messages.length + ? current + : new Map(current).set(liveQuery.nativeThreadId, { ...entry, messages }); + }); } } @@ -6544,8 +7123,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( liveQuery.nativeThreadId, message.task_id, ); + // A workflow frame is worth an update even with an empty description: + // it carries the phase plan, the member roster and the run's usage. + const carriesWorkflowTelemetry = hasClaudeWorkflowSnapshot(message); + const isWorkflowCoordinator = + (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id)?.task.workflow !== + undefined; if ( - progress.length > 0 && + (carriesWorkflowTelemetry || (progress.length > 0 && !isWorkflowCoordinator)) && !context.ignoredTaskIds.has(message.task_id) && !isBackgroundTask ) { @@ -6553,7 +7138,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( context, taskId: message.task_id, ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), - progress, + ...(progress.length === 0 ? {} : { progress }), + workflowFrame: message, status: "running", }); } @@ -6607,6 +7193,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( context, taskId: message.task_id, ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), + // Terminal usage totals arrive here, after the last snapshot. + workflowFrame: message, result: message.summary, status: message.status === "completed" @@ -6673,10 +7261,25 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } for (const { toolResult, output } of claudeToolResultEntriesFromMessage(message)) { - // Session scope: while the root is idle, this context is the turn - // that settled last, which never saw a subagent an earlier turn - // launched (a nested one whose Agent call returns now). const subagent = yield* resolveSubagentByToolUseId(context, toolResult.tool_use_id); + const runHandles = parseClaudeWorkflowRunHandles(claudeNativeToolOutputValue(output)); + // Independent of the branches below: unlike the Agent tool, a + // Workflow tool_use is an ordinary tool call and so lands in + // toolCalls, which skips the subagent branch entirely. Its launch + // acknowledgement is the only carrier of the run's filesystem + // handles, so harvest them wherever the result arrives. + if (subagent !== undefined && runHandles !== undefined) { + yield* updateClaudeSubagentNode({ + context, + taskId: subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id), + toolUseId: toolResult.tool_use_id, + workflowRunHandles: runHandles, + // The acknowledgement arrives at launch, so the coordinator + // is still running; a late one is dropped by the same + // terminal protection that guards every other update. + status: "running", + }); + } // A resume task_started reuses the resuming tool call's // tool_use_id (e.g. SendMessage), whose tool_result only // acknowledges delivery. Only the Agent launch's tool_result may @@ -6689,7 +7292,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // A background Agent launch resolves its tool_use immediately // with an async-launch ACK while the task keeps running; only // the eventual task_notification terminalizes the subagent. - if (isClaudeSubagentAsyncLaunchAck(output)) { + if ( + !isClaudeToolResultError(toolResult) && + (runHandles !== undefined || isClaudeSubagentAsyncLaunchAck(output)) + ) { continue; } const result = claudeSubagentResultText(output); @@ -6838,39 +7444,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? `${subagent.lastAssistantText ?? ""}\n${assistantText.text}` : assistantText.text; subagent.lastAssistantMessageId = nativeMessageId; - const artifacts = makeSubagentConversationArtifacts({ - messageId: idAllocator.derive.messageFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: assistantText.nativeItemId, - }), - turnItemId: idAllocator.derive.turnItemFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: assistantText.nativeItemId, - }), + yield* emitSubagentConversationMessage({ + nativeItemId: assistantText.nativeItemId, threadId: subagent.childThreadId, rootNodeId: subagent.childRootNodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: { - driver: CLAUDE_PROVIDER, - nativeId: assistantText.nativeItemId, - strength: "strong", - }, role: "assistant", text: assistantText.text, ordinal: ++subagent.nextChildItemOrdinal, now, }); - yield* emitProviderEvent({ - type: "message.updated", - driver: CLAUDE_PROVIDER, - message: artifacts.message, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CLAUDE_PROVIDER, - turnItem: artifacts.turnItem, - }); return; } if (assistantText !== null && assistantText.text.length > 0) { @@ -7726,6 +8308,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( queryPolicyKey, selectionKey: compiledSelection.queryIdentity, closed, + lastTurn: null, promptEchoMode: "unknown", openedPermissionMode: queryOptions.permissionMode, permissionMode: queryOptions.permissionMode, @@ -7738,16 +8321,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }; yield* Ref.set(queryContext, context); yield* querySession.messages.pipe( - Stream.runForEach((message) => { - if ( - message.type === "system" && - (message.subtype === "init" || message.subtype === "status") && - message.permissionMode !== undefined - ) { - context.permissionMode = message.permissionMode; - } - return handleSdkMessage({ query: querySession, message }); - }), + Stream.runForEach((message) => + sdkMessageLock.withPermits(1)( + Effect.suspend(() => { + if ( + message.type === "system" && + (message.subtype === "init" || message.subtype === "status") && + message.permissionMode !== undefined + ) { + context.permissionMode = message.permissionMode; + } + return handleSdkMessage({ query: querySession, message }); + }), + ), + ), Effect.exit, Effect.flatMap( Effect.fnUntraced(function* (exit: ClaudeQueryStreamExit) { @@ -7767,6 +8354,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); if (ownsLiveQuery) { yield* finalizeActiveTurnAfterQueryExit( + context, exit._tag === "Failure" ? exit.cause : undefined, ); yield* endSubagentCallsOfClosedQuery( @@ -7885,17 +8473,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( uuid: promptUuid, }); const querySession = yield* openQuery(turnInput, nativeThreadId); - yield* Ref.set(activeTurn, context); - yield* emitProviderEvent({ - type: "provider_turn.updated", - driver: CLAUDE_PROVIDER, - providerTurn: providerTurnPayload({ - context, - status: "running", - completedAt: null, - }), + const attachTurn = Effect.gen(function* () { + querySession.lastTurn = context; + yield* Ref.set(activeTurn, context); + yield* emitProviderEvent({ + type: "provider_turn.updated", + driver: CLAUDE_PROVIDER, + providerTurn: providerTurnPayload({ + context, + status: "running", + completedAt: null, + }), + }); }); if (userMessage !== null) { + yield* attachTurn; // A user turn that races a wake leaves the buffer alone: the // continuation run the worker queued behind this run drains it // afterwards with correct attribution. @@ -7904,64 +8496,95 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* querySession.query.offer(userMessage); return; } - const drained = yield* Ref.modify(wakeBuffers, (current) => { - const entry = current.get(nativeThreadId); - if (entry === undefined) { - return [[] as ReadonlyArray, current] as const; - } - const updated = new Map(current); - updated.delete(nativeThreadId); - return [entry.messages, updated] as const; - }); - yield* Ref.update(requestedContinuations, (current) => { - const updated = new Set(current); - updated.delete(nativeThreadId); - return updated; - }); - if (drained.length === 0) { - // Spurious continuation (buffer already lost with a recycled - // session, or a duplicate request): settle immediately instead - // of leaving a run waiting on a prompt that was never sent. - const completedAt = yield* DateTime.now; - yield* finalizeActiveTurn({ context, status: "completed", completedAt }); - return; - } - // Replay any result message last: a result finalizes the turn, and - // replaying it before the rest would drop them back into the wake - // buffer and request another continuation. - const resultMessages = drained.filter((entry) => entry.type === "result"); - const opaqueReplayTombstones = taskIdSetForNativeThread( - yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread), - nativeThreadId, + // Transcript reads suspend replay. Hold live SDK frames until the whole + // buffer has been applied so starts, completions and the root result stay ordered. + yield* sdkMessageLock.withPermits(1)( + Effect.gen(function* () { + if ( + (yield* Ref.get(queryContext))?.query !== querySession.query || + querySession.stopping + ) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Claude provider query ended before the continuation could start.", + }); + } + // Live frames can finish while this continuation waits for the permit. + yield* attachTurn; + // Exit and interrupt cleanup can run during transcript reads. + // Keep pending outcomes visible until replay applies them. + const drained = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? []; + yield* Ref.update(requestedContinuations, (current) => { + const updated = new Set(current); + updated.delete(nativeThreadId); + return updated; + }); + if (drained.length === 0) { + // Spurious continuation (buffer already lost with a recycled + // session, or a duplicate request): settle immediately instead + // of leaving a run waiting on a prompt that was never sent. + const completedAt = yield* DateTime.now; + yield* finalizeActiveTurn({ context, status: "completed", completedAt }); + return; + } + // Replay any result message last: a result finalizes the turn, and + // replaying it before the rest would drop them back into the wake + // buffer and request another continuation. + const resultMessages = drained.filter((entry) => entry.type === "result"); + const opaqueReplayTombstones = taskIdSetForNativeThread( + yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread), + nativeThreadId, + ); + const hasOpaqueTaskNotification = drained.some( + (entry) => + entry.type === "system" && + entry.subtype === "task_notification" && + opaqueReplayTombstones.has(entry.task_id), + ); + for (const entry of drained) { + if ( + !(yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages.includes(entry) + ) { + continue; + } + if (entry.type !== "result") { + yield* handleSdkMessage({ query: querySession.query, message: entry }); + } + yield* Ref.update(wakeBuffers, (current) => { + const buffered = current.get(nativeThreadId); + if (buffered === undefined) return current; + const index = buffered.messages.indexOf(entry); + if (index === -1) return current; + // An idle handler may requeue this same frame for the next continuation. + const messages = [ + ...buffered.messages.slice(0, index), + ...buffered.messages.slice(index + 1), + ]; + const next = new Map(current); + if (messages.length === 0) next.delete(nativeThreadId); + else next.set(nativeThreadId, { ...buffered, messages }); + return next; + }); + } + const lastResult = resultMessages.at(-1); + if (lastResult !== undefined) { + yield* handleSdkMessage({ query: querySession.query, message: lastResult }); + return; + } + // A drained `init` means Claude began the wake turn, so its output + // may still be on the way: stay open for it. + const hasNativeWakeFrame = drained.some( + (entry) => + entry.type === "user" || + entry.type === "assistant" || + isClaudeTurnStartMessage(entry), + ); + if (hasOpaqueTaskNotification && !hasNativeWakeFrame) { + const completedAt = yield* DateTime.now; + yield* finalizeActiveTurn({ context, status: "completed", completedAt }); + } + }), ); - const hasOpaqueTaskNotification = drained.some( - (entry) => - entry.type === "system" && - entry.subtype === "task_notification" && - opaqueReplayTombstones.has(entry.task_id), - ); - for (const entry of drained) { - if (entry.type !== "result") { - yield* handleSdkMessage({ query: querySession.query, message: entry }); - } - } - const lastResult = resultMessages.at(-1); - if (lastResult !== undefined) { - yield* handleSdkMessage({ query: querySession.query, message: lastResult }); - return; - } - // A drained `init` means Claude began the wake turn, so its output - // may still be on the way: stay open for it. - const hasNativeWakeFrame = drained.some( - (entry) => - entry.type === "user" || - entry.type === "assistant" || - isClaudeTurnStartMessage(entry), - ); - if (hasOpaqueTaskNotification && !hasNativeWakeFrame) { - const completedAt = yield* DateTime.now; - yield* finalizeActiveTurn({ context, status: "completed", completedAt }); - } }, (effect, turnInput) => effect.pipe( @@ -8042,14 +8665,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( providerThreadId: turnInput.providerThread.id, providerTurnId: turnInput.providerTurnId, }); - yield* Ref.update(queryContext, (current) => - current?.query === existing.query ? null : current, + const ownedLiveQuery = yield* Ref.modify(queryContext, (current) => + current?.query === existing.query ? [true, null] : [false, current], ); - yield* finalizeActiveTurn({ - context: currentTurn, - status: "interrupted", - completedAt, - }); + if (ownedLiveQuery) { + existing.stopping = true; + yield* finalizeActiveTurnAfterQueryExit(existing); + yield* endSubagentCallsOfClosedQuery(existing.nativeThreadId, "interrupted"); + } else { + // The stream may already be finalizing while another process starts. + yield* finalizeActiveTurn({ + context: currentTurn, + status: "interrupted", + completedAt, + }); + } yield* Deferred.succeed(existing.closed, undefined); }, (effect, turnInput) => @@ -8155,6 +8785,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( current?.query === existing.query ? [true, null] : [false, current], ); if (ownedLiveQuery) { + yield* finalizeActiveTurnAfterQueryExit(existing); yield* endSubagentCallsOfClosedQuery(nativeThreadId, "interrupted"); } yield* Deferred.succeed(existing.closed, undefined); @@ -8266,6 +8897,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message: { ...turnInput.message, text: "/compact" }, }), steerTurn, + stopTask: ({ providerThread, taskId }) => + Effect.gen(function* () { + const liveQuery = yield* Ref.get(queryContext); + const task = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId); + if ( + liveQuery === null || + liveQuery.stopping || + liveQuery.nativeThreadId !== providerThread.nativeThreadRef?.nativeId || + task === undefined || + task.task.threadId !== providerThread.appThreadId || + task.task.workflow === undefined || + task.task.status !== "running" || + liveQuery.subagentsFromEarlierProcesses.has(task) + ) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "No running workflow task belongs to this provider thread", + }); + } + yield* liveQuery.query.stopTask(taskId).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Workflow stop request failed", + cause, + }), + ), + ); + }), interruptTurn, stopSubagent: Effect.fn("ClaudeAdapterV2.stopSubagent")(function* (input) { const existing = yield* Ref.get(queryContext); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b782ad676423c68a55a1cd781af37759307d2e3f --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -0,0 +1,80 @@ +import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; + +import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers"; +import { + CLAUDE_AGENT_SDK_QUERY_PROTOCOL, + ClaudeAgentSdkQueryRunner, + layerQueryRunner, +} from "./ClaudeAdapterV2.ts"; + +vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); + +it.effect("logs successful task stops for replay and omits failed stops", () => + Effect.gen(function* () { + const writes: unknown[] = []; + const stopTask = vi.fn(async (_taskId: string) => {}); + const query = vi.spyOn(ClaudeSdk, "query").mockReturnValue({ + stopTask, + } as unknown as ClaudeSdk.Query); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + const threadId = ThreadId.make("thread-stop"); + const providerSessionId = ProviderSessionId.make("session-stop"); + yield* Effect.gen(function* () { + const runner = yield* ClaudeAgentSdkQueryRunner; + const session = yield* runner.open({ + threadId, + providerSessionId, + options: { + sessionId: "native-session-stop", + model: "claude-sonnet-4-6", + tools: [], + permissionMode: "default", + }, + }); + writes.length = 0; + yield* session.stopTask("workflow-task"); + assert.deepEqual(stopTask.mock.calls, [["workflow-task"]]); + assert.deepEqual(writes, [ + { + provider: "claudeAgent", + protocol: CLAUDE_AGENT_SDK_QUERY_PROTOCOL, + kind: "protocol", + providerSessionId, + event: { + direction: "outgoing", + stage: "decoded", + payload: { type: "query.stop_task", taskId: "workflow-task" }, + }, + }, + ]); + stopTask.mockRejectedValueOnce(new Error("Stop failed")); + assert.isTrue(Exit.isFailure(yield* Effect.exit(session.stopTask("failed-task")))); + assert.equal(writes.length, 1); + }).pipe( + Effect.provide( + layerQueryRunner.pipe( + Layer.provide( + Layer.succeed(ProviderEventLoggers, { + native: { + filePath: "/tmp/events.log", + write: (event) => + Effect.sync(() => { + writes.push(event); + }), + close: () => Effect.void, + }, + canonical: undefined, + }), + ), + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 398337b22e2c1658f7cd079b9f89aeba5fc25b56..38e64af02e1213c33e2da2c987e7af319b8ddbe7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -4975,17 +4975,17 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( driver: CODEX_PROVIDER, node: artifacts.node, }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem: artifacts.turnItem, + }); yield* emitProviderEvent({ type: "runtime_request.updated", driver: CODEX_PROVIDER, threadId: artifacts.node.threadId, runtimeRequest: artifacts.request, }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, - }); yield* Ref.update(finalAnswerItemIdsByTurn, (current) => { const ids = current.get(payload.turnId); if (!ids?.has(payload.item.id)) return current; @@ -5082,17 +5082,17 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( driver: CODEX_PROVIDER, node: artifacts.node, }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem: artifacts.turnItem, + }); yield* emitProviderEvent({ type: "runtime_request.updated", driver: CODEX_PROVIDER, threadId: artifacts.node.threadId, runtimeRequest: artifacts.request, }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, - }); const resolved = yield* Deferred.await(decision).pipe( Effect.ensuring( @@ -5142,17 +5142,17 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( driver: CODEX_PROVIDER, node: artifacts.node, }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem: artifacts.turnItem, + }); yield* emitProviderEvent({ type: "runtime_request.updated", driver: CODEX_PROVIDER, threadId: artifacts.node.threadId, runtimeRequest: artifacts.request, }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, - }); const resolved = yield* Deferred.await(decision).pipe( Effect.ensuring( @@ -5203,17 +5203,17 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( driver: CODEX_PROVIDER, node: artifacts.node, }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem: artifacts.turnItem, + }); yield* emitProviderEvent({ type: "runtime_request.updated", driver: CODEX_PROVIDER, threadId: artifacts.node.threadId, runtimeRequest: artifacts.request, }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, - }); const resolved = yield* Deferred.await(decision).pipe( Effect.ensuring( @@ -5288,17 +5288,17 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( driver: CODEX_PROVIDER, node: artifacts.node, }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem: artifacts.turnItem, + }); yield* emitProviderEvent({ type: "runtime_request.updated", driver: CODEX_PROVIDER, threadId: artifacts.node.threadId, runtimeRequest: artifacts.request, }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, - }); const resolved = yield* Deferred.await(decision).pipe( Effect.ensuring( @@ -5347,17 +5347,17 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( driver: CODEX_PROVIDER, node: artifacts.node, }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem: artifacts.turnItem, + }); yield* emitProviderEvent({ type: "runtime_request.updated", driver: CODEX_PROVIDER, threadId: artifacts.node.threadId, runtimeRequest: artifacts.request, }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, - }); const resolved = yield* Deferred.await(decision).pipe( Effect.ensuring( @@ -5407,17 +5407,17 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( driver: CODEX_PROVIDER, node: artifacts.node, }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem: artifacts.turnItem, + }); yield* emitProviderEvent({ type: "runtime_request.updated", driver: CODEX_PROVIDER, threadId: artifacts.node.threadId, runtimeRequest: artifacts.request, }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, - }); const resolved = yield* Deferred.await(decision).pipe( Effect.ensuring( @@ -5465,17 +5465,17 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( driver: CODEX_PROVIDER, node: artifacts.node, }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem: artifacts.turnItem, + }); yield* emitProviderEvent({ type: "runtime_request.updated", driver: CODEX_PROVIDER, threadId: artifacts.node.threadId, runtimeRequest: artifacts.request, }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CODEX_PROVIDER, - turnItem: artifacts.turnItem, - }); const resolved = yield* Deferred.await(answers).pipe( Effect.ensuring( diff --git a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ece93ea3fb9c949a13d297d643ce4eae2fe3e7b --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + mergeClaudeWorkflowProgress, + parseClaudeWorkflowRunHandles, +} from "./claudeWorkflowProgress.ts"; + +const startFrame = { + type: "system", + subtype: "task_started", + task_id: "w1", + task_type: "local_workflow", + workflow_name: "probe-wf", +}; + +const snapshotFrame = ( + entries: ReadonlyArray>, + usage?: Record, +) => ({ + type: "system", + subtype: "task_progress", + task_id: "w1", + description: "Alpha: alpha:one", + workflow_progress: entries, + ...(usage === undefined ? {} : { usage }), +}); + +const phase = (index: number, title: string) => ({ type: "workflow_phase", index, title }); + +const agent = (overrides: Record) => ({ + type: "workflow_agent", + index: 1, + label: "alpha:one", + state: "start", + phaseIndex: 1, + phaseTitle: "Alpha", + ...overrides, +}); + +describe("mergeClaudeWorkflowProgress", () => { + it("marks a coordinator as a workflow before any member exists", () => { + const workflow = mergeClaudeWorkflowProgress({ + previous: undefined, + name: "probe-wf", + message: startFrame, + }); + expect(workflow).toEqual({ name: "probe-wf", phases: [], agents: [] }); + }); + + it("leaves an ordinary subagent's frames alone", () => { + expect( + mergeClaudeWorkflowProgress({ + previous: undefined, + message: { + type: "system", + subtype: "task_progress", + task_id: "t1", + description: "reading files", + usage: { total_tokens: 500, tool_uses: 2, duration_ms: 90 }, + }, + }), + ).toBeUndefined(); + }); + + it("maps provider agent states and per-member metrics", () => { + const workflow = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([ + phase(1, "Alpha"), + agent({ + state: "done", + agentId: "a1", + model: "claude-opus-5[1m]", + attempt: 2, + tokens: 71_141, + toolCalls: 3, + durationMs: 1277, + startedAt: 1_700_000_000_000, + queuedAt: 1_699_999_999_000, + promptPreview: "Reply with exactly: A1", + resultPreview: "A1", + }), + agent({ index: 2, label: "alpha:two", state: "queued" }), + ]), + }); + expect(workflow?.phases).toEqual([{ index: 1, title: "Alpha" }]); + expect(workflow?.agents[0]).toEqual({ + index: 1, + label: "alpha:one", + agentId: "a1", + state: "completed", + phaseIndex: 1, + phaseTitle: "Alpha", + model: "claude-opus-5[1m]", + attempt: 2, + totalTokens: 71_141, + toolCalls: 3, + durationMs: 1277, + queuedAt: 1_699_999_999_000, + startedAt: 1_700_000_000_000, + prompt: "Reply with exactly: A1", + result: "A1", + }); + expect(workflow?.agents[1]?.state).toBe("queued"); + }); + + it.each(["something-new", "toString", "constructor", "__proto__"])( + "treats unrecognised state %s as still running rather than settled", + (state) => { + const workflow = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([agent({ state })]), + }); + expect(workflow?.agents[0]?.state).toBe("running"); + }, + ); + + it("keeps the roster through a usage-only frame", () => { + const first = mergeClaudeWorkflowProgress({ + previous: undefined, + name: "probe-wf", + message: snapshotFrame([phase(1, "Alpha"), agent({})], { + total_tokens: 100, + tool_uses: 1, + duration_ms: 40, + }), + }); + const second = mergeClaudeWorkflowProgress({ + previous: first, + message: snapshotFrame([], { total_tokens: 250, tool_uses: 2, duration_ms: 900 }), + }); + expect(second?.agents).toHaveLength(1); + expect(second?.phases).toHaveLength(1); + expect(second?.name).toBe("probe-wf"); + expect(second?.totalTokens).toBe(250); + }); + + it("harvests run handles from the launch acknowledgement", () => { + const workflow = mergeClaudeWorkflowProgress({ + previous: undefined, + runHandles: parseClaudeWorkflowRunHandles({ + status: "async_launched", + taskType: "local_workflow", + runId: "wf_abc", + transcriptDir: "/root/.claude/projects/p/s/subagents/workflows/wf_abc", + scriptPath: "/root/.claude/projects/p/s/workflows/scripts/x.js", + }), + message: startFrame, + }); + expect(workflow?.runHandles?.transcriptDir).toBe( + "/root/.claude/projects/p/s/subagents/workflows/wf_abc", + ); + expect(workflow?.runHandles?.runId).toBe("wf_abc"); + }); + + it("ignores a non-workflow tool acknowledgement", () => { + expect( + parseClaudeWorkflowRunHandles({ status: "async_launched", taskType: "local_agent" }), + ).toBeUndefined(); + }); + + it("keeps a phase the plan reached at runtime even if a later frame omits it", () => { + const first = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([phase(1, "Alpha"), phase(2, "Beta"), phase(3, "Gamma")]), + }); + const second = mergeClaudeWorkflowProgress({ + previous: first, + message: snapshotFrame([phase(1, "Alpha")]), + }); + expect(second?.phases.map((entry) => entry.title)).toEqual(["Alpha", "Beta", "Gamma"]); + }); + + it("keeps other members and phase links when a sparse frame starts a new attempt", () => { + const first = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([ + phase(1, "Alpha"), + agent({ + state: "error", + attempt: 1, + promptPreview: "First prompt", + resultPreview: "First result", + tokens: 100, + toolCalls: 2, + durationMs: 50, + queuedAt: 1000, + startedAt: 1010, + }), + agent({ index: 2, state: "done" }), + ]), + }); + const second = mergeClaudeWorkflowProgress({ + previous: first, + message: snapshotFrame([ + { type: "workflow_agent", index: 1, label: "alpha:one", state: "start", attempt: 2 }, + ]), + }); + expect(second?.agents).toEqual([ + { + index: 1, + label: "alpha:one", + state: "running", + attempt: 2, + phaseIndex: 1, + phaseTitle: "Alpha", + }, + first?.agents[1], + ]); + expect(second?.phases).toEqual(first?.phases); + }); + + it("ignores an older attempt after a retry completes", () => { + const first = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([agent({ state: "done", attempt: 2, resultPreview: "New result" })]), + }); + const second = mergeClaudeWorkflowProgress({ + previous: first, + message: snapshotFrame([agent({ state: "error", attempt: 1, resultPreview: "Old result" })]), + }); + expect(second?.agents).toEqual(first?.agents); + }); + + it.each([ + ["done", "start"], + ["done", "queued"], + ["error", "start"], + ["error", "queued"], + ["start", "queued"], + ])("keeps member state when the same attempt regresses from %s to %s", (state, staleState) => { + const first = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([agent({ state, attempt: 2, resultPreview: "Current result" })]), + }); + const second = mergeClaudeWorkflowProgress({ + previous: first, + message: snapshotFrame([ + agent({ state: staleState, attempt: 2, resultPreview: "Old result" }), + ]), + }); + expect(second?.agents).toEqual(first?.agents); + }); + + it("retains member fields omitted by a later frame in the same attempt", () => { + const first = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([agent({ attempt: 2, promptPreview: "Current prompt", tokens: 100 })]), + }); + const second = mergeClaudeWorkflowProgress({ + previous: first, + message: snapshotFrame([agent({ state: "done", resultPreview: "Current result" })]), + }); + expect(second?.agents[0]).toMatchObject({ + state: "completed", + attempt: 2, + prompt: "Current prompt", + result: "Current result", + totalTokens: 100, + }); + }); + + it("keeps existing run handles when a later acknowledgement supplies only a script path", () => { + const first = mergeClaudeWorkflowProgress({ + previous: undefined, + runHandles: { runId: "wf_abc", transcriptDir: "/workflows/wf_abc" }, + message: startFrame, + }); + const second = mergeClaudeWorkflowProgress({ + previous: first, + runHandles: { scriptPath: "/scripts/workflow.js" }, + message: startFrame, + }); + expect(second?.runHandles).toEqual({ + runId: "wf_abc", + transcriptDir: "/workflows/wf_abc", + scriptPath: "/scripts/workflow.js", + }); + }); + + it("preserves prompt and result whitespace", () => { + const workflow = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([ + agent({ promptPreview: " indented\n", resultPreview: "\n answer\n" }), + ]), + }); + expect(workflow?.agents[0]?.prompt).toBe(" indented\n"); + expect(workflow?.agents[0]?.result).toBe("\n answer\n"); + }); + + it("drops timestamps outside the DateTime range", () => { + const workflow = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([ + agent({ startedAt: 8_640_000_000_000_001, queuedAt: Number.MAX_SAFE_INTEGER }), + agent({ index: 2, startedAt: 8_640_000_000_000_000, queuedAt: 0 }), + ]), + }); + expect(workflow?.agents[0]).not.toHaveProperty("startedAt"); + expect(workflow?.agents[0]).not.toHaveProperty("queuedAt"); + expect(workflow?.agents[1]?.startedAt).toBe(8_640_000_000_000_000); + expect(workflow?.agents[1]?.queuedAt).toBe(0); + }); + + it("drops malformed entries instead of failing the frame", () => { + const workflow = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([ + { type: "workflow_phase", title: "No index" }, + { type: "workflow_agent", index: 4 }, + { type: "something_else", index: 1, title: "Ignored" }, + phase(1, "Alpha"), + ]), + }); + expect(workflow).toEqual({ phases: [{ index: 1, title: "Alpha" }], agents: [] }); + }); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd28a5d9b848011bfcb44fbb2d53a87a068a3c92 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts @@ -0,0 +1,192 @@ +import type { + OrchestrationV2SubagentWorkflow, + OrchestrationV2WorkflowAgent, + OrchestrationV2WorkflowPhase, + OrchestrationV2WorkflowRunHandles, +} from "@t3tools/contracts"; + +import { field, text } from "../unknownField.ts"; + +export const CLAUDE_WORKFLOW_TASK_TYPE = "local_workflow"; + +export function hasClaudeWorkflowSnapshot( + message: unknown, +): message is { readonly workflow_progress: ReadonlyArray } { + const entries = field(message, "workflow_progress"); + return Array.isArray(entries) && entries.length > 0; +} + +const AGENT_STATES: Record = { + queued: "queued", + start: "running", + done: "completed", + error: "failed", +}; + +function count(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; +} + +function timestamp(value: unknown): number | undefined { + const milliseconds = count(value); + return milliseconds !== undefined && milliseconds <= 8_640_000_000_000_000 + ? milliseconds + : undefined; +} + +function preview(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function optional(key: string, value: T | undefined): Record { + return value === undefined ? {} : ({ [key]: value } as Record); +} + +function parsePhase(entry: unknown): OrchestrationV2WorkflowPhase | null { + const index = count(field(entry, "index")); + const title = text(field(entry, "title")); + return index === undefined || title === undefined ? null : { index, title }; +} + +function parseAgent(entry: unknown): OrchestrationV2WorkflowAgent | null { + const index = count(field(entry, "index")); + const label = text(field(entry, "label")); + if (index === undefined || label === undefined) return null; + // An unknown state is in-flight, not settled: guessing "completed" would + // freeze a live row, while guessing "running" self-corrects on the next frame. + const reportedState = text(field(entry, "state")) ?? ""; + const state = Object.hasOwn(AGENT_STATES, reportedState) + ? (AGENT_STATES[reportedState] ?? "running") + : "running"; + return { + index, + label, + state, + ...optional("agentId", text(field(entry, "agentId"))), + ...optional("phaseIndex", count(field(entry, "phaseIndex"))), + ...optional("phaseTitle", text(field(entry, "phaseTitle"))), + ...optional("model", text(field(entry, "model"))), + ...optional("attempt", count(field(entry, "attempt"))), + ...optional("totalTokens", count(field(entry, "tokens"))), + ...optional("toolCalls", count(field(entry, "toolCalls"))), + ...optional("durationMs", count(field(entry, "durationMs"))), + ...optional("queuedAt", timestamp(field(entry, "queuedAt"))), + ...optional("startedAt", timestamp(field(entry, "startedAt"))), + ...optional("prompt", preview(field(entry, "promptPreview"))), + ...optional("result", preview(field(entry, "resultPreview"))), + }; +} + +/** + * Reads the Workflow tool's structured result. A background launch answers the + * model with an acknowledgement rather than the run's output, and that + * acknowledgement is the only place the run's filesystem handles appear — + * nothing later on the event stream repeats them. + */ +export function parseClaudeWorkflowRunHandles( + toolUseResult: unknown, +): OrchestrationV2WorkflowRunHandles | undefined { + if (text(field(toolUseResult, "taskType")) !== CLAUDE_WORKFLOW_TASK_TYPE) return undefined; + const handles = { + ...optional("runId", text(field(toolUseResult, "runId"))), + ...optional("transcriptDir", text(field(toolUseResult, "transcriptDir"))), + ...optional("scriptPath", text(field(toolUseResult, "scriptPath"))), + }; + return Object.keys(handles).length === 0 ? undefined : handles; +} + +function parseClaudeWorkflowProgress(message: unknown): { + readonly phases: ReadonlyArray; + readonly agents: ReadonlyArray; +} | null { + if (!hasClaudeWorkflowSnapshot(message)) return null; + const phases: OrchestrationV2WorkflowPhase[] = []; + const agents: OrchestrationV2WorkflowAgent[] = []; + for (const entry of message.workflow_progress) { + const type = text(field(entry, "type")); + if (type === "workflow_phase") { + const phase = parsePhase(entry); + if (phase !== null) phases.push(phase); + } else if (type === "workflow_agent") { + const agent = parseAgent(entry); + if (agent !== null) agents.push(agent); + } + } + return phases.length === 0 && agents.length === 0 ? null : { phases, agents }; +} + +/** + * Folds one frame into the coordinator's workflow state. A frame without a + * snapshot still carries usage, so totals keep climbing between snapshots. + * A phase only ever joins the plan — the provider re-sends the full plan on + * every snapshot, but a late frame that omits an already-seen phase must not + * shorten the arc the user is looking at. + * + * Returns undefined when nothing identifies the task as a workflow. Progress + * frames do not repeat `task_type`, so this is the single place that decides + * whether a task is a coordinator; without it every ordinary subagent's usage + * frame would mint an empty workflow and render as a coordinator. + */ +export function mergeClaudeWorkflowProgress(input: { + readonly previous: OrchestrationV2SubagentWorkflow | undefined; + readonly name?: string | undefined; + readonly runHandles?: OrchestrationV2WorkflowRunHandles | undefined; + readonly message: unknown; +}): OrchestrationV2SubagentWorkflow | undefined { + const { previous } = input; + const snapshot = parseClaudeWorkflowProgress(input.message); + if ( + previous === undefined && + input.name === undefined && + input.runHandles === undefined && + snapshot === null + ) { + return undefined; + } + const reported = field(input.message, "usage"); + const phasesByIndex = new Map( + (previous?.phases ?? []).map((phase) => [phase.index, phase] as const), + ); + for (const phase of snapshot?.phases ?? []) phasesByIndex.set(phase.index, phase); + const agentsByIndex = new Map( + (previous?.agents ?? []).map((agent) => [agent.index, agent] as const), + ); + for (const agent of snapshot?.agents ?? []) { + const prior = agentsByIndex.get(agent.index); + const attempt = agent.attempt ?? prior?.attempt ?? 1; + if (prior !== undefined && attempt < (prior.attempt ?? 1)) continue; + const restarted = prior !== undefined && attempt > (prior.attempt ?? 1); + const regressed = + !restarted && + (((prior?.state === "completed" || prior?.state === "failed") && + agent.state !== prior.state) || + (prior?.state === "running" && agent.state === "queued")); + agentsByIndex.set( + agent.index, + regressed + ? { ...agent, ...prior } + : { + ...(restarted + ? { + ...optional("phaseIndex", prior.phaseIndex), + ...optional("phaseTitle", prior.phaseTitle), + } + : prior), + ...agent, + }, + ); + } + const runHandles = + input.runHandles === undefined + ? previous?.runHandles + : { ...previous?.runHandles, ...input.runHandles }; + return { + ...optional("name", input.name ?? previous?.name), + ...optional("runHandles", runHandles), + phases: Array.from(phasesByIndex.values()).sort((left, right) => left.index - right.index), + agents: Array.from(agentsByIndex.values()).sort((left, right) => left.index - right.index), + ...optional("totalTokens", count(field(reported, "total_tokens")) ?? previous?.totalTokens), + ...optional("toolCalls", count(field(reported, "tool_uses")) ?? previous?.toolCalls), + ...optional("durationMs", count(field(reported, "duration_ms")) ?? previous?.durationMs), + }; +} diff --git a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts index d1e62aad91bbcb74e23fadfddb00186cf2c7749d..ed560fad6fd46c7a11258037151bbae777498855 100644 --- a/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts +++ b/apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts @@ -42,10 +42,12 @@ const stopEarlierBackgroundWork = ({ stopWithQueue, olderStart = false, stalledRun, + workflowMember = false, }: { readonly failedStart?: boolean; readonly stopWithQueue?: "thread.stop" | "run.interrupt"; readonly olderStart?: boolean; + readonly workflowMember?: boolean; readonly stalledRun?: | "missing-session" | "missing-session-terminal" @@ -488,23 +490,27 @@ const stopEarlierBackgroundWork = ({ events: [ // A subagent the reviewer started, which the provider records on the // reviewer's own thread. - { - id: EventId.make("nested-subagent-item"), - type: "turn-item.updated", - threadId: reviewerThreadId, - occurredAt: now, - payload: { - ...nested, - id: nestedItemId, - nodeId: nestedSubagentId, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: 1, - type: "subagent", - subagentId: nestedSubagentId, - }, - }, + ...(workflowMember + ? [] + : [ + { + id: EventId.make("nested-subagent-item"), + type: "turn-item.updated", + threadId: reviewerThreadId, + occurredAt: now, + payload: { + ...nested, + id: nestedItemId, + nodeId: nestedSubagentId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + type: "subagent", + subagentId: nestedSubagentId, + }, + } as const, + ]), { id: EventId.make("nested-subagent"), type: "subagent.updated", @@ -515,12 +521,36 @@ const stopEarlierBackgroundWork = ({ id: nestedSubagentId, parentNodeId: reviewerRootId, createdBy: "agent", - nativeTaskRef: null, + nativeTaskRef: workflowMember + ? { driver, nativeId: "workflow:member", strength: "strong" } + : null, model: null, }, }, ], }); + if (workflowMember) { + // Workflow members have execution nodes and child roots, but no subagent turn item. + const memberRoot = (yield* orchestrator.getThreadProjection(tester.threadId)).nodes[0]!; + yield* sink.write({ + events: [ + { + id: EventId.make("workflow-member-node"), + type: "node.updated", + threadId: reviewerThreadId, + occurredAt: now, + payload: { + ...memberRoot, + id: nestedSubagentId, + threadId: reviewerThreadId, + parentNodeId: reviewerRootId, + rootNodeId: reviewerRootId, + kind: "subagent", + }, + }, + ], + }); + } // A settled run with its root node, attempt, provider turn and, // optionally, a command or native subagent it left running. const settledRun = (input: { @@ -883,10 +913,18 @@ const stopEarlierBackgroundWork = ({ const reviewerThread = yield* orchestrator.getThreadProjection(reviewerThreadId); assert.equal(reviewerThread.nodes[0]?.status, "interrupted"); assert.equal(reviewerThread.subagents[0]?.status, "interrupted"); - assert.equal( - reviewerThread.turnItems.find((item) => item.id === nestedItemId)?.status, - "interrupted", - ); + if (workflowMember) { + assert.isUndefined(reviewerThread.turnItems.find((item) => item.id === nestedItemId)); + assert.equal( + reviewerThread.nodes.find((node) => node.id === nestedSubagentId)?.status, + "interrupted", + ); + } else { + assert.equal( + reviewerThread.turnItems.find((item) => item.id === nestedItemId)?.status, + "interrupted", + ); + } const testerThread = yield* orchestrator.getThreadProjection(tester.threadId); assert.equal(testerThread.nodes[0]?.status, "interrupted"); }).pipe( @@ -905,6 +943,10 @@ it.effect("Stop reaches background work an earlier provider thread still runs", stopEarlierBackgroundWork({}), ); +it.effect("Stop settles workflow members without subagent turn items", () => + stopEarlierBackgroundWork({ workflowMember: true }), +); + it.effect( "Stop reaches earlier background work after the newest run fails before provider start", () => stopEarlierBackgroundWork({ failedStart: true }), diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index 15e175aa7c742d819b55e16e6009adc1a61919ad..f7e2e70ab3c5d897a06625ff255236eeb8f262c4 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -158,6 +158,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con }), setModel: () => Effect.void, setPermissionMode: () => Effect.void, + stopTask: () => Effect.void, interrupt: Effect.die("automatic delivery must never interrupt"), close: Effect.void, }), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index e3f5f72665b3564e6a9178743016f55ac7f912ca..de38ae7923889c2db9fa0b13f9d2087825928b7d 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -8421,7 +8421,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio OrchestrationV2ThreadProjection, "nodes" | "subagents" | "turnItems" >; - readonly subagent: Extract; + readonly subagent: Pick< + Extract, + "subagentId" | "origin" | "childThreadId" | "providerInstanceId" + >; }> = [{ projection: input.projection, subagent: item }]; for (const { projection, subagent } of dying) { const ended = yield* endOrphanedNativeSubagent({ @@ -8466,6 +8469,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio for (const nested of child.turnItems) { if (nested.type === "subagent") dying.push({ projection: child, subagent: nested }); } + // Workflow members have records and child roots without subagent turn items. + for (const nested of child.subagents) { + if (nested.origin === "provider_native" && nested.runId === null) { + dying.push({ + projection: child, + subagent: { + subagentId: nested.id, + origin: nested.origin, + childThreadId: nested.childThreadId, + providerInstanceId: nested.providerInstanceId, + }, + }); + } + } } } for (const providerThread of input.projection.providerThreads) { diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index c939749e8cd32ef1d6afb270defc052b9e1498e2..bda7b896e1876a0c420a8f7e552ca249ef484ce8 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -349,6 +349,54 @@ it.effect("memory recovery selection includes unfinished items from missing runs }).pipe(Effect.provide(ProjectionStore.layerMemory)), ); +const runlessNativeSubagentNeedsRecovery = Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* addRolledBackRecoveryCandidate("runless-native-recovery"); + const now = yield* DateTime.now; + const id = NodeId.make("node:runless-native-recovery"); + assert.notInclude(yield* store.getRecoveryThreadIds("runtime"), threadId); + for (const status of ["pending", "running", "waiting", "completed"] as const) { + yield* store.apply({ + id: EventId.make(`event:runless-native-recovery:${status}`), + type: "subagent.updated", + threadId, + nodeId: id, + driver, + providerInstanceId, + occurredAt: now, + payload: { + id, + threadId, + runId: null, + parentNodeId: NodeId.make("node:runless-native-parent"), + origin: "provider_native", + createdBy: "agent", + driver, + providerInstanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: { driver, nativeId: "runless-native", strength: "strong" }, + prompt: "Do the work", + title: "Workflow member", + model: null, + status, + result: null, + startedAt: now, + completedAt: status === "completed" ? now : null, + updatedAt: now, + }, + }); + assert.equal( + (yield* store.getRecoveryThreadIds("runtime")).includes(threadId), + status !== "completed", + ); + } +}); + +it.effect("memory recovery selects unfinished runless native subagents", () => + runlessNativeSubagentNeedsRecovery.pipe(Effect.provide(ProjectionStore.layerMemory)), +); + // Project A holds an active thread, an archived thread and a fork of a run in // an archived project B thread, so a project-scoped snapshot still has to load // a fork source that is outside the project and archived. @@ -501,6 +549,11 @@ it.effect("memory shell snapshots scope to one project", () => ); it.layer(layerTest)("ProjectionStoreV2", (it) => { + it.effect( + "SQL recovery selects unfinished runless native subagents", + () => runlessNativeSubagentNeedsRecovery, + ); + it.effect( "keeps restart-cancelled work through a stale run.updated", () => restartCancelledWorkSurvivesStaleRunUpdate, @@ -2520,6 +2573,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const now = yield* DateTime.now; const settledThreadId = ThreadId.make("thread:recovery-candidates:settled"); const runningThreadId = ThreadId.make("thread:recovery-candidates:running"); + const memberThreadId = ThreadId.make("thread:recovery-candidates:member"); + const childThreadId = ThreadId.make("thread:recovery-candidates:child"); const rolledBackThreadId = yield* addRolledBackRecoveryCandidate("recovery-candidates"); const orphanedThreadId = yield* addOrphanedRecoveryCandidate("recovery-candidates"); const projectId = ProjectId.make("project:recovery-candidates"); @@ -2551,7 +2606,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { deletedAt: null, }); - for (const threadId of [settledThreadId, runningThreadId]) { + for (const threadId of [settledThreadId, runningThreadId, memberThreadId, childThreadId]) { yield* projectionStore.apply({ id: EventId.make(`event:recovery-candidates:${threadId}:created`), type: "thread.created", @@ -2591,11 +2646,118 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }, }); + const memberId = NodeId.make("node:recovery-candidates:member"); + const childRootNodeId = NodeId.make("node:recovery-candidates:child-root"); + const nativeTaskRef = { driver, nativeId: "workflow:agent:1", strength: "strong" as const }; + const memberNode = { + id: memberId, + threadId: memberThreadId, + runId: null, + parentNodeId: NodeId.make("node:recovery-candidates:coordinator-root"), + rootNodeId: NodeId.make("node:recovery-candidates:coordinator-root"), + kind: "subagent" as const, + status: "running" as const, + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: nativeTaskRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }; + yield* projectionStore.apply({ + id: EventId.make("event:recovery-candidates:member"), + type: "subagent.updated", + threadId: memberThreadId, + nodeId: memberId, + driver, + providerInstanceId, + occurredAt: now, + payload: { + id: memberId, + threadId: memberThreadId, + runId: null, + parentNodeId: memberNode.parentNodeId, + origin: "provider_native", + createdBy: "agent", + driver, + providerInstanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef, + prompt: "Do the work", + title: "Member", + model: null, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }); + for (const [threadId, node] of [ + [memberThreadId, memberNode], + [ + childThreadId, + { + ...memberNode, + id: childRootNodeId, + threadId: childThreadId, + parentNodeId: null, + rootNodeId: childRootNodeId, + kind: "root_turn" as const, + }, + ], + ] as const) { + yield* projectionStore.apply({ + id: EventId.make(`event:recovery-candidates:node:${node.id}`), + type: "node.updated", + threadId, + nodeId: node.id, + driver, + occurredAt: now, + payload: node, + }); + } + const recoveryThreadIds = yield* projectionStore.getRecoveryThreadIds("runtime"); assert.include(recoveryThreadIds, runningThreadId); assert.include(recoveryThreadIds, orphanedThreadId); + assert.include(recoveryThreadIds, memberThreadId); + assert.include(recoveryThreadIds, childThreadId); assert.notInclude(recoveryThreadIds, settledThreadId); assert.notInclude(recoveryThreadIds, rolledBackThreadId); + const memberRecovery = yield* projectionStore.getRuntimeRecoveryProjection(memberThreadId); + const childRecovery = yield* projectionStore.getRuntimeRecoveryProjection(childThreadId); + assert.deepEqual( + memberRecovery.subagents.map((subagent) => subagent.id), + [memberId], + ); + assert.deepEqual( + memberRecovery.nodes.map((node) => node.id), + [memberId], + ); + assert.deepEqual( + childRecovery.nodes.map((node) => node.id), + [childRootNodeId], + ); + yield* projectionStore.apply({ + id: EventId.make("event:recovery-candidates:member-cancelled"), + type: "subagent.updated", + threadId: memberThreadId, + nodeId: memberId, + driver, + providerInstanceId, + occurredAt: now, + payload: { + ...memberRecovery.subagents[0]!, + status: "cancelled", + completedAt: now, + updatedAt: now, + }, + }); + assert.include(yield* projectionStore.getRecoveryThreadIds("runtime"), childThreadId); }), ); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index a6b7cc23d087bd8161f5434e225e5f5e7aa620ce..19232ddeeac4f0b4e40964e368ec705800b35efe 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -1589,6 +1589,90 @@ layer("ProviderEventIngestorV2", (it) => { }), ); + it.effect("updates provider child metadata without recreating its saved thread", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const rootEvent = yield* threadCreatedEvent(now); + if (rootEvent.type !== "thread.created") throw new Error("Expected thread fixture"); + const childThreadId = idAllocator.derive.threadFromProviderThread({ + driver: CODEX_DRIVER, + nativeThreadId: "workflow-member-metadata", + }); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId: rootEvent.threadId, + }); + const offered = { + ...rootEvent.payload, + id: childThreadId, + title: "Inspect checkout", + activeProviderThreadId: null, + lineage: { + parentThreadId: rootEvent.threadId, + relationshipToParent: "subagent" as const, + rootThreadId: rootEvent.threadId, + }, + }; + const ingest = (appThread: OrchestrationV2AppThread) => + ingestor.ingestNormalized({ + providerSessionId, + providerInstanceId: modelSelection.instanceId, + threadId: rootEvent.threadId, + event: { type: "app_thread.created", driver: CODEX_DRIVER, appThread }, + }); + yield* eventSink.write({ events: [rootEvent] }); + const created = yield* Effect.all([ingest(offered), ingest(offered)], { + concurrency: "unbounded", + }); + assert.deepEqual( + created.flat().map((stored) => stored.event.type), + ["thread.created"], + ); + const saved = { + ...offered, + archivedAt: now, + deletedAt: now, + activeProviderThreadId: rootEvent.payload.activeProviderThreadId, + branch: "user/checkout", + }; + yield* eventSink.write({ + events: [ + { + ...rootEvent, + id: yield* idAllocator.allocate.event({ threadId: childThreadId }), + threadId: childThreadId, + type: "thread.deleted", + payload: saved, + }, + ], + }); + for (const metadata of [ + { title: "Review checkout", modelSelection }, + { title: "Review checkout", modelSelection: { ...modelSelection, model: "gpt-6.1-sol" } }, + ]) { + const updated = yield* ingest({ ...offered, ...metadata }); + assert.deepEqual( + updated.map((stored) => stored.event.type), + ["thread.metadata-updated"], + ); + const thread = yield* projectionStore.getThread(childThreadId); + assert.deepEqual(thread, { ...saved, ...metadata, updatedAt: thread.updatedAt }); + } + assert.deepEqual( + yield* ingest({ + ...offered, + title: "Review checkout", + modelSelection: { ...modelSelection, model: "gpt-6.1-sol" }, + }), + [], + ); + }), + ); + it.effect("moves a native subagent's thread to the model its provider reports later", () => Effect.gen(function* () { const now = yield* DateTime.now; diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 308b8b210cebecdf683c6950dc971628c564a1c9..d02e78ce55bc93c64106fbd67db0cf972a6406c2 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -535,14 +535,37 @@ export const layer: Layer.Layer< const normalize: ProviderEventIngestorV2Shape["normalize"] = (input) => Effect.gen(function* () { switch (input.event.type) { - case "app_thread.created": + case "app_thread.created": { + const offered = input.event.appThread; + const existing = yield* projections.getThread(offered.id).pipe( + Effect.catchTags({ + ProjectionStoreThreadNotFoundError: () => Effect.succeed(null), + }), + ); + if ( + existing !== null && + existing.title === offered.title && + modelSelectionsEqual(existing.modelSelection, offered.modelSelection) + ) + return []; + const now = yield* DateTime.now; return [ yield* makeDomainEvent(input, { - type: "thread.created", - threadId: input.event.appThread.id, - payload: input.event.appThread, + type: existing === null ? "thread.created" : "thread.metadata-updated", + threadId: offered.id, + payload: + existing === null + ? offered + : { + ...existing, + title: offered.title, + modelSelection: offered.modelSelection, + updatedAt: now, + }, + occurredAt: now, }), ]; + } case "provider_session.updated": return [ yield* makeDomainEvent(input, { @@ -795,6 +818,10 @@ export const layer: Layer.Layer< ); }), ), + (effect) => + input.event.type === "app_thread.created" + ? threadCommands.withLock(input.event.appThread.id, effect) + : effect, ), }); }), diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index c8802a30f4d4248c6993e8f39b9400fd8c3d19f6..05c2b41c011648af006acedd12596ff0bf3d9f3c 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -854,6 +854,97 @@ it.effect( }, ); +it.effect("cancels workflow members and child roots without run ids after restart", () => { + const parentThreadId = ThreadId.make("thread_recovery_workflow_members"); + const childThreadId = ThreadId.make("thread_recovery_workflow_member_child"); + const memberId = NodeId.make("node_recovery_workflow_member"); + const childRootId = NodeId.make("node_recovery_workflow_member_root"); + const providerInstanceId = ProviderInstanceId.make("claudeAgent"); + const driver = ProviderDriverKind.make("claudeAgent"); + const member = { + id: memberId, + threadId: parentThreadId, + runId: null, + origin: "provider_native", + status: "running", + driver, + providerInstanceId, + }; + const parentProjection = { + thread: { id: parentThreadId, providerInstanceId }, + runs: [], + attempts: [], + nodes: [ + { id: memberId, threadId: parentThreadId, runId: null, kind: "subagent", status: "running" }, + ], + subagents: [member], + runtimeRequests: [], + providerSessions: [], + providerThreads: [], + providerTurns: [], + messages: [], + turnItems: [], + } as unknown as OrchestrationV2ThreadProjection; + const childProjection = { + ...parentProjection, + thread: { id: childThreadId, providerInstanceId }, + nodes: [ + { + id: childRootId, + threadId: childThreadId, + runId: null, + kind: "root_turn", + status: "running", + }, + ], + subagents: [], + } as unknown as OrchestrationV2ThreadProjection; + const committed: Array[0]> = []; + const layer = ProviderRuntimeRecovery.layer.pipe( + Layer.provide(ServerSettings.layerTest()), + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getRecoveryThreadIds: () => Effect.succeed([parentThreadId, childThreadId]), + getRuntimeRecoveryProjection: (threadId) => + Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), + }), + Layer.mock(EventSink.EventSinkV2)({ + commitCommand: (input) => { + committed.push(input); + return Effect.succeed({ committed: true, cancelledEffectCount: 0 } as never); + }, + }), + IdAllocator.layer, + Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ + runRecoveryOnce: Effect.succeed(false), + }), + Layer.mock(EffectOutbox.EffectOutboxV2)({ + reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), + }), + ), + ), + ); + + return Effect.gen(function* () { + yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).reconcile("startup"); + assert.deepEqual( + committed.flatMap((command) => + command.events.map((event) => [ + command.threadId, + event.type, + "status" in event.payload ? event.payload.status : null, + ]), + ), + [ + [parentThreadId, "subagent.updated", "cancelled"], + [parentThreadId, "node.updated", "cancelled"], + [childThreadId, "node.updated", "cancelled"], + ], + ); + }).pipe(Effect.provide(layer)); +}); + it.effect( "clears persisted pendingBackgroundTasks and terminalizes stale background items on settled runs", () => { diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index c42c402003d40d176564a20fda3994bbd415ea0f..d877922a644a63b80d758a5c84177603c4696860 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -165,7 +165,7 @@ it.effect("routes shared-runtime events only to their owning root run", () => }), ); -it("leaves a child thread created after the root turn ended to the run that is live then", () => { +it("routes only enrolled workflow members after the root turn ends", () => { const threadId = ThreadId.make("thread:late-child"); const rootProviderTurnId = ProviderTurnId.make("provider-turn:late-child"); 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 attemptId: RunAttemptId.make("attempt:late-child"), providerThreadId: ProviderThreadId.make("provider-thread:late-child"), }; - const childCreated = (childThreadId: ThreadId): ProviderAdapter.ProviderAdapterV2Event => + const childCreated = ( + childThreadId: ThreadId, + parentThreadId = threadId, + ): ProviderAdapter.ProviderAdapterV2Event => ({ type: "app_thread.created", driver, appThread: { id: childThreadId, lineage: { - parentThreadId: threadId, + parentThreadId, relationshipToParent: "subagent", rootThreadId: threadId, }, @@ -200,6 +203,13 @@ it("leaves a child thread created after the root turn ended to the run that is l initial, ); assert.isTrue(earlyAccepted); + const existingDescendant = ThreadId.make("thread:late-child:existing-descendant"); + const [descendantAccepted, withDescendant] = RunExecutionService.routeProviderEvent( + childCreated(existingDescendant, earlyChild), + identity, + live, + ); + assert.isTrue(descendantAccepted); const [terminalAccepted, ended] = RunExecutionService.routeProviderEvent( { type: "turn.terminal", @@ -212,7 +222,7 @@ it("leaves a child thread created after the root turn ended to the run that is l threadDisposition: "reusable", }, identity, - live, + withDescendant, ); assert.isTrue(terminalAccepted); // 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 ); assert.isFalse(lateAccepted); assert.isFalse(afterLate.ownedThreadIds.has(lateChild)); + + const memberId = ThreadId.make("thread:late-child:workflow-member"); + assert.isFalse( + RunExecutionService.routeProviderEvent(childCreated(memberId, earlyChild), identity, ended)[0], + ); + const coordinator: OrchestrationV2Subagent = { + id: NodeId.make("node:late-child:workflow"), + threadId, + runId: identity.runId, + parentNodeId: NodeId.make("node:late-child"), + origin: "provider_native", + createdBy: "agent", + driver, + providerInstanceId: ProviderInstanceId.make("codex"), + providerThreadId: null, + childThreadId: earlyChild, + nativeTaskRef: null, + prompt: "Review the code", + title: "Review", + model: null, + status: "running", + result: null, + startedAt: null, + completedAt: null, + updatedAt: DateTime.makeUnsafe(0), + workflow: { + phases: [], + agents: [{ index: 0, label: "Reviewer", state: "running", childThreadId: memberId }], + }, + }; + const [foreignAccepted, afterForeign] = RunExecutionService.routeProviderEvent( + { + type: "subagent.updated", + driver, + subagent: { + ...coordinator, + threadId: earlyChild, + childThreadId: existingDescendant, + runId: RunId.make("run:late-child:new"), + }, + }, + identity, + ended, + ); + assert.isTrue(foreignAccepted); + assert.isFalse( + RunExecutionService.routeProviderEvent( + childCreated(memberId, existingDescendant), + identity, + afterForeign, + )[0], + ); + const [workflowAccepted, afterWorkflow] = RunExecutionService.routeProviderEvent( + { type: "subagent.updated", driver, subagent: coordinator }, + identity, + ended, + ); + assert.isTrue(workflowAccepted); + assert.isFalse( + RunExecutionService.routeProviderEvent(childCreated(memberId), identity, afterWorkflow)[0], + ); + const [memberAccepted, afterMember] = RunExecutionService.routeProviderEvent( + childCreated(memberId, earlyChild), + identity, + afterWorkflow, + ); + assert.isTrue(memberAccepted); + assert.isTrue(afterMember.ownedThreadIds.has(memberId)); + const [unrelatedAccepted] = RunExecutionService.routeProviderEvent( + childCreated(ThreadId.make("thread:late-child:unrelated-member"), lateChild), + identity, + ended, + ); + assert.isFalse(unrelatedAccepted); + const [unrelatedDescendantAccepted, afterUnrelatedDescendant] = + RunExecutionService.routeProviderEvent( + childCreated(ThreadId.make("thread:late-child:unrelated-descendant"), earlyChild), + identity, + afterWorkflow, + ); + assert.isFalse(unrelatedDescendantAccepted); + assert.isFalse( + afterUnrelatedDescendant.ownedThreadIds.has( + ThreadId.make("thread:late-child:unrelated-descendant"), + ), + ); }); it("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 }), ); -it.effect("keeps ingesting owned child events after the root turn terminalizes", () => +it.effect("ingests new member threads of an owned child after the root turn terminalizes", () => Effect.gen(function* () { const threadId = ThreadId.make("thread:run-execution-late-child"); const childThreadId = ThreadId.make("thread:run-execution-late-child:child"); + const memberThreadId = ThreadId.make("thread:run-execution-late-child:member"); const runId = RunId.make("run:run-execution-late-child"); const attemptId = RunAttemptId.make("attempt:run-execution-late-child"); const providerInstanceId = ProviderInstanceId.make("codex"); @@ -1268,9 +1365,15 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ ingestNormalized: (input) => Effect.gen(function* () { + if ( + input.event.type === "app_thread.created" && + input.event.appThread.id === memberThreadId + ) { + yield* Ref.update(order, (current) => [...current, "member-thread"]); + } if ( input.event.type === "message.updated" && - input.event.message.threadId === childThreadId + input.event.message.threadId === memberThreadId ) { yield* Ref.update(order, (current) => [...current, "child-message"]); yield* Deferred.succeed(childMessageIngested, undefined).pipe(Effect.ignore); @@ -1307,12 +1410,21 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", type: "subagent.updated", driver, subagent: { - id: subagentNodeId, - threadId, - runId, - status: "running", + ...makeRunOwnedSubagentFixture({ + ids: backgroundScenarioIds("run-execution-late-child"), + providerInstanceId, + childThreadId, + driver, + status: "running", + }), + workflow: { + phases: [], + agents: [ + { index: 0, label: "Reviewer", state: "running", childThreadId: memberThreadId }, + ], + }, }, - } as ProviderAdapter.ProviderAdapterV2Event, + }, { type: "provider_turn.updated", driver, @@ -1335,12 +1447,24 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", failure: null, threadDisposition: "reusable", }, + { + type: "app_thread.created", + driver, + appThread: { + id: memberThreadId, + lineage: { + parentThreadId: childThreadId, + relationshipToParent: "subagent", + rootThreadId: threadId, + }, + }, + } as ProviderAdapter.ProviderAdapterV2Event, { type: "message.updated", driver, message: { id: MessageId.make("message:run-execution-late-child:child"), - threadId: childThreadId, + threadId: memberThreadId, runId: null, nodeId: childNodeId, role: "assistant", @@ -1428,7 +1552,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", Effect.timeoutOption("2 seconds"), ); assert.isTrue(Option.isSome(observed), "child message was not ingested after root terminal"); - assert.deepEqual(yield* Ref.get(order), ["root-finalized", "child-message"]); + assert.deepEqual(yield* Ref.get(order), ["root-finalized", "member-thread", "child-message"]); }), ); @@ -2614,11 +2738,42 @@ it.effect( ids, status: "running", }); + const memberThreadId = ThreadId.make("thread:subagent-interrupt-cascade:member"); + const runningMember = { + ...runningSubagent, + id: NodeId.make("node:subagent-interrupt-cascade:member"), + threadId: childThreadId, + runId: null, + parentNodeId: runningChildNode.id, + childThreadId: memberThreadId, + }; + const foreignSubagent = { + ...runningMember, + id: NodeId.make("node:subagent-interrupt-cascade:foreign-subagent"), + runId: RunId.make("run:subagent-interrupt-cascade:foreign"), + childThreadId: unrelatedChildThreadId, + }; + const runningMemberRoot = { + ...runningChildNode, + id: NodeId.make("node:subagent-interrupt-cascade:member-root"), + threadId: memberThreadId, + rootNodeId: NodeId.make("node:subagent-interrupt-cascade:member-root"), + }; + const foreignChildNode = { + ...runningChildNode, + id: NodeId.make("node:subagent-interrupt-cascade:foreign-child"), + runId: foreignSubagent.runId, + }; const runningChildTurnItem = makeLinkedChildTurnItemFixture({ ids, driver, type: "command_execution", }); + const foreignChildTurnItem = { + ...runningChildTurnItem, + id: TurnItemId.make("turn-item:subagent-interrupt-cascade:foreign-child"), + runId: foreignSubagent.runId, + }; const suppressedChildAssistantTurnItem = makeLinkedChildTurnItemFixture({ ids: { ...ids, @@ -2692,6 +2847,11 @@ it.effect( driver, turnItem: unrelatedChildTurnItem, }, + { type: "subagent.updated", driver, subagent: runningMember }, + { type: "subagent.updated", driver, subagent: foreignSubagent }, + { type: "node.updated", driver, node: runningMemberRoot }, + { type: "node.updated", driver, node: foreignChildNode }, + { type: "turn_item.updated", driver, turnItem: foreignChildTurnItem }, rootTerminalEvent(ids, "interrupted"), // Late provider completion after interrupt must not be ingested. { @@ -2758,7 +2918,7 @@ it.effect( providerTurnId: ids.rootProviderTurnId, } as OrchestrationV2RunAttempt, attemptId: ids.attemptId, - relatedThreadIds: [unrelatedChildThreadId], + relatedThreadIds: [unrelatedChildThreadId, memberThreadId], providerTurnOrdinal: 1, message: { messageId: MessageId.make("message:subagent-interrupt-cascade:user"), @@ -2810,8 +2970,39 @@ it.effect( const runUpdatedIndex = events.findIndex((event) => event.type === "run.updated"); assert.isAtLeast(runUpdatedIndex, 0, "root run.updated must be written"); - assert.lengthOf(subagentEvents, 1); - const terminalSubagent = subagentEvents[0]; + assert.lengthOf(subagentEvents, 2); + const terminalSubagent = subagentEvents.find( + (event) => event.payload.id === runningSubagent.id, + ); + const terminalMember = subagentEvents.find((event) => event.payload.id === runningMember.id); + assert.isDefined(terminalMember); + assert.equal(terminalMember.payload.status, "interrupted"); + assert.equal(terminalMember.payload.runId, null); + assert.equal(terminalMember.payload.childThreadId, memberThreadId); + assert.isTrue( + events.some( + (event) => + event.type === "node.updated" && + event.payload.id === runningMemberRoot.id && + event.payload.status === "interrupted", + ), + ); + assert.isFalse( + events.some( + (event) => event.type === "subagent.updated" && event.payload.id === foreignSubagent.id, + ), + ); + assert.isFalse( + events.some( + (event) => event.type === "node.updated" && event.payload.id === foreignChildNode.id, + ), + ); + assert.isFalse( + events.some( + (event) => + event.type === "turn-item.updated" && event.payload.id === foreignChildTurnItem.id, + ), + ); assert.isDefined(terminalSubagent); assert.equal(terminalSubagent.payload.status, "interrupted"); assert.equal(terminalSubagent.payload.childThreadId, childThreadId); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index fa6d0fbd7f125d9ed7cb5c137894dab043c63327..b2fe77f6cd85b90d4f211fbfb450d845d2e8577e 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -53,8 +53,12 @@ import * as RunFinalizationService from "./RunFinalizationService.ts"; export interface ProviderEventRoutingState { readonly ownedThreadIds: ReadonlySet; // Set once this run's root turn ended. A child thread created after that - // belongs to the run that is live then, so this one no longer adopts it. + // belongs to the run that is live then, so this one no longer adopts it, + // except a workflow member enrolled below: its coordinator keeps this run + // open, so the member is this run's to follow. readonly rootTurnEnded: boolean; + /** Member thread id to the coordinator child thread that spawned it. */ + readonly workflowMemberParents: ReadonlyMap; readonly ownedProviderThreadIds: ReadonlySet; readonly ownedProviderTurnIds: ReadonlySet; readonly inheritedBackgroundTurnItems: ReadonlyMap; @@ -334,6 +338,7 @@ export function makeProviderEventRoutingState(input: { return { ownedThreadIds: new Set([input.identity.threadId, ...(input.relatedThreadIds ?? [])]), rootTurnEnded: false, + workflowMemberParents: new Map(), ownedProviderThreadIds: new Set([ input.identity.providerThreadId, ...(input.relatedProviderThreadIds ?? []), @@ -379,10 +384,12 @@ export function routeProviderEvent( return [true, state]; } const isOwnedSubagent = - !state.rootTurnEnded && event.appThread.lineage.relationshipToParent === "subagent" && event.appThread.lineage.parentThreadId !== null && - ownsThread(event.appThread.lineage.parentThreadId); + ownsThread(event.appThread.lineage.parentThreadId) && + (!state.rootTurnEnded || + state.workflowMemberParents.get(event.appThread.id) === + event.appThread.lineage.parentThreadId); if (!isOwnedSubagent) { return [false, state]; } @@ -417,8 +424,24 @@ export function routeProviderEvent( } return [true, addProviderThread(event.node.providerThreadId)]; } - case "subagent.updated": - return [ownsRun(event.subagent.runId) || ownsChildThread(event.subagent.threadId), state]; + case "subagent.updated": { + const belongs = ownsRun(event.subagent.runId) || ownsChildThread(event.subagent.threadId); + const coordinator = event.subagent; + if ( + !ownsRun(coordinator.runId) || + coordinator.workflow === undefined || + coordinator.childThreadId === null + ) { + return [belongs, state]; + } + const workflowMemberParents = new Map(state.workflowMemberParents); + for (const member of coordinator.workflow.agents) { + if (member.childThreadId !== undefined) { + workflowMemberParents.set(member.childThreadId, coordinator.childThreadId); + } + } + return [true, { ...state, workflowMemberParents }]; + } case "message.updated": return [ownsRun(event.message.runId) || ownsChildThread(event.message.threadId), state]; case "turn_item.updated": { @@ -1070,7 +1093,13 @@ export const layer: Layer.Layer< const belongsToOwnedChildThread = event.node.threadId !== input.run.threadId && routing.ownedThreadIds.has(event.node.threadId); - if (!belongsToRootSubagent && !belongsToOwnedChildThread) { + if ( + !belongsToRootSubagent && + !( + belongsToOwnedChildThread && + (event.node.runId === null || event.node.runId === input.run.id) + ) + ) { return; } yield* Ref.update(openRunOwnedSubagents, (current) => { @@ -1108,7 +1137,11 @@ export const layer: Layer.Layer< return next; }); } - if (belongsToOwnedChildThread && deliverable) { + if ( + belongsToOwnedChildThread && + (event.turnItem.runId === null || belongsToRootRun) && + deliverable + ) { yield* Ref.update(openRunOwnedSubagents, (current) => { const childTurnItems = new Map(current.childTurnItems); if (isSettledTurnItemStatus(event.turnItem.status)) { diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index 958fb89cc8e461733ebf54c4408794c2a6af9a92..03fbfa59331e800366f21963562590f1823ba0c3 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -388,7 +388,7 @@ function makeCompletingHandoffAdapter( }; } -it.live("restarts selection as a new attempt and retries after old-session cleanup", () => +it.live("retries a failed selection restart session open before completing", () => Effect.scoped( Effect.gen(function* () { 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 const result = yield* Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const eventSink = yield* EventSink.EventSinkV2; + yield* orchestrator.dispatch({ type: "thread.create", createdBy: "user", @@ -418,6 +421,21 @@ it.live("restarts selection as a new attempt and retries after old-session clean branch: null, worktreePath: cwd, }); + const running = yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + }) + .pipe( + Stream.map((stored) => stored.event), + Stream.filter( + (event) => + event.type === "provider-turn.updated" && event.payload.status === "running", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); yield* orchestrator.dispatch({ type: "message.dispatch", createdBy: "user", @@ -430,11 +448,8 @@ it.live("restarts selection as a new attempt and retries after old-session clean modelSelection: initialSelection, dispatchMode: { type: "start_immediately" }, }); - for (let index = 0; index < 1_000; index += 1) { - const current = yield* orchestrator.getThreadProjection(threadId); - if (current.providerTurns.some((turn) => turn.status === "running")) break; - yield* Effect.sleep("5 millis"); - } + yield* Fiber.join(running); + yield* worker.drain(); const activeProjection = yield* orchestrator.getThreadProjection(threadId); assert.isTrue(activeProjection.providerTurns.some((turn) => turn.status === "running")); const activeRunId = activeProjection.runs[0]?.id; @@ -442,6 +457,20 @@ it.live("restarts selection as a new attempt and retries after old-session clean return yield* Effect.die("active restart test run is missing"); } + const completed = yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + }) + .pipe( + Stream.map((stored) => stored.event), + Stream.filter( + (event) => event.type === "run.updated" && event.payload.status === "completed", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); yield* orchestrator.dispatch({ type: "message.dispatch", createdBy: "user", @@ -454,27 +483,12 @@ it.live("restarts selection as a new attempt and retries after old-session clean modelSelection: replacementSelection, dispatchMode: { type: "restart_active", targetRunId: activeRunId }, }); - for (let index = 0; index < 1_000; index += 1) { - const current = yield* orchestrator.getThreadProjection(threadId); - if (current.attempts.length === 2 && current.attempts[1]?.status === "completed") { - const captured = yield* Ref.get(state); - return { projection: current, captured }; - } - yield* Effect.sleep("5 millis"); - } - const current = yield* orchestrator.getThreadProjection(threadId); - const adapterState = yield* Ref.get(state); - yield* Effect.logError("selection restart did not complete", { - runs: current.runs.map((run) => [run.status, run.activeAttemptId]), - attempts: current.attempts.map((attempt) => [attempt.id, attempt.status]), - providerTurns: current.providerTurns.map((turn) => [turn.id, turn.status]), - providerThreads: current.providerThreads.map((thread) => [ - thread.providerSessionId, - thread.status, - ]), - adapterState, - }); - return yield* Effect.die("selection restart did not complete"); + yield* Fiber.join(completed); + yield* worker.drain(); + return { + projection: yield* orchestrator.getThreadProjection(threadId), + captured: yield* Ref.get(state), + }; }).pipe( Effect.provide( ProviderReplayHarness.layerWithRegistry( @@ -501,7 +515,8 @@ it.live("restarts selection as a new attempt and retries after old-session clean ), "selection restart supersede must not project hard-Stop interrupt items", ); - assert.equal(projection.runs[0]?.modelSelection.model, replacementSelection.model); + assert.equal(projection.runs.at(-1)?.modelSelection.model, replacementSelection.model); + assert.equal(projection.runs.at(-1)?.status, "completed"); assert.isTrue(captured.failedReplacementOpen); // The old pooled process remains available to its other threads; this // thread moved to a freshly allocated replacement session. diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 94e17bdc567e781eee6fa99b31df09de8348083e..b4694a19863c773476ba2b46fa2f284b4f129818 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -5,25 +5,38 @@ import { NodeId, type OrchestrationV2Command, type OrchestrationV2Run, + type OrchestrationV2Subagent, + type OrchestrationV2ProviderThread, + ProviderThreadId, + ProviderDriverKind, + ProviderSessionId, type OrchestrationV2StoredEvent, type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, ProjectId, ProviderInstanceId, RunId, ThreadId, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; import * as Orchestrator from "./Orchestrator.ts"; +import * as ProviderSessionManager from "./ProviderSessionManager.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ThreadManagementService from "./ThreadManagementService.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import { DispatchModeLimit, type DispatchModeRefusal } from "./DispatchModeLimit.ts"; it("stamps authoritative provenance on commands that create threads or messages", () => { const command: OrchestrationV2Command = { @@ -269,6 +282,7 @@ it.effect("classifies projection infrastructure failures separately from a missi cause: infrastructureCause, }); const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadProjection: () => Effect.fail(projectionError), @@ -302,6 +316,7 @@ it.effect("uses thread-not-found only after a projection loads outside the proje }, } as OrchestrationV2ThreadProjection; const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadProjection: () => Effect.succeed(projection), @@ -327,6 +342,7 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte cause: new Error("checkpoint import failed"), }); const layerTest = ThreadManagementService.layerWithLegacyImporter.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( Layer.mergeAll( Layer.mock(Orchestrator.OrchestratorV2)({ @@ -368,6 +384,7 @@ it.effect.each([ runs: status === "missing" ? [] : [{ id: runId, status }], }) as unknown as OrchestrationV2ThreadProjection; const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadEventSequence: () => Effect.succeed(0), @@ -434,6 +451,7 @@ it.effect("waitForThread reads the run again only when the run updates", () => const stored = (sequence: number, event: object) => ({ sequence, event: { threadId, ...event } }) as unknown as OrchestrationV2StoredEvent; const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadEventSequence: () => Effect.succeed(0), @@ -474,6 +492,222 @@ it.effect("waitForThread reads the run again only when the run updates", () => }), ); +it.effect.each([ + "running", + "missing", + "completed", + "unsupported", + "missing-native-id", + "wrong-thread", + "no-session", + "unsupported-runtime", + "provider-failed", + "provider-timeout", + "within-limit", + "runtime-raised", + "interaction-raised", +] as const)("stops a persisted workflow with state %s", (state) => + Effect.gen(function* () { + const threadId = ThreadId.make("thread:workflow-stop"); + const subagentId = NodeId.make("node:workflow-stop"); + const runId = RunId.make("run:workflow-stop"); + const providerThreadId = ProviderThreadId.make("provider-thread:workflow-stop"); + const providerSessionId = ProviderSessionId.make("provider-session:workflow-stop"); + const driver = ProviderDriverKind.make("claudeAgent"); + const instanceId = ProviderInstanceId.make("claudeAgent"); + const now = yield* DateTime.now; + let targetModes: Pick = { + runtimeMode: "approval-required", + interactionMode: "plan", + }; + const task: OrchestrationV2Subagent = { + id: subagentId, + threadId, + runId, + parentNodeId: NodeId.make("node:workflow-parent"), + origin: "provider_native", + createdBy: "agent", + driver, + providerInstanceId: instanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: + state === "missing-native-id" + ? null + : { + driver, + nativeId: "native-workflow-id", + strength: "strong", + }, + workflow: { phases: [], agents: [] }, + prompt: "Run the workflow", + title: null, + model: null, + status: state === "completed" ? "completed" : "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + ...(state === "unsupported" ? { driver: ProviderDriverKind.make("codex") } : {}), + }; + const providerThread: OrchestrationV2ProviderThread = { + id: providerThreadId, + driver, + providerInstanceId: instanceId, + providerSessionId, + appThreadId: state === "wrong-thread" ? ThreadId.make("other-thread") : threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + pendingBackgroundTasks: [], + contextUsage: null, + nativeMetadata: null, + createdAt: now, + updatedAt: now, + }; + const calls: Array<{ taskId: string; providerThread: OrchestrationV2ProviderThread }> = []; + const stopStarted = yield* Deferred.make(); + const runtime = { + driver, + providerSessionId, + instanceId, + ...(state === "unsupported-runtime" + ? {} + : { + stopTask: (input: (typeof calls)[number]) => + state === "provider-failed" + ? Effect.fail( + new ProviderAdapter.ProviderAdapterProtocolError({ + driver, + detail: "private provider detail", + }), + ) + : state === "provider-timeout" + ? Deferred.succeed(stopStarted, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.sync(() => { + calls.push(input); + }), + }), + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime; + const layerTest = ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadRecords: () => + Effect.sync( + () => + ({ + thread: { id: threadId, deletedAt: null, ...targetModes }, + subagents: state === "missing" ? [] : [task], + runs: [{ id: runId, providerThreadId }], + providerThreads: [providerThread], + }) as unknown as OrchestrationV2ThreadProjection, + ), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + get: (id) => { + expect(id).toBe(providerSessionId); + return Effect.succeed(state === "no-session" ? Option.none() : Option.some(runtime)); + }, + }), + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* ThreadManagementService.ThreadManagementService; + const refused = yield* Ref.make(undefined); + const stop = service + .stopWorkflow({ threadId, subagentId }) + .pipe( + Effect.provideService( + DispatchModeLimit, + state === "within-limit" || state === "runtime-raised" || state === "interaction-raised" + ? { runtimeMode: "approval-required", interactionMode: "plan", refused } + : undefined, + ), + Effect.result, + ); + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const result = + state === "runtime-raised" || state === "interaction-raised" + ? yield* Effect.gen(function* () { + const started = yield* Deferred.make(); + const pending = yield* executor.withLock( + threadId, + Effect.gen(function* () { + const fiber = yield* Deferred.succeed(started, undefined).pipe( + Effect.andThen(stop), + Effect.forkChild, + ); + yield* Deferred.await(started); + targetModes = + state === "runtime-raised" + ? { ...targetModes, runtimeMode: "full-access" } + : { ...targetModes, interactionMode: "default" }; + return fiber; + }), + ); + return yield* Fiber.join(pending); + }) + : state === "provider-timeout" + ? yield* Effect.gen(function* () { + const finished = yield* Ref.make(false); + const pending = yield* stop.pipe( + Effect.tap(() => Ref.set(finished, true)), + Effect.forkChild, + ); + yield* Deferred.await(stopStarted); + yield* TestClock.adjust("15 seconds"); + expect(yield* Ref.get(finished)).toBe(true); + const result = yield* Fiber.join(pending); + expect(yield* executor.withLock(threadId, Effect.succeed("lock-released"))).toBe( + "lock-released", + ); + return result; + }) + : yield* stop; + if (state === "running" || state === "within-limit") { + expect(result).toMatchObject({ _tag: "Success" }); + expect(calls).toEqual([{ taskId: "native-workflow-id", providerThread }]); + expect(task.status).toBe("running"); + } else { + expect(result).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "OrchestrationV2StopWorkflowError", + threadId, + subagentId, + reason: + state === "missing" || state === "completed" + ? "not-running" + : state === "unsupported" || state === "unsupported-runtime" + ? "unsupported" + : state === "provider-failed" || state === "provider-timeout" + ? "stop-failed" + : "unavailable", + }, + }); + expect(calls).toEqual([]); + if (state === "provider-timeout") { + expect(result).toMatchObject({ failure: { cause: { _tag: "TimeoutError" } } }); + } + if (state === "runtime-raised" || state === "interaction-raised") { + expect(yield* Ref.get(refused)).toEqual({ + threadId, + ...targetModes, + mode: state === "runtime-raised" ? "runtime" : "interaction", + }); + } + } + }).pipe(Effect.provide(Layer.merge(layerTest, ThreadCommandExecutor.layer))); + }), +); + it.effect.each([ { status: "completed" as const, settles: true }, { status: "failed" as const, settles: false }, @@ -485,6 +719,7 @@ it.effect.each([ const runId = RunId.make("run:thread-management:settle-after-run"); const dispatched: Array = []; const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadEventSequence: () => Effect.succeed(0), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 3da3ef12438d8947c7a3acaf77cec9ab5dc990df..c1a542bf776185ffd7afa12a82fa0af7fa5a9fc2 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -11,6 +11,8 @@ import { type ChatAttachment, CommandId, MessageId, + OrchestrationV2StopWorkflowError, + type OrchestrationV2StopWorkflowInput, type ModelSelection, type OrchestrationV2Actor, type OrchestrationV2Command, @@ -35,10 +37,14 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Orchestrator from "./Orchestrator.ts"; +import * as ProviderSessionManager from "./ProviderSessionManager.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; +import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts"; import { projectTurnItemForDetail } from "./WireProjection.ts"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; @@ -364,6 +370,13 @@ export interface ThreadManagementServiceShape { readonly interruptThread: ( input: ThreadManagementInterruptInput, ) => Effect.Effect; + /** + * Asks the provider to stop a running workflow coordinator. The provider's + * own terminal notification then settles the coordinator and its members. + */ + readonly stopWorkflow: ( + input: OrchestrationV2StopWorkflowInput, + ) => Effect.Effect; /** * Sends `thread.stop` to every delegated task under a thread, depth first. Their command IDs * derive from `commandId`, so a retry repeats nothing that already stopped. A task that @@ -440,7 +453,9 @@ function latestSteerableRun( const SETTLE_AFTER_RUN_WAIT_MS = 24 * 60 * 60 * 1_000; const make = Effect.gen(function* () { + const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor; const orchestrator = yield* Orchestrator.OrchestratorV2; + const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; const layerScope = yield* Effect.scope; const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; @@ -841,6 +856,90 @@ const make = Effect.gen(function* () { return { type: "interrupt_requested", run: interruptibleRun, dispatch } as const; }); + const stopWorkflow: ThreadManagementServiceShape["stopWorkflow"] = (input) => + Effect.gen(function* () { + const records = yield* orchestrator + .getThreadRecords(input.threadId, ["subagents", "runs", "providerThreads"]) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable", cause }), + ), + ); + if (records.thread.deletedAt !== null) { + return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" }); + } + // A limited caller (an agent over MCP) may not stop work on a thread + // running above its own modes. Checked under the thread lock, where the + // thread's user cannot raise the modes between this read and the stop. + const limit = yield* DispatchModeLimit; + const exceeded = + limit === undefined ? undefined : exceededDispatchModeLimit(limit, records.thread); + if (limit !== undefined && exceeded !== undefined) { + if (limit.refused !== undefined) { + yield* Ref.set(limit.refused, { + threadId: input.threadId, + runtimeMode: records.thread.runtimeMode, + interactionMode: records.thread.interactionMode, + mode: exceeded, + }); + } + return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" }); + } + const task = records.subagents.find((candidate) => candidate.id === input.subagentId); + if ( + task === undefined || + task.threadId !== input.threadId || + task.workflow === undefined || + task.status !== "running" + ) { + return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "not-running" }); + } + if (task.origin !== "provider_native" || task.driver !== "claudeAgent") { + return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unsupported" }); + } + const providerThreadId = + task.providerThreadId ?? + records.runs.find((run) => run.id === task.runId)?.providerThreadId; + const providerThread = records.providerThreads.find( + (candidate) => + candidate.id === providerThreadId && + candidate.appThreadId === input.threadId && + candidate.providerInstanceId === task.providerInstanceId && + candidate.driver === task.driver, + ); + const taskId = task.nativeTaskRef?.nativeId; + if (providerThread?.providerSessionId == null || taskId == null || taskId.length === 0) { + return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" }); + } + const runtime = yield* sessions + .get(providerThread.providerSessionId) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable", cause }), + ), + ); + if (Option.isNone(runtime)) { + return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" }); + } + if (runtime.value.stopTask === undefined) { + return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unsupported" }); + } + // Bounded so a provider that never answers cannot hold the thread lock. + yield* runtime.value.stopTask({ providerThread, taskId }).pipe( + Effect.timeout("15 seconds"), + Effect.mapError( + (cause) => + new OrchestrationV2StopWorkflowError({ + ...input, + reason: "stop-failed", + cause, + }), + ), + ); + }).pipe((effect) => threadDispatch.withLock(input.threadId, effect)); + const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = (input) => Effect.gen(function* () { const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]); @@ -919,6 +1018,7 @@ const make = Effect.gen(function* () { settleThread, interruptThread, stopDelegatedTasks, + stopWorkflow, getThreadEventSequence: orchestrator.getThreadEventSequence, recoverDelegatedTask: orchestrator.recoverDelegatedTask, delegatedTaskResultPending: orchestrator.delegatedTaskResultPending, @@ -938,11 +1038,19 @@ const layerLegacyV1ThreadImporterNoop = Layer.succeed( }), ); -export const layer: Layer.Layer = - Layer.effect(ThreadManagementService, make).pipe(Layer.provide(layerLegacyV1ThreadImporterNoop)); +export const layer: Layer.Layer< + ThreadManagementService, + never, + Orchestrator.OrchestratorV2 | ProviderSessionManager.ProviderSessionManagerV2 +> = Layer.effect(ThreadManagementService, make).pipe( + Layer.provide(layerLegacyV1ThreadImporterNoop), + Layer.provide(ThreadCommandExecutor.layer), +); export const layerWithLegacyImporter: Layer.Layer< ThreadManagementService, never, - LegacyV1ThreadImporter.LegacyV1ThreadImporter | Orchestrator.OrchestratorV2 -> = Layer.effect(ThreadManagementService, make); + | LegacyV1ThreadImporter.LegacyV1ThreadImporter + | Orchestrator.OrchestratorV2 + | ProviderSessionManager.ProviderSessionManagerV2 +> = Layer.effect(ThreadManagementService, make).pipe(Layer.provide(ThreadCommandExecutor.layer)); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 2442a4fa010c8edb043d7d5f9fb3f9951fe3e6b4..623b1e0841181a658fd55064b1340755d6ffa207 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -240,7 +240,13 @@ const layerAgentSessionImporterProvided = AgentSessionImporter.layer.pipe( ); const layerThreadManagementProvided = ThreadManagementService.layerWithLegacyImporter.pipe( - Layer.provide(Layer.merge(layerOrchestratorProvided, layerLegacyV1ThreadImporterProvided)), + Layer.provide( + Layer.mergeAll( + layerOrchestratorProvided, + layerLegacyV1ThreadImporterProvided, + layerProviderSessionManagerProvided, + ), + ), ); export const layerProjectSetupScriptRunner = ProjectSetupScriptRunner.layer.pipe( Layer.provide(layerProjectService), diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 27c1cc28ae5a4a76d8552422d5dee6b184d638b9..577a78aae5acc21a93d7979bb423131a109b2aee 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -242,7 +242,10 @@ export function layerProviderReplay { const layerRegistry = harness.makeProviderAdapterRegistryLayer( diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_output.ts index bc4a61641a07ce52a0c73352f7b0db31db4fa8c3..e22e961aaa37b38a18a602d8b0b110227ed21db3 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_output.ts @@ -59,6 +59,20 @@ export function assertSubagentV2ApprovalOutput( const approval = approvalItems[0]!; assert.equal(approval.runId, run.id); assert.equal(approval.status, "completed"); + const pendingRequestIndex = result.domainEvents.findIndex( + (event) => + event.type === "runtime-request.updated" && + event.payload.id === request.id && + event.payload.status === "pending", + ); + const waitingItemIndex = result.domainEvents.findIndex( + (event) => + event.type === "turn-item.updated" && + event.payload.id === approval.id && + event.payload.status === "waiting", + ); + assert.isAtLeast(waitingItemIndex, 0); + assert.isAbove(pendingRequestIndex, waitingItemIndex); if (approval.type !== "approval_request") throw new Error("expected an approval item"); assert.equal(approval.requestId, request.id); assert.include(approval.prompt ?? "", "subagent-approval.txt"); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_output.ts index f7765b03be6322d487abaec3c7d1295ef7a6ae66..28b2d173833c279a69004d3e37fd1b06daae7893 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_output.ts @@ -72,6 +72,20 @@ export function assertSubagentV2NestedApprovalOutput( const approval = approvalItems[0]!; assert.equal(approval.runId, run.id); assert.equal(approval.status, "completed"); + const pendingRequestIndex = result.domainEvents.findIndex( + (event) => + event.type === "runtime-request.updated" && + event.payload.id === request.id && + event.payload.status === "pending", + ); + const waitingItemIndex = result.domainEvents.findIndex( + (event) => + event.type === "turn-item.updated" && + event.payload.id === approval.id && + event.payload.status === "waiting", + ); + assert.isAtLeast(waitingItemIndex, 0); + assert.isAbove(pendingRequestIndex, waitingItemIndex); if (approval.type !== "approval_request") throw new Error("expected an approval item"); assert.equal(approval.requestId, request.id); assert.include(approval.prompt ?? "", "nested-approval.txt"); diff --git a/apps/server/src/orchestration-v2/unknownField.ts b/apps/server/src/orchestration-v2/unknownField.ts new file mode 100644 index 0000000000000000000000000000000000000000..94c7e9fbcb152fb295dc855f96f30650a1f1df59 --- /dev/null +++ b/apps/server/src/orchestration-v2/unknownField.ts @@ -0,0 +1,10 @@ +/** Readers for decoding undeclared provider payloads without trusting shapes. */ +export function field(source: unknown, key: string): unknown { + return typeof source === "object" && source !== null ? Reflect.get(source, key) : undefined; +} + +export function text(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} diff --git a/apps/server/src/orchestration-v2/workflowAgentAnswers.test.ts b/apps/server/src/orchestration-v2/workflowAgentAnswers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fdeb043c40d693d8a8a0d169c62b901919c10fae --- /dev/null +++ b/apps/server/src/orchestration-v2/workflowAgentAnswers.test.ts @@ -0,0 +1,121 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { it as effectIt } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; + +import { parseWorkflowAgentAnswers, readWorkflowAgentAnswers } from "./workflowAgentAnswers.ts"; + +const line = (value: unknown) => `${JSON.stringify(value)}\n`; + +const assistant = (id: string, content: ReadonlyArray) => + line({ type: "assistant", message: { role: "assistant", id, content } }); + +describe("parseWorkflowAgentAnswers", () => { + it("returns one entry per assistant turn", () => { + expect( + parseWorkflowAgentAnswers( + assistant("a", [{ type: "text", text: "first" }]) + + assistant("b", [{ type: "text", text: "second" }]), + ), + ).toEqual(["first", "second"]); + }); + + it("joins the lines of one split assistant message into a single turn", () => { + expect( + parseWorkflowAgentAnswers( + assistant("a", [{ type: "thinking", thinking: "weighing it up" }]) + + assistant("a", [{ type: "text", text: "the answer" }]) + + assistant("a", [{ type: "text", text: "and more" }]), + ), + ).toEqual(["the answer\n\nand more"]); + }); + + it("ignores harness bookkeeping and the member's own prompt", () => { + expect( + parseWorkflowAgentAnswers( + line({ type: "attachment", attachment: { type: "skill_listing", content: "- adhd" } }) + + line({ type: "user", message: { role: "user", content: "the prompt" } }) + + line({ + type: "assistant", + isMeta: true, + message: { role: "assistant", id: "m", content: [{ type: "text", text: "injected" }] }, + }) + + assistant("a", [{ type: "text", text: "real answer" }]), + ), + ).toEqual(["real answer"]); + }); + + it("skips a turn that produced no text", () => { + expect( + parseWorkflowAgentAnswers( + assistant("a", [{ type: "tool_use", id: "t", name: "Bash", input: {} }]), + ), + ).toEqual([]); + }); + + it("preserves answer indentation and ignores blank text", () => { + expect( + parseWorkflowAgentAnswers( + assistant("a", [{ type: "text", text: " indented code" }]) + + assistant("b", [{ type: "text", text: " " }]), + ), + ).toEqual([" indented code"]); + }); + + it("survives a trailing line the byte cap cut in half", () => { + expect( + parseWorkflowAgentAnswers( + assistant("a", [{ type: "text", text: "complete" }]) + '{"type":"assistant","mess', + ), + ).toEqual(["complete"]); + }); +}); + +// Transcripts must sit under /projects: readContainedWorkflowFile +// rejects any path outside it. A temporary configDir keeps the real +// ~/.claude untouched. +let configDir: string; +let transcriptDir: string; +beforeAll(() => { + configDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "wf-config-")); + transcriptDir = NodePath.join(configDir, "projects", "test"); + NodeFS.mkdirSync(transcriptDir, { recursive: true }); +}); + +afterAll(() => { + NodeFS.rmSync(configDir, { recursive: true, force: true }); +}); + +describe("readWorkflowAgentAnswers", () => { + // The under-cap case is the control: without it a read stuck on [] would pass. + effectIt.effect("reads final answers from both small and capped transcripts", () => + Effect.gen(function* () { + const turn = assistant("a", [{ type: "text", text: "the answer" }]); + const filler = line({ type: "user", message: { role: "user", content: "x".repeat(4096) } }); + const write = (agentId: string, contents: string) => + NodeFS.writeFileSync(NodePath.join(transcriptDir, `agent-${agentId}.jsonl`), contents); + const read = (agentId: string) => + readWorkflowAgentAnswers({ transcriptDir, agentId, configDir }); + write("small", turn); + const edgeAnswer = "x".repeat( + 512 * 1024 + 1 - Buffer.byteLength(assistant("edge", [{ type: "text", text: "" }])), + ); + write("edge", assistant("edge", [{ type: "text", text: edgeAnswer }])); + write( + "boundary", + filler.repeat(160) + turn + " ".repeat(512 * 1024 - Buffer.byteLength(turn)), + ); + write( + "huge", + turn + filler.repeat(160) + assistant("final", [{ type: "text", text: "final answer" }]), + ); + expect(yield* read("small")).toEqual(["the answer"]); + expect(yield* read("edge")).toEqual([edgeAnswer]); + expect(yield* read("huge")).toEqual(["final answer"]); + expect(yield* read("boundary")).toEqual(["the answer"]); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/workflowAgentAnswers.ts b/apps/server/src/orchestration-v2/workflowAgentAnswers.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e8fd9edf5cf8ed880d4678750448325feddafa5 --- /dev/null +++ b/apps/server/src/orchestration-v2/workflowAgentAnswers.ts @@ -0,0 +1,97 @@ +// Path joining only; the filesystem access lives in readContainedWorkflowFile. +// @effect-diagnostics nodeBuiltinImport:off +/** + * Reads what a workflow member actually answered. + * + * A member agent never becomes a provider task and emits nothing on the event + * stream, so the transcript the harness writes is the only full record of its + * turn — the progress snapshot carries a capped excerpt. The path is derived + * here from the run's transcript directory and the member's provider id rather + * than accepted from the caller, and the read itself is contained by + * readContainedWorkflowFile. + */ +import * as NodePath from "node:path"; + +import { OrchestrationWorkflowFileError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { field, text } from "./unknownField.ts"; +import { readContainedWorkflowFile } from "./workflowFileRead.ts"; + +const TRANSCRIPT_BYTE_CAP = 512 * 1024; + +/** A member id names a file, so anything path-shaped is rejected outright. */ +const AGENT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; + +/** + * Collects the assistant text of each turn in the harness's JSONL. + * + * Two shapes have to be handled: `attachment` lines and `isMeta` user lines are + * harness bookkeeping (hook output, skill listings, injected context) and are + * never conversation, and one assistant API message is split across several + * lines sharing a `message.id`, so those are joined into a single turn rather + * than read as separate ones. + */ +export function parseWorkflowAgentAnswers(contents: string): ReadonlyArray { + const turns: string[] = []; + const turnIndexById = new Map(); + + for (const line of contents.split("\n")) { + if (line.trim().length === 0) continue; + let entry: unknown; + try { + entry = JSON.parse(line); + } catch { + // A capped read can cut the final line mid-object; drop it and keep the rest. + continue; + } + if (text(field(entry, "type")) !== "assistant") continue; + if (field(entry, "isMeta") === true) continue; + + const message = field(entry, "message"); + const content = field(message, "content"); + if (!Array.isArray(content)) continue; + const body = content + .filter((block) => text(field(block, "type")) === "text") + .map((block) => field(block, "text")) + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .join("\n\n"); + if (body.length === 0) continue; + + const messageId = text(field(message, "id")); + const existing = messageId === undefined ? undefined : turnIndexById.get(messageId); + if (existing !== undefined) { + turns[existing] = `${turns[existing]}\n\n${body}`; + continue; + } + if (messageId !== undefined) turnIndexById.set(messageId, turns.length); + turns.push(body); + } + + return turns; +} + +export const readWorkflowAgentAnswers = Effect.fn("orchestration.readWorkflowAgentAnswers")( + function* (input: { + readonly transcriptDir: string; + readonly agentId: string; + readonly configDir?: string; + }) { + if (!AGENT_ID_PATTERN.test(input.agentId)) { + return yield* new OrchestrationWorkflowFileError({ + reason: "invalid-path", + path: input.agentId, + }); + } + const file = yield* readContainedWorkflowFile({ + path: NodePath.join(input.transcriptDir, `agent-${input.agentId}.jsonl`), + extension: ".jsonl", + byteCap: TRANSCRIPT_BYTE_CAP, + tail: true, + ...(input.configDir === undefined ? {} : { configDir: input.configDir }), + }); + return parseWorkflowAgentAnswers( + file.truncated ? file.contents.slice(file.contents.indexOf("\n") + 1) : file.contents, + ); + }, +); diff --git a/apps/server/src/orchestration-v2/workflowFileRead.ts b/apps/server/src/orchestration-v2/workflowFileRead.ts new file mode 100644 index 0000000000000000000000000000000000000000..86c9496394023f6f0835ba7aa8a35730583b9af3 --- /dev/null +++ b/apps/server/src/orchestration-v2/workflowFileRead.ts @@ -0,0 +1,112 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Contained reads of files the Claude harness persisted for a workflow run. + * + * Containment rules (lifted from the reviewed #3650 inspection service): + * - the resolved realpath must live under the configured Claude projects + * directory (where the harness persists workflow scripts and transcripts) — + * realpath re-containment defeats symlink escapes, including a symlinked + * leaf file; + * - only the caller's expected extension is served; + * - reads are size-capped rather than failed, with a truncation marker. + * + * The client-supplied path is a hint from the workflow's runHandles; it is + * never trusted beyond these checks. + */ +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import { OrchestrationWorkflowFileError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +function workflowFilesRoot(home: string, configDir?: string): string { + return NodePath.join(configDir?.trim() || NodePath.join(home, ".claude"), "projects"); +} + +export const readContainedWorkflowFile = Effect.fn("orchestration.readContainedWorkflowFile")( + function* (input: { + readonly path: string; + readonly extension: string; + readonly byteCap: number; + readonly tail?: boolean; + readonly configDir?: string; + }) { + if (!NodePath.isAbsolute(input.path) || NodePath.extname(input.path) !== input.extension) { + return yield* new OrchestrationWorkflowFileError({ + reason: "invalid-path", + path: input.path, + }); + } + + const home = yield* HostProcess.HomeDirectory; + const root = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(workflowFilesRoot(home, input.configDir)), + catch: (cause) => + new OrchestrationWorkflowFileError({ + reason: "root-unavailable", + path: input.path, + cause, + }), + }); + + // Realpath the FILE itself (not just its directory): a symlink named + // like a transcript inside a contained directory must not escape. + const resolved = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(input.path), + catch: (cause) => + new OrchestrationWorkflowFileError({ reason: "not-found", path: input.path, cause }), + }); + + if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) { + return yield* new OrchestrationWorkflowFileError({ reason: "outside-root", path: resolved }); + } + if (NodePath.extname(resolved) !== input.extension) { + return yield* new OrchestrationWorkflowFileError({ + reason: "wrong-extension", + path: resolved, + }); + } + + // fd inode vs path inode catches a swapped leaf, not a swapped intermediate + // dir; root is 0700 ~/.claude, so that already needs the uid we run as. + // Both containment checks fail with their own tagged reason; "read-failed" + // is reserved for genuine platform failures with the real cause attached. + const read = yield* Effect.tryPromise({ + try: async () => { + const handle = await NodeFSP.open( + resolved, + NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NONBLOCK ?? 0), + ); + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + return { failure: "not-regular-file" as const }; + } + const pathStat = await NodeFSP.lstat(resolved); + if (stat.ino !== pathStat.ino || stat.dev !== pathStat.dev) { + return { failure: "changed-during-read" as const }; + } + const buffer = Buffer.alloc(Math.min(stat.size, input.byteCap + (input.tail ? 1 : 0))); + const truncated = stat.size > buffer.length; + const offset = input.tail ? stat.size - buffer.length : 0; + const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset); + return { + contents: buffer.subarray(0, bytesRead).toString("utf8"), + truncated, + }; + } finally { + await handle.close(); + } + }, + catch: (cause) => + new OrchestrationWorkflowFileError({ reason: "read-failed", path: resolved, cause }), + }); + if ("failure" in read) { + return yield* new OrchestrationWorkflowFileError({ reason: read.failure, path: resolved }); + } + + return { path: resolved, contents: read.contents, truncated: read.truncated }; + }, +); diff --git a/apps/server/src/orchestration-v2/workflowScriptQuery.test.ts b/apps/server/src/orchestration-v2/workflowScriptQuery.test.ts index 311b894276972b68ccc1a2f55c04b7a78e7befb7..39f14c53b85cc2e79a185bd3191c1c4d8710558c 100644 --- a/apps/server/src/orchestration-v2/workflowScriptQuery.test.ts +++ b/apps/server/src/orchestration-v2/workflowScriptQuery.test.ts @@ -2,6 +2,9 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; +import { OrchestrationWorkflowFileError } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { it as effectIt } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { afterAll, assert, describe } from "vite-plus/test"; @@ -30,7 +33,18 @@ afterAll(() => { NodeFS.rmSync(outside, { force: true }); }); -describe("readWorkflowScript containment", () => { +describe("workflow script containment", () => { + effectIt.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "rejects a FIFO without waiting for a writer", + () => + Effect.gen(function* () { + const fifo = NodePath.join(root, "pipe.js"); + NodeChildProcess.execFileSync("mkfifo", [fifo]); + const error = yield* Effect.flip(readWorkflowScript({ scriptPath: fifo })); + assert.equal(error.reason, "not-regular-file"); + }), + ); + effectIt.effect("serves a real script under the projects root", () => Effect.gen(function* () { const result = yield* readWorkflowScript({ scriptPath }); @@ -47,6 +61,12 @@ describe("readWorkflowScript containment", () => { readWorkflowScript({ scriptPath: scriptPath.replace(".js", ".ts") }), ); assert.equal(nonJs._tag, "Failure"); + const error = yield* Effect.flip(readWorkflowScript({ scriptPath: "run.js" })); + assert.equal(error._tag, "OrchestrationGetWorkflowScriptError"); + assert.equal(error.scriptPath, "run.js"); + assert.equal(error.reason, "invalid-path"); + assert.equal(error.message, "Workflow scripts must be absolute .js paths."); + assert.instanceOf(error.cause, OrchestrationWorkflowFileError); }), ); diff --git a/apps/server/src/orchestration-v2/workflowScriptQuery.ts b/apps/server/src/orchestration-v2/workflowScriptQuery.ts index ef2ea88c574a6213cfe05a996ccc1c605353be1b..1775202a79a3b6ea7cb94c25d0fdd3a3308e8420 100644 --- a/apps/server/src/orchestration-v2/workflowScriptQuery.ts +++ b/apps/server/src/orchestration-v2/workflowScriptQuery.ts @@ -1,128 +1,23 @@ -// @effect-diagnostics nodeBuiltinImport:off -/** - * Read-only access to persisted workflow scripts for the Agents surface's - * "{} script" affordance. - * - * Containment rules (lifted from the reviewed #3650 inspection service): - * - the resolved realpath must live under ~/.claude/projects (where the - * Claude harness persists workflow scripts) — realpath re-containment - * defeats symlink escapes, including a symlinked leaf file; - * - only .js leaf files are served; - * - reads are size-capped rather than failed, with a truncation marker. - * - * The client-supplied path is a hint from the workflow's runHandles; it is - * never trusted beyond these checks. - */ -import * as NodeFSP from "node:fs/promises"; -import * as NodePath from "node:path"; - import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as HostProcess from "@t3tools/shared/HostProcess"; - -const SCRIPT_BYTE_CAP = 256 * 1024; - -function scriptsRoot(home: string): string { - return NodePath.join(home, ".claude", "projects"); -} +import { readContainedWorkflowFile } from "./workflowFileRead.ts"; export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: { readonly scriptPath: string; }) { - const requested = input.scriptPath; - - if (!NodePath.isAbsolute(requested) || NodePath.extname(requested) !== ".js") { - return yield* new OrchestrationGetWorkflowScriptError({ - reason: "invalid-path", - scriptPath: requested, - }); - } - - const home = yield* HostProcess.HomeDirectory; - const root = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(scriptsRoot(home)), - catch: (cause) => - new OrchestrationGetWorkflowScriptError({ - reason: "root-unavailable", - scriptPath: requested, - cause, - }), - }); - - // Realpath the FILE itself (not just its directory): a symlink named - // like a script inside a contained directory must not escape. - const resolved = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(requested), - catch: (cause) => - new OrchestrationGetWorkflowScriptError({ - reason: "not-found", - scriptPath: requested, - cause, - }), - }); - - if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) { - return yield* new OrchestrationGetWorkflowScriptError({ - reason: "outside-root", - scriptPath: resolved, - }); - } - if (NodePath.extname(resolved) !== ".js") { - return yield* new OrchestrationGetWorkflowScriptError({ - reason: "not-js", - scriptPath: resolved, - }); - } - - // TOCTOU-safe read (review finding): open FIRST, then verify what was - // actually opened via the file descriptor. Re-checking the path after - // open would race against a swap; fstat on the handle cannot. The two - // containment checks fail with their own tagged reasons (not manufactured - // Errors folded into read-failed); "read-failed" is reserved for genuine - // platform failures with the real cause attached. - const read = yield* Effect.tryPromise({ - try: async () => { - const handle = await NodeFSP.open(resolved, "r"); - try { - const stat = await handle.stat(); - if (!stat.isFile()) { - return { failure: "not-regular-file" as const }; - } - // The opened inode must be the same one realpath resolved to: a - // process swapping the path between realpath and open changes the - // inode, which this comparison catches. - const pathStat = await NodeFSP.lstat(resolved); - if (stat.ino !== pathStat.ino || stat.dev !== pathStat.dev) { - return { failure: "changed-during-read" as const }; - } - const truncated = stat.size > SCRIPT_BYTE_CAP; - const buffer = Buffer.alloc(Math.min(stat.size, SCRIPT_BYTE_CAP)); - const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); - return { - contents: buffer.subarray(0, bytesRead).toString("utf8"), - truncated, - }; - } finally { - await handle.close(); - } - }, - catch: (cause) => - new OrchestrationGetWorkflowScriptError({ - reason: "read-failed", - scriptPath: resolved, - cause, - }), - }); - if ("failure" in read) { - return yield* new OrchestrationGetWorkflowScriptError({ - reason: read.failure, - scriptPath: resolved, - }); - } - - return { - scriptPath: resolved, - contents: read.contents, - truncated: read.truncated, - }; + const file = yield* readContainedWorkflowFile({ + path: input.scriptPath, + extension: ".js", + byteCap: 256 * 1024, + }).pipe( + Effect.mapError( + (error) => + new OrchestrationGetWorkflowScriptError({ + reason: error.reason === "wrong-extension" ? "not-js" : error.reason, + scriptPath: error.path, + cause: error, + }), + ), + ); + return { scriptPath: file.path, contents: file.contents, truncated: file.truncated }; }); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index f10af26391c678bde457aa6a81344082a17dda6c..bc26ef7f021ae3afb57f60dac5d01029c78a029c 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -212,6 +212,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( settleAfterRun: unused, settleThread: unused, interruptThread: unused, + stopWorkflow: () => Effect.die("unused stopWorkflow"), stopDelegatedTasks: unused, getThreadEventSequence: unused, recoverDelegatedTask: unused, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f3606bdb83c13123a2b52102d2af0f5eb2b3b2b1..61bad3faf318498c8f901f8d5cb7f1f00640ccb9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1864,6 +1864,7 @@ const layerWsRpc = ( ), ), ), + [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: (input) => threadManagement.stopWorkflow(input), [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) => readWorkflowScript({ scriptPath: input.scriptPath }), [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) => diff --git a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1d85211db9bb3f6d0f712f0bc50c1ec87800a18b --- /dev/null +++ b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx @@ -0,0 +1,266 @@ +import type { + SubagentWorkflowGroup, + RuntimeSubagent, +} from "@t3tools/client-runtime/state/subagentRuntime"; +import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; +import { ChevronDownIcon, LoaderCircleIcon, SquareIcon } from "lucide-react"; +import { useState, type ReactNode } from "react"; + +import { AgentElapsed } from "./AgentElapsed"; +import { SubagentTooltipContent } from "./SubagentTooltipContent"; +import { ThreadHoverCardPopup } from "../ThreadHoverCard"; +import { ThreadRelationshipIcon } from "./ThreadRelationshipIcon"; +import { cn } from "../../lib/utils"; +import { ThreadDetailsControl } from "./ThreadDetailsControl"; +import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, + THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, +} from "./threadDetailsPanelStyles"; + +/** + * Wall clock for the phase: its members run in parallel, so the span from the + * first start to the last finish is the time the phase actually took. Shaped + * for AgentElapsed, which ticks it while the phase is still running. + */ +function phaseElapsed(phase: SubagentWorkflowGroup["phases"][number]) { + const instants = (key: "startedAt" | "completedAt") => + phase.members + .map((member) => member[key]) + .filter((value) => value !== null) + .sort(); + const running = phase.state === "running"; + const startedAt = instants("startedAt")[0] ?? null; + const completedAt = running ? null : (instants("completedAt").at(-1) ?? null); + return { + status: running ? ("running" as const) : ("completed" as const), + // A settled phase whose members never reported an end has no span to show. + startedAt: running || completedAt !== null ? startedAt : null, + completedAt, + }; +} + +/** Phase state for the header's label; the tone and member badges show it visually. */ +function phaseStatusLabel(phase: SubagentWorkflowGroup["phases"][number]) { + if (phase.state === "running") return "running"; + if (phase.members.some((member) => member.status === "failed")) return "failed"; + if ( + phase.members.some((member) => member.status === "cancelled" || member.status === "interrupted") + ) { + return "stopped"; + } + return phase.state === "done" ? "done" : "not started"; +} + +/** Members run under the coordinator's provider, so they share its glyph. */ +function WorkflowMemberRow({ + member, + providerInstanceId, + provider, + providers, + driver, + onOpen, + isThreadAvailable, +}: { + member: RuntimeSubagent; + providerInstanceId: ProviderInstanceId; + provider: ServerProvider | undefined; + providers: ReadonlyArray | undefined; + driver: ProviderDriverKind | undefined; + onOpen: (threadId: string) => void; + isThreadAvailable: (threadId: string) => boolean; +}) { + const threadId = member.childThreadId; + const canOpen = threadId !== null && isThreadAvailable(threadId); + return ( +
  • + + canOpen && onOpen(threadId)} + /> + } + > + + {member.title} + {member.status} + {member.startedAt ? ( + + + + ) : null} + + + } + status={member.status} + result={member.result ?? member.error} + progress={member.progress} + /> + + +
  • + ); +} + +export function ThreadLineageWorkflowRow({ + group, + header, + providerInstanceId, + provider, + providers, + driver, + onOpenThread, + isThreadAvailable, + onStop, + stopping, + stopDisabled, +}: { + readonly group: SubagentWorkflowGroup; + readonly header: ReactNode; + readonly providerInstanceId: ProviderInstanceId; + readonly provider: ServerProvider | undefined; + readonly providers: ReadonlyArray | undefined; + readonly driver: ProviderDriverKind | undefined; + readonly onOpenThread: (threadId: string) => void; + readonly isThreadAvailable: (threadId: string) => boolean; + readonly onStop?: (() => void) | undefined; + readonly stopping: boolean; + readonly stopDisabled: boolean; +}) { + const [expanded, setExpanded] = useState(false); + // The running phase opens itself until the user picks a side for it. + const [phaseOpen, setPhaseOpen] = useState>(() => new Map()); + const label = group.workflow.workflowName ?? group.workflow.title; + const phases = group.phases.filter((phase) => phase.members.length > 0); + return ( + // The flag lets the lineage list trade its compact height for the open tree. +
  • +
    + {header} + {onStop ? ( +
    + + + } + > + {stopping ? ( + + ) : ( + + )} + + Stop entire workflow + +
    + ) : null} +
    + {expanded ? ( +
      + {phases.map((phase) => { + const open = phaseOpen.get(phase.index) ?? phase.state === "running"; + const status = phaseStatusLabel(phase); + return ( +
    • + setPhaseOpen((phases) => new Map(phases).set(phase.index, !open))} + accessory={ + <> + {status === "failed" ? ( + Failed + ) : null} + + {phase.settledCount}/{phase.members.length}{" "} + {phase.members.length === 1 ? "agent" : "agents"} + + + + + + } + > + {phase.title} + + {open ? ( +
        + {phase.members.map((member) => ( + + ))} +
      + ) : null} +
    • + ); + })} + {group.unphasedMembers.map((member) => ( + + ))} + {phases.length === 0 && group.unphasedMembers.length === 0 ? ( +
    • No agents yet
    • + ) : null} +
    + ) : null} +
  • + ); +} diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx index 16021e9ce71b11d858c1fbde859f1a22a65ff58a..d7793287516bd4f5a88ba67905c6b7469dbf950c 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx @@ -18,6 +18,8 @@ const state = vi.hoisted(() => ({ configs: new Map(), showTooltips: false, command: vi.fn().mockResolvedValue({ _tag: "Success" }), + workflowCommand: vi.fn().mockResolvedValue({ _tag: "Success" }), + toast: vi.fn(), })); vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate })); @@ -30,7 +32,25 @@ vi.mock("../../state/entities", () => ({ vi.mock("../../lib/archivedThreadsState", () => ({ useArchivedThreadSnapshots: () => ({ snapshots: [] }), })); -vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.command })); +vi.mock("../../state/use-atom-command", () => ({ + useAtomCommand: (command: { label: string }) => + command.label === "environment-data:orchestration:stop-workflow" + ? state.workflowCommand + : state.command, +})); +vi.mock("../ui/toast", () => ({ toastManager: { add: state.toast } })); +vi.mock("../../state/orchestration", async () => { + const { Atom } = await import("effect/reactivity"); + const granted = Atom.make(true); + return { + orchestrationEnvironment: { + stopWorkflow: { + label: "environment-data:orchestration:stop-workflow", + permissionAtom: () => granted, + }, + }, + }; +}); vi.mock("../ui/tooltip", () => ({ Tooltip: ({ children }: { children: ReactNode }) => children, TooltipTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) => @@ -49,17 +69,423 @@ afterEach(async () => { state.projects = []; state.configs.clear(); state.showTooltips = false; + state.navigate.mockClear(); state.command.mockClear(); + state.workflowCommand.mockReset().mockResolvedValue({ _tag: "Success" }); + state.toast.mockClear(); state.projection = null; }); +it("stops the entire native workflow through its parent without changing state before acknowledgement", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const coordinator = { + id: "workflow", + origin: "provider_native", + driver: "claudeAgent", + providerInstanceId: "claude-work", + childThreadId: "workflow-chat", + title: "Checkout review", + status: "running", + startedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), + completedAt: null, + updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), + workflow: { + name: "Checkout review", + phases: [{ index: 0, title: "Inspect" }], + agents: [{ index: 0, label: "Checker", state: "running", phaseIndex: 0 }], + }, + }; + state.projection = { + thread: { id: "parent", lineage: { relationshipToParent: null } }, + runs: [], + providerThreads: [], + providerSessions: [], + contextTransfers: [], + subagents: [ + coordinator, + { + ...coordinator, + id: "worker", + childThreadId: "worker-chat", + origin: "app_owned", + title: "Worker", + workflow: undefined, + }, + ], + }; + state.shells = [ + { + environmentId: "remote", + source: { + id: "workflow-chat", + title: "Coordinator chat", + lineage: { parentThreadId: "parent", relationshipToParent: "subagent" }, + activityRunStatus: "waiting", + }, + }, + ]; + await act(async () => { + renderer = create( + , + ); + }); + await act(async () => + renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(), + ); + const stopButton = () => + renderer.root.findByProps({ "aria-label": "Stop workflow Checkout review" }); + expect( + renderer.root.findAll( + (node) => node.type === "button" && String(node.props["aria-label"]).startsWith("Stop "), + ), + ).toHaveLength(2); + let acknowledge!: (result: { _tag: string }) => void; + state.workflowCommand.mockImplementationOnce( + () => + new Promise((resolve) => { + acknowledge = resolve; + }), + ); + await act(async () => { + stopButton().props.onClick(); + }); + expect(state.workflowCommand).toHaveBeenCalledExactlyOnceWith({ + environmentId: "remote", + input: { threadId: "parent", subagentId: "workflow" }, + }); + expect(state.command).not.toHaveBeenCalled(); + expect(state.navigate).not.toHaveBeenCalled(); + expect(stopButton().props.disabled).toBe(true); + expect(renderer.root.findByProps({ "aria-label": "Stop subagent Worker" }).props.disabled).toBe( + true, + ); + expect(renderer.root.findByProps({ "aria-label": "Open Checker chat" })).toBeDefined(); + expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 2 running"]); + await act(async () => { + acknowledge({ _tag: "Success" }); + }); + expect(stopButton().props.disabled).toBe(false); + expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 2 running"]); + state.workflowCommand.mockResolvedValueOnce({ _tag: "Failure" }); + await act(async () => { + stopButton().props.onClick(); + }); + expect(state.toast).toHaveBeenCalledExactlyOnceWith({ + type: "error", + title: "Could not stop workflow", + }); + expect(stopButton().props.disabled).toBe(false); + expect(state.command).not.toHaveBeenCalled(); + expect(state.navigate).not.toHaveBeenCalled(); +}); + it.each([ - { driver: "codex", origin: "app_owned" }, - { driver: "claudeAgent", origin: "app_owned" }, - { driver: "claudeAgent", origin: "provider_native" }, + ["claudeAgent", "provider_native", "completed"], + ["claudeAgent", "provider_native", "failed"], + ["claudeAgent", "provider_native", "interrupted"], + ["claudeAgent", "provider_native", "waiting"], + ["codex", "provider_native", "running"], + ["claudeAgent", "app_owned", "running"], +])("does not offer workflow stop for %s %s %s", async (driver, origin, status) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.projection = { + thread: { id: "parent", lineage: { relationshipToParent: null } }, + runs: [], + providerThreads: [], + providerSessions: [], + contextTransfers: [], + subagents: [ + { + id: "workflow", + driver, + origin, + status, + providerInstanceId: "claude-work", + childThreadId: "workflow-chat", + title: "Checkout review", + startedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), + completedAt: null, + updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), + workflow: { name: "Checkout review", phases: [], agents: [] }, + }, + ], + }; + await act(async () => { + renderer = create( + , + ); + }); + const previousAgents = renderer.root.findAll( + (node) => + node.type === "button" && node.props["aria-expanded"] === false && !node.props["aria-label"], + ); + if (previousAgents.length > 0) await act(async () => previousAgents[0]!.props.onClick()); + expect(renderer.root.findByProps({ "aria-label": "Expand Checkout review" })).toBeDefined(); + expect( + renderer.root.findAllByProps({ "aria-label": "Stop workflow Checkout review" }), + ).toHaveLength(0); + expect(state.workflowCommand).not.toHaveBeenCalled(); +}); + +it("opens the correct chat for every workflow phase and unphased member", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.showTooltips = true; + state.configs.set("remote", { + providers: [ + { + instanceId: "claude-personal", + driver: "claudeAgent", + displayName: "Personal account", + models: [], + }, + { + instanceId: "claude-work", + driver: "claudeAgent", + displayName: "Work account", + models: [{ slug: "claude-sonnet-4-6", name: "Sonnet" }], + }, + ], + }); + const phases = ["Inspect", "Improve", "Verify"].map((title, index) => ({ index, title })); + const agents = Array.from({ length: 7 }, (_, index) => ({ + index, + label: `Member ${index}`, + state: + index === 2 ? "cancelled" : index === 4 ? "failed" : index === 5 ? "running" : "completed", + ...(index < 6 ? { phaseIndex: Math.floor(index / 2) } : {}), + childThreadId: `member-chat-${index}`, + model: "claude-sonnet-4-6", + result: `Result ${index}`, + totalTokens: 1200, + // Staggered starts, so a phase spans longer than either member's own run. + startedAt: 1_700_000_000_000 + index * 1000, + durationMs: 5000, + })); + const project = (members: typeof agents) => ({ + thread: { id: "parent", lineage: { relationshipToParent: null } }, + runs: [], + providerThreads: [], + providerSessions: [], + contextTransfers: [], + subagents: [ + { + id: "workflow", + driver: "claudeAgent", + providerInstanceId: "claude-work", + childThreadId: "workflow-chat", + title: "Checkout review", + status: "running", + startedAt: null, + completedAt: null, + updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), + workflow: { name: "Checkout review", phases, agents: members }, + }, + ], + }); + state.projection = project(agents); + // Matching IDs from another environment cannot make these chats available. + const memberShells = agents.map((agent) => ({ + id: agent.childThreadId, + title: agent.label, + lineage: { parentThreadId: "workflow-chat", relationshipToParent: "subagent" }, + status: "completed", + activityRunStatus: null, + })); + state.shells = memberShells.map((source) => ({ environmentId: "other", source })); + const panel = ( + + ); + await act(async () => { + renderer = create(panel); + }); + await act(async () => + renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(), + ); + const memberButtons = () => + renderer.root.findAll( + (node) => + node.type === "button" && String(node.props["aria-label"]).startsWith("Open Member"), + ); + const phaseButtons = () => + renderer.root.findAll( + (node) => + node.type === "button" && + typeof node.props["aria-expanded"] === "boolean" && + String(node.props["aria-label"]).includes(" phase, "), + ); + // Only the running phase starts open, beside the unphased member. + expect(memberButtons()).toHaveLength(3); + expect(phaseButtons()).toHaveLength(3); + expect(renderer.root.findByProps({ "aria-label": "Expand Inspect phase, done" })).toBeDefined(); + expect( + renderer.root.findByProps({ "aria-label": "Expand Improve phase, stopped" }), + ).toBeDefined(); + expect( + renderer.root.findByProps({ "aria-label": "Collapse Verify phase, running" }), + ).toBeDefined(); + for (const phase of phaseButtons().slice(0, 2)) await act(async () => phase.props.onClick()); + expect(memberButtons()).toHaveLength(7); + for (const button of memberButtons()) { + expect(button.props.disabled).toBe(true); + await act(async () => button.props.onClick()); + } + expect(state.navigate).not.toHaveBeenCalled(); + // The coordinator roster arrives before the member thread shells. + state.shells = memberShells.map((source) => ({ environmentId: "remote", source })); + await act(async () => renderer.update(cloneElement(panel))); + for (const button of memberButtons()) expect(button.props.disabled).toBe(false); + for (const agent of agents) { + await act(async () => + renderer.root.findByProps({ "aria-label": `Open ${agent.label} chat` }).props.onClick(), + ); + expect(state.navigate).toHaveBeenLastCalledWith({ + to: "/$environmentId/$threadId", + params: { environmentId: "remote", threadId: agent.childThreadId }, + }); + } + const rendered = JSON.stringify(renderer.toJSON()); + expect(rendered.match(/Sonnet · Work account/g)).toHaveLength(7); + expect(rendered).not.toContain("Personal account"); + for (const phase of phases) expect(rendered).toContain(phase.title); + // The closed row leaves phase progress to the tree. + expect(rendered).not.toContain(" phases"); + expect(rendered).toContain('"1","/","2"," ","agents"'); + expect(rendered).toContain("Running"); + // The phase holding the running member reports itself as the active one. + expect(rendered).toContain("running"); + expect(rendered).toContain("done"); + expect(rendered).toContain("stopped"); + // Members 0 and 1 start a second apart and run 5s each, so Inspect took 6s. + expect(rendered).toContain('"6s"'); + for (const phase of phaseButtons()) { + const label = String(phase.props["aria-label"]).slice("Collapse ".length); + await act(async () => phase.props.onClick()); + expect(memberButtons()).toHaveLength(5); + await act(async () => + renderer.root.findByProps({ "aria-label": `Expand ${label}` }).props.onClick(), + ); + expect(memberButtons()).toHaveLength(7); + } + // A phase the user closed stays closed once it starts running, and stays + // closed after it settles. + await act(async () => phaseButtons()[0]!.props.onClick()); + expect(memberButtons()).toHaveLength(5); + const restarted = agents.map((agent) => + agent.index === 0 ? { ...agent, state: "running" } : agent, + ); + state.projection = project(restarted); + await act(async () => renderer.update(cloneElement(panel))); + expect(memberButtons()).toHaveLength(5); + state.projection = project(agents); + await act(async () => renderer.update(cloneElement(panel))); + expect(memberButtons()).toHaveLength(5); + await act(async () => + renderer.root.findByProps({ "aria-label": "Collapse Checkout review" }).props.onClick(), + ); + expect(memberButtons()).toHaveLength(0); + expect(state.navigate).toHaveBeenCalledTimes(7); +}); + +it("groups and counts a workflow by its run status instead of its coordinator chat", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.showTooltips = true; + const coordinator = { + id: "workflow", + driver: "claudeAgent", + providerInstanceId: "claudeAgent", + childThreadId: "workflow-chat", + title: "Checkout review", + status: "running", + startedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), + progress: "Inspecting the checkout workflow", + result: "Workflow checks complete", + completedAt: null, + updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), + workflow: { + name: "Checkout review", + phases: [{ index: 0, title: "Inspect" }], + agents: [{ index: 0, label: "Checker", state: "running", phaseIndex: 0 }], + }, + }; + const projection = { + thread: { id: "parent", lineage: { relationshipToParent: null } }, + runs: [], + providerThreads: [], + providerSessions: [], + contextTransfers: [], + subagents: [coordinator], + }; + const child = { + id: "workflow-chat", + title: "Coordinator chat", + lineage: { parentThreadId: "parent", relationshipToParent: "subagent" }, + status: "completed", + activityRunStatus: null, + }; + state.projection = projection; + state.shells = [{ environmentId: "test", source: child }]; + const panel = ( + + ); + await act(async () => { + renderer = create(panel); + }); + const text = () => + renderer.root + .findAll((node) => typeof node.type === "string") + .flatMap((node) => node.children.filter((child) => typeof child === "string")) + .join(""); + expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 1 running"]); + await act(async () => + renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(), + ); + expect(renderer.root.findByProps({ "aria-label": "Open Checker chat" })).toBeDefined(); + state.shells = [{ environmentId: "test", source: { ...child, activityRunStatus: "waiting" } }]; + await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 1 running"]); + expect(text()).toContain("Inspecting the checkout workflow"); + state.projection = { + ...projection, + subagents: [ + { + ...coordinator, + status: "completed", + completedAt: DateTime.makeUnsafe("2026-09-21T12:10:00Z"), + }, + ], + }; + await act(async () => renderer.update(cloneElement(panel))); + expect(renderer.root.findByType("h3").children).toEqual(["Lineage"]); + expect(text()).toContain("Previous agents"); + expect(renderer.root.findAllByProps({ "aria-label": "Open Checker chat" })).toHaveLength(0); + await act(async () => + renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(), + ); + expect(renderer.root.findByProps({ "aria-label": "Expand Checkout review" })).toBeDefined(); + expect(text()).toContain("Workflow checks complete"); + expect(text()).toContain("10m"); +}); + +it.each([ + { driver: "codex", origin: "app_owned", runId: null, canStop: true }, + { driver: "claudeAgent", origin: "app_owned", runId: null, canStop: true }, + { driver: "claudeAgent", origin: "provider_native", runId: "run-1", canStop: true }, + { driver: "claudeAgent", origin: "provider_native", runId: null, canStop: false }, ])( - "stops active $origin $driver subagents without opening their thread", - async ({ driver, origin }) => { + "offers individual Stop for supported $origin $driver subagents with run $runId", + async ({ driver, origin, runId, canStop }) => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const parent = { id: "parent", @@ -76,6 +502,7 @@ it.each([ childThreadId: "child", origin, threadId: "parent", + runId, nativeTaskRef: { driver, nativeId: "claude-task", strength: "strong" }, driver, providerInstanceId: "codex", @@ -108,6 +535,12 @@ it.each([ await act(async () => { renderer = create(panel); }); + if (!canStop) { + expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength( + 0, + ); + return; + } const stopButton = () => renderer.root.findByProps({ "aria-label": "Stop subagent Worker" }); await act(async () => stopButton().props.onClick()); expect(state.command).toHaveBeenCalledWith({ @@ -397,11 +830,16 @@ it("shows readable models and only differing workspace details in agent tooltips }; return read(renderer.root); }; - expect(text()).toContain("My GPT · high"); - state.projection = { + const rerender = async (source = child) => { + state.shells = [{ environmentId: "test", source: { ...source } }]; + await act(async () => renderer.update(cloneElement(panel))); + }; + const nativeProjection = { ...projection, subagents: [{ ...projection.subagents[0], origin: "provider_native" }], }; + expect(text()).toContain("My GPT · high"); + state.projection = nativeProjection; await act(async () => renderer.update(cloneElement(panel))); expect(text()).toContain("My GPT"); expect(text()).not.toContain("My GPT · high"); @@ -467,8 +905,7 @@ it("shows readable models and only differing workspace details in agent tooltips [undefined, ""], ] as const) { child.modelSelection.options = options; - state.shells = [{ environmentId: "test", source: { ...child } }]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender(); expect(text()).toContain(`My GPT${expected}`); if (!expected) expect(text()).not.toContain("My GPT ·"); } @@ -532,8 +969,7 @@ it("shows readable models and only differing workspace details in agent tooltips { id: "reasoningEffort", value: "high" }, ...(value === undefined ? [] : [{ id: descriptor.id, value }]), ]; - state.shells = [{ environmentId: "test", source: { ...child } }]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender(); expect(text(true)).toContain("My GPT · high"); expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/); expect(text()).toContain(`My GPT · ${iconLabel}high`); @@ -541,27 +977,11 @@ it("shows readable models and only differing workspace details in agent tooltips child.modelSelection.options = child.modelSelection.options.filter( ({ id }) => id !== "reasoningEffort", ); - state.shells = [{ environmentId: "test", source: { ...child } }]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender(); expect(text()).not.toContain(" · high"); expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/); if (iconLabel) expect(text()).toContain(iconLabel); else expect(text()).not.toContain("mode on"); - state.projection = { - ...projection, - subagents: [{ ...projection.subagents[0], origin: "provider_native" }], - }; - await act(async () => renderer.update(cloneElement(panel))); - expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/); - state.projection = projection; - for (const modelSelection of [ - { ...child.modelSelection, instanceId: "other" }, - { ...child.modelSelection, model: "gpt-5.5" }, - ]) { - state.shells = [{ environmentId: "test", source: { ...child, modelSelection } }]; - await act(async () => renderer.update(cloneElement(panel))); - expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/); - } } state.configs.set("test", { providers: [ @@ -589,11 +1009,21 @@ it("shows readable models and only differing workspace details in agent tooltips { id: "reasoningEffort", value: "high" }, { id: "serviceTier", value: "priority" }, ]; - state.shells = [{ environmentId: "test", source: { ...child } }]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender(); expect(text(true)).toContain("My GPT · Work account · high"); expect(text()).toContain("My GPT · Work account · Fast mode onhigh"); expect(text()).not.toContain("Personal account"); + state.projection = nativeProjection; + await act(async () => renderer.update(cloneElement(panel))); + expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/); + state.projection = projection; + for (const modelSelection of [ + { ...child.modelSelection, instanceId: "other" }, + { ...child.modelSelection, model: "gpt-5.5" }, + ]) { + await rerender({ ...child, modelSelection }); + expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/); + } state.shells = []; for (const status of ["running", "completed", "failed"] as const) { state.projection = { @@ -629,21 +1059,14 @@ it("shows readable models and only differing workspace details in agent tooltips state.projection = projection; state.configs.set("test", speedConfig); child.modelSelection.options = [{ id: "reasoningEffort", value: "high" }]; - state.shells = [ - { - environmentId: "test", - source: { ...child, modelSelection: { ...child.modelSelection, instanceId: "other" } }, - }, - ]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender({ ...child, modelSelection: { ...child.modelSelection, instanceId: "other" } }); expect(text()).not.toContain("My GPT ·"); const config = state.configs.get("test"); state.configs.clear(); await act(async () => renderer.update(cloneElement(panel))); expect(text()).toContain("GPT-5.4"); expect(text()).not.toContain("GPT-5.4 ·"); - state.shells = [{ environmentId: "test", source: { ...child } }]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender(); expect(text()).toContain("GPT-5.4 · high"); state.configs.set("test", config); 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 ...projection, subagents: [{ ...projection.subagents[0], model }], }; - state.shells = [ - { - environmentId: "test", - source: { ...child, modelSelection: { ...child.modelSelection, model: "custom/model-v1" } }, - }, - ]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender({ + ...child, + modelSelection: { ...child.modelSelection, model: "custom/model-v1" }, + }); expect(text().includes(`${model} · high`)).toBe(model === "custom/model-v1"); } @@ -692,16 +1112,14 @@ it("shows readable models and only differing workspace details in agent tooltips state.projection = projection; child.worktreePath = "/main/worktrees/checker"; - state.shells = [{ environmentId: "test", source: { ...child } }]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender(); expect(text()).toContain("Worktree"); expect(text()).toContain("checker"); expect(text()).not.toContain("/main/worktrees"); expect(text()).not.toContain("Project"); child.branch = "fix/checker"; - state.shells = [{ environmentId: "test", source: { ...child } }]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender(); expect(text()).toContain("Branch"); expect(text()).toContain("fix/checker"); expect(text()).not.toContain("Worktree"); @@ -710,8 +1128,7 @@ it("shows readable models and only differing workspace details in agent tooltips child.projectId = "other"; child.worktreePath = null; child.branch = null; - state.shells = [{ environmentId: "test", source: { ...child } }]; - await act(async () => renderer.update(cloneElement(panel))); + await rerender(); expect(text()).toContain("Other project"); expect(text()).toContain("Workspace"); expect(text()).toContain("other"); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index d7f6471eea3c6c7fda51e60e24aeb586b6b826d1..283abd67c3ec19e6dd5ba37d3169dbd33d9c35a9 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; import { ThreadHoverCardPopup } from "../ThreadHoverCard"; import { ThreadDetailsSection } from "./ThreadDetailsSection"; @@ -6,6 +7,7 @@ import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { + deriveWorkflowGroups, projectedSubagentsToRuntime, type RuntimeSubagent, } from "@t3tools/client-runtime/state/subagentRuntime"; @@ -24,11 +26,12 @@ import { canDetachThreadProviderSession, resolveLatestMergeBackRun, } from "@t3tools/client-runtime/state/thread-workflows"; -import type { - EnvironmentId, - OrchestrationV2Subagent, - OrchestrationV2ThreadShell, - ThreadId, +import { + NodeId, + type EnvironmentId, + type OrchestrationV2Subagent, + type OrchestrationV2ThreadShell, + type ThreadId, } from "@t3tools/contracts"; import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming"; import { groupBy } from "effect/Array"; @@ -56,9 +59,11 @@ import { useThreadShells, } from "../../state/entities"; import { threadEnvironment } from "../../state/threads"; +import { orchestrationEnvironment } from "../../state/orchestration"; import { useAtomCommand } from "../../state/use-atom-command"; import { AgentElapsed } from "./AgentElapsed"; import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadRelationshipIcon"; +import { ThreadLineageWorkflowRow } from "./ThreadLineageWorkflowRow"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -97,11 +102,13 @@ export function ThreadLineageRowList(props: { component: this sits inside an already scrolling panel, where a max-height-only virtual viewport measures badly. Every row is a focusable button, so keyboard users reach and scroll the region through the rows - themselves and the container needs no extra tab stop of its own. + themselves and the container needs no extra tab stop of its own. An + expanded workflow asks for a taller window, so it raises the bound while + its tree is open. */}
      {props.children}
    @@ -136,6 +143,7 @@ function ThreadLineageGroup(props: {
    {props.label ? ( setExpanded(!expanded)} accessory={ @@ -217,28 +225,39 @@ export function ThreadRelationshipsPanel(props: { const ref = scopeThreadRef(props.environmentId, props.threadId); const projection = useThreadProjection(ref)?.projection ?? null; const providers = useServerConfigs().get(props.environmentId)?.providers; - const subagentsByThreadId = useMemo( - () => - new Map( - (projection?.subagents ?? []) - .filter((subagent) => subagent.childThreadId !== null) - .map((subagent) => [ - subagent.childThreadId, - { - ...projectedSubagentsToRuntime([subagent])[0]!, - id: subagent.id, - threadId: subagent.threadId, - nativeTaskRef: subagent.nativeTaskRef, - nativeStatus: subagent.status, - driver: subagent.driver, - providerInstanceId: subagent.providerInstanceId, - origin: subagent.origin, - modelSelection: subagent.modelSelection, - }, - ]), - ), + const runtimeSubagents = useMemo( + () => projectedSubagentsToRuntime(projection?.subagents ?? []), [projection?.subagents], ); + const subagentsByThreadId = useMemo(() => { + const byId = new Map(runtimeSubagents.map((agent) => [agent.id, agent])); + return new Map( + (projection?.subagents ?? []) + .filter((subagent) => subagent.childThreadId !== null) + .map((subagent) => [ + subagent.childThreadId, + { + ...byId.get(subagent.id)!, + id: subagent.id, + threadId: subagent.threadId, + runId: subagent.runId, + nativeTaskRef: subagent.nativeTaskRef, + nativeStatus: subagent.status, + driver: subagent.driver, + providerInstanceId: subagent.providerInstanceId, + origin: subagent.origin, + modelSelection: subagent.modelSelection, + }, + ]), + ); + }, [projection?.subagents, runtimeSubagents]); + // A workflow coordinator is already a subagent row here; the grouped model + // is what lets that row unfold into the phases and members it ran. + const workflowGroupsById = useMemo( + () => + new Map(deriveWorkflowGroups(runtimeSubagents).map((group) => [group.workflow.id, group])), + [runtimeSubagents], + ); const threadShells = useThreadShells(); const projects = useProjects().filter((project) => project.environmentId === props.environmentId); const archived = useArchivedThreadSnapshots([props.environmentId]); @@ -260,8 +279,12 @@ export function ThreadRelationshipsPanel(props: { const mergeBack = useAtomCommand(threadEnvironment.mergeBack); const stopSession = useAtomCommand(threadEnvironment.stopSession); const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn); + const stopWorkflowCommand = useAtomCommand(orchestrationEnvironment.stopWorkflow); + const canOperateWorkflow = useAtomValue( + orchestrationEnvironment.stopWorkflow.permissionAtom(props.environmentId), + ); const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null); - const [stoppingThreadId, setStoppingThreadId] = useState(null); + const [stoppingId, setStoppingId] = useState(null); const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection); const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection); const relationshipRows = useMemo( @@ -271,8 +294,17 @@ export function ThreadRelationshipsPanel(props: { rows: immediateThreadRelationships(graph, props.threadId), currentThreadId: props.threadId, mergeTargetThreadId, + }).map((row) => { + const agent = + row.edge.kind === "subagent" && !isParentThreadRelationship(row.edge, props.threadId) + ? subagentsByThreadId.get(row.threadId) + : undefined; + const workflowGroup = agent === undefined ? undefined : workflowGroupsById.get(agent.id); + return workflowGroup === undefined + ? row + : { ...row, edge: { ...row.edge, status: workflowGroup.workflow.status } }; }), - [graph, mergeTargetThreadId, props.threadId], + [graph, mergeTargetThreadId, props.threadId, subagentsByThreadId, workflowGroupsById], ); const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null; const canDetach = projection ? canDetachThreadProviderSession(projection) : false; @@ -341,8 +373,8 @@ export function ThreadRelationshipsPanel(props: { childThreadId: ThreadId, agent: Pick, ) => { - if (stoppingThreadId !== null) return; - setStoppingThreadId(childThreadId); + if (stoppingId !== null) return; + setStoppingId(childThreadId); const result = await interruptTurn({ environmentId: props.environmentId, input: @@ -350,12 +382,25 @@ export function ThreadRelationshipsPanel(props: { ? { threadId: agent.threadId, subagentId: agent.id } : { threadId: childThreadId }, }); - setStoppingThreadId(null); + setStoppingId(null); if (result._tag === "Failure") { toastManager.add({ type: "error", title: "Could not stop subagent" }); } }; + const stopWorkflow = async (subagentId: string) => { + if (stoppingId !== null) return; + setStoppingId(subagentId); + const result = await stopWorkflowCommand({ + environmentId: props.environmentId, + input: { threadId: props.threadId, subagentId: NodeId.make(subagentId) }, + }); + setStoppingId(null); + if (result._tag === "Failure") { + toastManager.add({ type: "error", title: "Could not stop workflow" }); + } + }; + const parentTitle = mergeTargetThreadId === null ? null @@ -400,33 +445,41 @@ export function ThreadRelationshipsPanel(props: { const isSubagent = edge.kind === "subagent"; const isMergeTarget = threadId === mergeTargetThreadId; const isParent = isParentThreadRelationship(edge, props.threadId); - const status = threadRelationshipRowStatus(graph, { threadId, edge }); + const rowStatus = threadRelationshipRowStatus(graph, { threadId, edge }); const RelationshipIcon = isParent ? CornerLeftUpIcon : isSubagent ? BotIcon : GitForkIcon; const relationship = relationshipLabel(edge, props.threadId); - const agent = currentSubagent( - isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined, - node?.thread, - ); - const failed = status === "failed" || status === "error"; + const projectedAgent = + isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined; + const workflowGroup = + projectedAgent === undefined + ? undefined + : workflowGroupsById.get(projectedAgent.id); + const agent = workflowGroup + ? projectedAgent + : currentSubagent(projectedAgent, node?.thread); + const failed = rowStatus === "failed" || rowStatus === "error"; const canStop = agent && (agent.origin === "app_owned" || (agent.origin === "provider_native" && agent.driver === "claudeAgent" && + agent.runId != null && agent.nativeTaskRef?.strength === "strong" && agent.nativeTaskRef.nativeId !== null && ["pending", "running", "waiting"].includes(agent.nativeStatus))) && agent.startedAt && ["pending", "running", "waiting"].includes(agent.status); - const trailingVisibilityClass = canStop - ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" - : ""; const threadTitle = relationshipThreadTitle({ - title: node?.thread?.title ?? agent?.title ?? threadId, + // A workflow's name is shorter than its child thread's title. + title: + workflowGroup?.workflow.workflowName ?? + node?.thread?.title ?? + agent?.title ?? + threadId, isSubagent, }); const provider = providers?.find( @@ -435,6 +488,14 @@ export function ThreadRelationshipsPanel(props: { (agent?.providerInstanceId ?? node?.thread?.providerInstanceId), ); const providerDriver = agent?.driver ?? provider?.driver; + const canStopWorkflow = + canOperateWorkflow && + workflowGroup?.workflow.status === "running" && + agent?.origin === "provider_native" && + providerDriver === "claudeAgent"; + const trailingVisibilityClass = (workflowGroup ? canStopWorkflow : canStop) + ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" + : ""; const project = projects.find((project) => project.id === node?.thread?.projectId); const relationshipHint = node?.missing ? "This related thread is unavailable" @@ -468,7 +529,7 @@ export function ThreadRelationshipsPanel(props: { driver={isSubagent && !isParent ? providerDriver : undefined} provider={provider} fallbackIcon={RelationshipIcon} - status={status} + status={rowStatus} /> @@ -478,23 +539,68 @@ export function ThreadRelationshipsPanel(props: { {agent ? null : ( )} - {/* One trailing item keeps room for the title: the icon dot already - carries status, so an agent with a known time shows only that. */} - {agent && !failed && deriveSubagentElapsedMs(agent, 0) !== null ? ( + {agent && + !failed && + (deriveSubagentElapsedMs(agent, 0) !== null || canStopWorkflow) ? ( ) : !isMergeTarget ? ( - {threadRelationshipStatusLabel(status)} + {threadRelationshipStatusLabel(rowStatus)} ) : null} ); + // A workflow pairs this row with a disclosure, so it renders as the + // leading half of a split row; everything else renders it directly. + const relationshipLink = ( + + openThread(threadId)} + /> + } + > + {relationshipContent} + + {relationshipTooltip} + + ); + if (workflowGroup && agent) { + return ( + openThread(memberThreadId as ThreadId)} + isThreadAvailable={(memberThreadId) => + graph.nodes.get(memberThreadId as ThreadId)?.missing === false + } + onStop={canStopWorkflow ? () => void stopWorkflow(agent.id) : undefined} + stopping={stoppingId === agent.id} + stopDisabled={stoppingId !== null} + header={relationshipLink} + /> + ); + } return (
  • {isMergeTarget ? ( @@ -507,7 +613,7 @@ export function ThreadRelationshipsPanel(props: { size="sm" variant="ghost" part="primary" - aria-label={`${threadTitle} ${threadRelationshipStatusLabel(status)}`} + aria-label={`${threadTitle} ${threadRelationshipStatusLabel(rowStatus)}`} disabled={node?.missing === true} onClick={() => openThread(threadId)} /> @@ -553,27 +659,11 @@ export function ThreadRelationshipsPanel(props: { - {threadRelationshipStatusLabel(status)} + {threadRelationshipStatusLabel(rowStatus)}
  • ) : ( - - openThread(threadId)} - part="row" - /> - } - > - {relationshipContent} - - {relationshipTooltip} - + relationshipLink )} {canStop && agent ? (
    @@ -586,12 +676,12 @@ export function ThreadRelationshipsPanel(props: { part="icon" tone="destructive" aria-label={`Stop subagent ${threadTitle}`} - disabled={stoppingThreadId !== null} + disabled={stoppingId !== null} onClick={() => void stopSubagent(threadId, agent)} /> } > - {stoppingThreadId === threadId ? ( + {stoppingId === threadId ? ( ) : ( diff --git a/apps/web/src/components/ui/collapsible-section-header.tsx b/apps/web/src/components/ui/collapsible-section-header.tsx index 6a685fe03acd3fda0158484f0940bfde4bdee366..b87d7a324973f96c2338ec0b09e1251a40bda886 100644 --- a/apps/web/src/components/ui/collapsible-section-header.tsx +++ b/apps/web/src/components/ui/collapsible-section-header.tsx @@ -14,11 +14,13 @@ export function CollapsibleSectionHeader({ children, expanded, tone = "muted", + variant = "default", accessory, ...buttonProps }: Omit, "className" | "style" | "aria-expanded"> & { expanded: boolean; tone?: keyof typeof tones; + variant?: "default" | "panel"; accessory?: ReactNode; }) { return ( @@ -27,7 +29,8 @@ export function CollapsibleSectionHeader({ type="button" aria-expanded={expanded} className={cn( - "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", + "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", + variant === "panel" ? "ps-2 pe-2.5" : "px-2", tones[tone].label, )} > diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index 04341a6437338f68a3f3e9bd3bdc52bc5289055d..e0837d570ad7dd5c1ad25c1d3cb8154e134a2f65 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -44,6 +44,10 @@ export function createOrchestrationEnvironmentAtoms( staleTimeMs: 300_000, idleTtlMs: 300_000, }), + stopWorkflow: createEnvironmentRpcCommand(runtime, { + label: "environment-data:orchestration:stop-workflow", + tag: ORCHESTRATION_V2_WS_METHODS.stopWorkflow, + }), // Keyed by the item revision, so a live row refetches as its output grows. turnItem: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:turn-item", diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c4756b081f29b2ed19de70b627f249719dcb6cf --- /dev/null +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { OrchestrationV2Subagent, OrchestrationV2SubagentWorkflow } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + +import { deriveWorkflowGroups, projectedSubagentsToRuntime } from "./subagentRuntime.ts"; + +const at = (iso: string) => DateTime.makeUnsafe(iso); + +type ProjectedSubagent = Parameters[0][number]; + +function subagent(overrides: Partial = {}): ProjectedSubagent { + return { + id: "coordinator", + title: "probe", + prompt: "export const meta = …", + model: "claude-opus-5[1m]", + status: "running" satisfies OrchestrationV2Subagent["status"], + result: null, + startedAt: at("2026-08-01T10:00:00.000Z"), + completedAt: null, + updatedAt: at("2026-08-01T10:00:05.000Z"), + ...overrides, + }; +} + +const member = ( + overrides: Partial = {}, +): OrchestrationV2SubagentWorkflow["agents"][number] => ({ + index: 1, + label: "alpha:one", + state: "running", + phaseIndex: 1, + phaseTitle: "Alpha", + ...overrides, +}); + +const groupsOf = (subagents: ReadonlyArray) => + deriveWorkflowGroups(projectedSubagentsToRuntime(subagents)); + +describe("projectedSubagentsToRuntime workflow expansion", () => { + it("leaves a plain subagent flat", () => { + const [agent, ...rest] = projectedSubagentsToRuntime([subagent()]); + expect(rest).toEqual([]); + expect(agent?.kind).toBe("subagent"); + expect(agent?.phases).toEqual([]); + }); + + it("projects a coordinator plus one row per member", () => { + const rows = projectedSubagentsToRuntime([ + subagent({ + workflow: { + name: "probe-wf", + phases: [ + { index: 1, title: "Alpha" }, + { index: 2, title: "Beta" }, + ], + agents: [member(), member({ index: 2, label: "alpha:two", state: "queued" })], + totalTokens: 1000, + }, + }), + ]); + expect(rows.map((row) => row.kind)).toEqual(["workflow", "workflow_agent", "workflow_agent"]); + expect(rows[0]?.workflowName).toBe("probe-wf"); + expect(rows[0]?.usage?.totalTokens).toBe(1000); + expect(rows[1]).toMatchObject({ + id: "coordinator:agent:1", + title: "alpha:one", + parentAgentId: "coordinator", + phaseIndex: 1, + phaseTitle: "Alpha", + status: "running", + }); + // A queued member is not started work: it must not report an elapsed clock. + expect(rows[2]?.status).toBe("pending"); + expect(rows[2]?.startedAt).toBeNull(); + }); + + it("derives a settled member's end from its reported duration", () => { + const [, settled] = projectedSubagentsToRuntime([ + subagent({ + workflow: { + phases: [], + agents: [ + member({ + state: "completed", + startedAt: Date.UTC(2026, 7, 1, 10, 0, 0), + durationMs: 1500, + totalTokens: 71_141, + toolCalls: 3, + prompt: "Reply with exactly: A1", + result: "A1", + }), + ], + }, + }), + ]); + expect(settled?.status).toBe("completed"); + expect(settled?.completedAt).toBe("2026-08-01T10:00:01.500Z"); + expect(settled?.result).toBe("A1"); + // The prompt stays on progress so the detail view can show both halves. + expect(settled?.progress).toBe("Reply with exactly: A1"); + expect(settled?.usage).toEqual({ totalTokens: 71_141, toolUses: 3, durationMs: 1500 }); + }); + + it.each(["completed", "cancelled", "failed"] as const)( + "settles unfinished members when the coordinator is %s", + (status) => { + const groups = groupsOf([ + subagent({ + status, + completedAt: at("2026-08-01T10:00:09.000Z"), + workflow: { + phases: [], + agents: [ + member(), + member({ index: 2, state: "queued" }), + member({ index: 3, state: "completed" }), + ], + }, + }), + ]); + // The settled run keeps its members reachable under the coordinator. + expect(groups[0]?.workflow.status).toBe(status); + const members = groups[0]?.phases.flatMap((phase) => phase.members); + expect(members?.map((agent) => agent.status)).toEqual([status, status, "completed"]); + expect(members?.[0]?.completedAt).toBe("2026-08-01T10:00:09.000Z"); + }, + ); + + it("surfaces a retried member through the row's activation count", () => { + const [, retried] = projectedSubagentsToRuntime([ + subagent({ workflow: { phases: [], agents: [member({ attempt: 3 })] } }), + ]); + expect(retried?.activationCount).toBe(3); + expect(retried?.attempt).toBe(3); + }); +}); + +describe("deriveWorkflowGroups over a dynamic workflow", () => { + it("groups members under their phases and counts the run", () => { + const groups = groupsOf([ + subagent({ + workflow: { + name: "probe-wf", + phases: [ + { index: 1, title: "Alpha" }, + { index: 2, title: "Beta" }, + ], + agents: [ + member({ state: "completed" }), + member({ index: 2, label: "alpha:two", state: "failed" }), + member({ index: 3, label: "beta:one", phaseIndex: 2, phaseTitle: "Beta" }), + member({ + index: 4, + label: "beta:two", + phaseIndex: 2, + phaseTitle: "Beta", + state: "queued", + }), + ], + }, + }), + ]); + const group = groups[0]; + expect(group?.phases.map((phase) => [phase.title, phase.state])).toEqual([ + ["Alpha", "done"], + ["Beta", "running"], + ]); + expect(group?.phases.map((phase) => phase.settledCount)).toEqual([2, 0]); + expect(group?.phases.flatMap((phase) => phase.members)).toHaveLength(4); + expect(group?.unphasedMembers).toEqual([]); + }); + + it("shows a phase the script only reached at runtime", () => { + const groups = groupsOf([ + subagent({ + workflow: { + // The declared plan has not caught up with the member's phase yet. + phases: [{ index: 1, title: "Alpha" }], + agents: [ + member({ state: "completed" }), + member({ index: 2, label: "dynamic:late", phaseIndex: 3, phaseTitle: "Gamma" }), + ], + }, + }), + ]); + const group = groups[0]; + expect(group?.phases.map((phase) => phase.title)).toEqual(["Alpha", "Gamma"]); + expect(group?.unphasedMembers).toEqual([]); + }); + + it.each([false, true])("keeps a reported phase title, missing title first=%s", (missingFirst) => { + const agents = [member({ phaseTitle: "Review" }), member({ index: 2, phaseTitle: undefined })]; + const groups = groupsOf([ + subagent({ workflow: { phases: [], agents: missingFirst ? agents.toReversed() : agents } }), + ]); + expect(groups[0]?.phases[0]?.title).toBe("Review"); + }); + + it("keeps unphased members with their workflow and excludes unrelated agents", () => { + const runtime = projectedSubagentsToRuntime([ + subagent({ id: "ordinary" }), + subagent({ + id: "__proto__", + workflow: { + phases: [], + agents: [member({ phaseIndex: undefined, phaseTitle: undefined })], + }, + }), + subagent({ id: "constructor", workflow: { phases: [], agents: [] } }), + subagent({ id: "orphan", workflow: { phases: [], agents: [member()] } }), + ]); + const groups = deriveWorkflowGroups(runtime.filter((agent) => agent.id !== "orphan")); + expect(groups.map((group) => group.workflow.id)).toEqual(["__proto__", "constructor"]); + expect(groups[0]?.phases).toEqual([]); + expect(groups[0]?.unphasedMembers.map((agent) => agent.title)).toEqual(["alpha:one"]); + expect(groups[1]?.phases).toEqual([]); + expect(groups[1]?.unphasedMembers).toEqual([]); + }); +}); diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index 2b1fc96e54ac71883147f9ae9be820516c165bff..fe9eaea9258f6a364fec2ccfa39d6c3a32c0e05d 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -2,8 +2,13 @@ * Subagent status helpers shared by web and mobile, and the runtime shape the * web agent rows render. */ +import { groupBy } from "effect/Array"; import * as DateTime from "effect/DateTime"; -import type { OrchestrationV2Subagent, OrchestrationV2TurnItem } from "@t3tools/contracts"; +import type { + OrchestrationV2Subagent, + OrchestrationV2SubagentWorkflow, + OrchestrationV2TurnItem, +} from "@t3tools/contracts"; import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; export type RuntimeSubagentStatus = @@ -36,11 +41,12 @@ export interface SubagentWorkflowPhase { readonly title: string; } +// Optional-and-undefined, matching the contract's own optionals. export interface SubagentRunHandles { - readonly runId?: string; - readonly scriptPath?: string; - readonly transcriptDir?: string; - readonly sessionUrl?: string; + readonly runId?: string | undefined; + readonly scriptPath?: string | undefined; + readonly transcriptDir?: string | undefined; + readonly sessionUrl?: string | undefined; } export interface RuntimeSubagent { @@ -66,6 +72,8 @@ export interface RuntimeSubagent { readonly workflowName: string | null; readonly phases: ReadonlyArray; readonly runHandles: SubagentRunHandles | null; + /** The thread this agent owns, when it has one. */ + readonly childThreadId: string | null; readonly recentActivity: ReadonlyArray; /** First retained observation, used as the roster's stable display order. */ readonly firstSeenAt: string; @@ -91,18 +99,105 @@ export function isActiveSubagentStatus(status: RuntimeSubagentStatus): boolean { return isOrchestrationV2WorkActive(status); } -/** - * A subagent card whose child is still working. The card outlives its - * launching turn: once that turn settles, the waiting footer counts the child, - * so the card stays out of the turn's fold until the child finishes. - */ export function isLiveSubagentTurnItem(item: OrchestrationV2TurnItem): boolean { return item.type === "subagent" && isOrchestrationV2WorkActive(item.status); } +/** A workflow coordinator with its members grouped by phase, as Lineage renders it. */ +export interface SubagentWorkflowGroup { + readonly workflow: RuntimeSubagent; + readonly phases: ReadonlyArray<{ + readonly index: number; + readonly title: string; + readonly members: ReadonlyArray; + /** done = every member settled (success or error); running = any active. */ + readonly state: "pending" | "running" | "done"; + readonly settledCount: number; + }>; + /** Members with no resolvable phase (orphans render under the workflow). */ + readonly unphasedMembers: ReadonlyArray; +} + +function workflowUsage(source: { + readonly totalTokens?: number | undefined; + readonly toolCalls?: number | undefined; + readonly durationMs?: number | undefined; +}): SubagentUsage | null { + if (source.totalTokens === undefined) return null; + return { + totalTokens: source.totalTokens, + ...(source.toolCalls === undefined ? {} : { toolUses: source.toolCalls }), + ...(source.durationMs === undefined ? {} : { durationMs: source.durationMs }), + }; +} + +function isoFromEpochMillis(value: number | undefined): string | null { + return value === undefined ? null : DateTime.formatIso(DateTime.makeUnsafe(value)); +} + /** - * Projects orchestration-v2 subagent entities into the runtime shape the web - * agent rows render. + * Expands a coordinator's workflow roster into member rows. Members are + * synthesized from the coordinator's snapshot rather than read from their own + * subagent entities, so a parent thread's Lineage can show them without + * loading the coordinator's child thread; their identity is the coordinator id + * plus the spawn ordinal. + */ +function workflowMembersToRuntime( + coordinator: RuntimeSubagent, + workflow: OrchestrationV2SubagentWorkflow, +): ReadonlyArray { + return workflow.agents.map((agent) => { + // Every member state but queued is already a runtime status. + const status = + (agent.state === "queued" || agent.state === "running") && + isTerminalSubagentStatus(coordinator.status) + ? coordinator.status + : agent.state === "queued" + ? "pending" + : agent.state; + const failed = status === "failed"; + const startedAt = isoFromEpochMillis(agent.startedAt); + // The provider reports a settled member's duration, not its end instant. + const completedAt = isTerminalSubagentStatus(status) + ? agent.startedAt !== undefined && agent.durationMs !== undefined + ? isoFromEpochMillis(agent.startedAt + agent.durationMs) + : coordinator.completedAt + : null; + return { + // Members share the run's handles: the transcript directory is what + // makes their conversation readable, and only the run knows it. + ...coordinator, + id: `${coordinator.id}:agent:${agent.index}`, + kind: "workflow_agent" as const, + title: agent.label, + model: agent.model ?? null, + status, + // A member the workflow retried counts each attempt as a run. + activationCount: agent.attempt ?? 1, + usage: workflowUsage(agent), + // A member keeps its prompt on `progress` even once settled: that is the + // question half of its conversation, and the detail view shows both halves. + progress: agent.prompt ?? null, + result: failed ? null : (agent.result ?? null), + error: failed ? (agent.result ?? null) : null, + parentAgentId: coordinator.id, + agentIndex: agent.index, + phaseIndex: agent.phaseIndex ?? null, + phaseTitle: agent.phaseTitle ?? null, + attempt: agent.attempt ?? null, + phases: [], + childThreadId: agent.childThreadId ?? null, + recentActivity: [], + firstSeenAt: isoFromEpochMillis(agent.queuedAt) ?? startedAt ?? coordinator.firstSeenAt, + startedAt, + completedAt, + updatedAt: completedAt ?? startedAt ?? coordinator.firstSeenAt, + } satisfies RuntimeSubagent; + }); +} + +/** + * Projects subagents and workflow members into the runtime roster. */ export function projectedSubagentsToRuntime( subagents: ReadonlyArray<{ @@ -112,18 +207,23 @@ export function projectedSubagentsToRuntime( readonly model: string | null; readonly status: OrchestrationV2Subagent["status"]; readonly progress?: string | undefined; + readonly childThreadId?: string | null | undefined; + readonly workflow?: OrchestrationV2SubagentWorkflow | undefined; readonly result: string | null; readonly startedAt: DateTime.Utc | null; readonly completedAt: DateTime.Utc | null; readonly updatedAt: DateTime.Utc; }>, ): ReadonlyArray { - return subagents.map((subagent) => { + return subagents.flatMap((subagent) => { const updatedAt = DateTime.formatIso(subagent.updatedAt); const startedAt = subagent.startedAt === null ? null : DateTime.formatIso(subagent.startedAt); - return { + const firstSeenAt = startedAt ?? updatedAt; + const { workflow } = subagent; + const runHandles = workflow?.runHandles ?? null; + const coordinator = { id: subagent.id, - kind: "subagent" as const, + kind: workflow === undefined ? "subagent" : "workflow", title: subagent.title ?? (subagent.prompt.length > 80 ? `${subagent.prompt.slice(0, 77)}...` : subagent.prompt), @@ -132,7 +232,7 @@ export function projectedSubagentsToRuntime( effort: null, status: subagent.status, activationCount: 1, - usage: null, + usage: workflow === undefined ? null : workflowUsage(workflow), progress: subagent.progress ?? null, lastToolName: null, result: subagent.result, @@ -143,14 +243,78 @@ export function projectedSubagentsToRuntime( phaseIndex: null, phaseTitle: null, attempt: null, - workflowName: null, - phases: [], - runHandles: null, + workflowName: workflow?.name ?? null, + phases: workflow?.phases ?? [], + runHandles, + childThreadId: subagent.childThreadId ?? null, recentActivity: [], - firstSeenAt: startedAt ?? updatedAt, + firstSeenAt, startedAt, completedAt: subagent.completedAt === null ? null : DateTime.formatIso(subagent.completedAt), updatedAt, } satisfies RuntimeSubagent; + return workflow === undefined + ? [coordinator] + : [coordinator, ...workflowMembersToRuntime(coordinator, workflow)]; + }); +} + +export function deriveWorkflowGroups( + agents: ReadonlyArray, +): ReadonlyArray { + const workflows = agents + .filter((agent) => agent.kind === "workflow") + .sort((a, b) => a.firstSeenAt.localeCompare(b.firstSeenAt) || a.id.localeCompare(b.id)); + const members = groupBy( + agents.filter((agent) => agent.kind !== "workflow" && agent.parentAgentId !== null), + (agent) => agent.parentAgentId!, + ); + + return workflows.map((workflow) => { + const workflowMembers = Object.hasOwn(members, workflow.id) ? members[workflow.id]! : []; + // Union, not either/or: the declared plan lags the members, because the + // provider only admits a phase once something in it starts. Members seed + // the map; the declared titles then overwrite whatever they guessed. + const phaseTitles = new Map(); + for (const member of workflowMembers) { + if (member.phaseIndex === null) continue; + phaseTitles.set( + member.phaseIndex, + member.phaseTitle ?? phaseTitles.get(member.phaseIndex) ?? `Phase ${member.phaseIndex + 1}`, + ); + } + for (const phase of workflow.phases) phaseTitles.set(phase.index, phase.title); + const knownPhases = Array.from(phaseTitles.entries()) + .map(([index, title]) => ({ index, title })) + .sort((a, b) => a.index - b.index); + + const phases = knownPhases.map((phase) => { + const phaseMembers = workflowMembers + .filter((member) => member.phaseIndex === phase.index) + .sort((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0)); + const hasActiveMember = phaseMembers.some( + // Idle members count as active for phase-liveness: a resumable Codex + // member has not finished the phase. + (member) => isActiveSubagentStatus(member.status) || member.status === "idle", + ); + const settledCount = phaseMembers.filter((member) => + isTerminalSubagentStatus(member.status), + ).length; + const state: "pending" | "running" | "done" = + phaseMembers.length === 0 ? "pending" : hasActiveMember ? "running" : "done"; + return { + index: phase.index, + title: phase.title, + members: phaseMembers, + state, + settledCount, + }; + }); + + const unphasedMembers = workflowMembers + .filter((member) => member.phaseIndex === null) + .sort((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0)); + + return { workflow, phases, unphasedMembers }; }); } diff --git a/packages/client-runtime/src/t3ToolSummary.test.ts b/packages/client-runtime/src/t3ToolSummary.test.ts index 8ae001ea7769137371e7abec59233fb51cd66d6d..45868cc70fdc10bf4b78c8611a75575b5934cda0 100644 --- a/packages/client-runtime/src/t3ToolSummary.test.ts +++ b/packages/client-runtime/src/t3ToolSummary.test.ts @@ -248,4 +248,21 @@ describe("summarizeT3ToolCalls", () => { ]).label, ).toBe("Requested deletion of 1 scheduled task"); }); + + it("counts workflow stop requests by coordinator and excludes failed requests", () => { + const first = completed({ threadId: "parent", subagentId: "workflow-1" }, {}); + const failed = completed({ threadId: "parent", subagentId: "workflow-3" }, { isError: true }); + expect( + summarizeT3ToolCalls("workflow-stop", [ + first, + first, + completed({ threadId: "parent", subagentId: "workflow-2" }, {}), + failed, + ]), + ).toEqual({ label: "Requested stops for 2 workflows", failedCount: 1 }); + expect(summarizeT3ToolCalls("workflow-stop", [failed])).toEqual({ + label: "Tried to stop 1 workflow", + failedCount: 1, + }); + }); }); diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index b9a46fa65287f677d205952101d64396059a41c4..101950dd6a5de96a0f6f6b59e5cec026796356fc 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -168,6 +168,13 @@ export function summarizeT3ToolCalls( quantity(countEntities(threadIds), "thread"), ); break; + case "workflow-stop": + label = phrase( + "Requested stops for", + "stop", + quantity(countEntities(entityIds("subagentId")), "workflow"), + ); + break; case "task-status": label = phrase("Checked", "check", `task status ${times}`); break; diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 6e41a53478a1ce9bf82ac387084062447c9dd052..241215310aad7f5f2a8b8deed9c297b6700bf53d 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -308,6 +308,7 @@ describe("resolveWorkEntryToolPresentation", () => { ["t3_thread_fork", "Forking this thread", "Requested a fork of this thread"], ["t3_thread_send_attachments", "Sending attachments", "Sent attachments"], ["run_scheduled_task_now", "Running a scheduled task", "Requested a run of a scheduled task"], + ["t3_workflow_stop", "Stopping a workflow", "Requested a stop for a workflow"], ])("labels %s through its lifecycle", (tool, running, completed) => { expect(resolveWorkEntryToolPresentation({ label: `T3-code.${tool}` })?.displayName).toBe( running, @@ -320,6 +321,47 @@ describe("resolveWorkEntryToolPresentation", () => { ).toBe(completed); }); + it("summarizes workflow stop targets separately from their parent thread", () => { + const entry = { + id: "workflow-stop", + createdAt: "2026-10-07T00:00:00.000Z", + tone: "tool", + label: "Custom title", + itemType: "dynamic_tool", + toolLifecycleStatus: "completed", + toolData: { + server: "t3-code", + tool: "t3_workflow_stop", + arguments: { threadId: "parent", subagentId: "workflow-1" }, + result: {}, + }, + } satisfies WorkLogPresentationEntry; + expect( + summarizeToolGroup([ + entry, + entry, + { + ...entry, + toolData: { + ...entry.toolData, + arguments: { threadId: "parent", subagentId: "workflow-2" }, + }, + }, + { + ...entry, + toolData: { + ...entry.toolData, + tool: "t3_thread_interrupt", + arguments: { threadId: "parent" }, + }, + }, + ]), + ).toEqual({ + summary: "Requested stops for 2 workflows and requested interrupts for 1 thread", + hasFailure: false, + }); + }); + it("summarizes project tools from MCP arguments and results without claiming failed effects", () => { const entry: WorkLogPresentationEntry = { id: "clone", diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 8ef6f58be5954f7c51cec6fd6a77c2292ac42aa3..90c33b789a64fd57910815ab2a9e2ff572031f53 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -609,6 +609,7 @@ function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction) case "thread-create": case "thread-send": case "thread-interrupt": + case "workflow-stop": case "schedule-create": case "schedule-update": case "schedule-delete": diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 533f7b75e888c998d42e2b9b5bc054173c84fe02..90b683648cb7b521c207eff408a2c5639d6907b7 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -6,6 +6,7 @@ import { AuthSourceControlWriteScope, type AuthEnvironmentScope, } from "./auth.ts"; +import { ORCHESTRATION_V2_WS_METHODS } from "./orchestrationV2.ts"; import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ @@ -43,6 +44,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope, + [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, } as const; export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES; diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index cd185f908d5cef56d9bf10f809edacf2a6b4a2c1..bca9ff656ebec5f4147d02ab90d0c181ec6f5426 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -36,6 +36,7 @@ import { OrchestrationV2ShellSnapshot, OrchestrationV2SubscribeThreadInput, OrchestrationV2Subagent, + OrchestrationV2SubagentJson, OrchestrationV2ThreadHistoryPage, OrchestrationV2ThreadProjection, OrchestrationV2ThreadStreamItem, @@ -112,6 +113,7 @@ const decodeOrchestrationV2Checkpoint = Schema.decodeUnknownSync(OrchestrationV2 const decodeOrchestrationV2DomainEvent = Schema.decodeUnknownSync(OrchestrationV2DomainEvent); const decodeProviderReplayTranscript = Schema.decodeUnknownSync(ProviderReplayTranscript); const decodeOrchestrationV2Subagent = Schema.decodeUnknownSync(OrchestrationV2Subagent); +const encodeOrchestrationV2SubagentJson = Schema.encodeSync(OrchestrationV2SubagentJson); const decodeOrchestrationV2ThreadProjection = Schema.decodeUnknownSync( OrchestrationV2ThreadProjection, ); @@ -929,6 +931,68 @@ describe("orchestration V2 contracts", () => { expect(turnItem.progress).toBe("Inspecting package metadata"); }); + it.each([" indented prompt\n", "\n \t\n"])( + "preserves workflow prompt and result %j in stored and wire JSON", + (preview) => { + const subagent = { + ...decodeOrchestrationV2Subagent({ + id: "workflow-coordinator", + threadId: "thread-1", + runId: "run-1", + parentNodeId: "node-root-1", + origin: "provider_native", + createdBy: "agent", + driver: "claudeAgent", + providerInstanceId: "claudeAgent", + providerThreadId: null, + childThreadId: null, + nativeTaskRef: null, + prompt: "Run workflow", + title: null, + model: null, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }), + workflow: { + phases: [], + agents: [ + { + index: 1, + label: "Member", + state: "completed" as const, + prompt: preview, + result: preview, + }, + ], + }, + }; + const { workflow, ...coordinator } = subagent; + const incoming = { ...encodeOrchestrationV2SubagentJson(coordinator), workflow }; + for (const codec of [ + OrchestrationV2SubagentJson, + Schema.toCodecJson(OrchestrationV2Subagent), + ]) { + const encode = Schema.encodeSync(codec); + const decode = Schema.decodeUnknownSync(codec); + const json: unknown = JSON.parse(JSON.stringify(encode(subagent))); + expect(json).toMatchObject({ + workflow: { agents: [{ prompt: preview, result: preview }] }, + }); + expect(decode(json).workflow?.agents[0]).toMatchObject({ + prompt: preview, + result: preview, + }); + expect(decode(incoming).workflow?.agents[0]).toMatchObject({ + prompt: preview, + result: preview, + }); + } + }, + ); + it("decodes app-owned subagent parent-wake policies", () => { const appOwnedSubagent = { id: "node-subagent-2", diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 842f0576f29020dd89167c90bfb411975dd98359..4822493765a8844905ba3baab8e9c0ee0e22898e 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -664,6 +664,86 @@ export const OrchestrationV2ExecutionNode = Schema.Struct({ }); export type OrchestrationV2ExecutionNode = typeof OrchestrationV2ExecutionNode.Type; +/** + * One entry of a dynamic workflow's phase plan. The plan grows while the run + * executes: a phase a script only reaches at runtime joins the list when it + * starts, so a client must treat the list as append-only rather than as a + * fixed arc declared up front. + */ +export const OrchestrationV2WorkflowPhase = Schema.Struct({ + index: NonNegativeInt, + title: TrimmedNonEmptyString, +}); +export type OrchestrationV2WorkflowPhase = typeof OrchestrationV2WorkflowPhase.Type; + +/** + * One agent inside a workflow run. Timestamps are epoch milliseconds rather + * than `DateTimeUtc` because only the top-level entity fields are remapped for + * the JSON wire shape (see `OrchestrationV2SubagentJson`), so a nested + * `DateTimeUtc` would never be encoded. + */ +export const OrchestrationV2WorkflowAgent = Schema.Struct({ + /** Spawn ordinal within the run. Stable identity: an agent keeps it across updates. */ + index: NonNegativeInt, + label: TrimmedNonEmptyString, + /** + * Provider-assigned id, absent until the agent actually starts. Names the + * member's transcript file inside the run's transcript directory. + */ + agentId: Schema.optional(TrimmedNonEmptyString), + /** The member's own thread, so the UI can open it like any subagent's. */ + childThreadId: Schema.optional(ThreadId), + state: Schema.Literals(["queued", "running", "completed", "failed"]), + phaseIndex: Schema.optional(NonNegativeInt), + phaseTitle: Schema.optional(TrimmedNonEmptyString), + model: Schema.optional(TrimmedNonEmptyString), + /** Retry counter; > 1 means the agent was restarted after a provider error. */ + attempt: Schema.optional(NonNegativeInt), + totalTokens: Schema.optional(NonNegativeInt), + toolCalls: Schema.optional(NonNegativeInt), + durationMs: Schema.optional(NonNegativeInt), + queuedAt: Schema.optional(NonNegativeInt), + startedAt: Schema.optional(NonNegativeInt), + /** + * Prompt and result excerpts. A workflow member's whole conversation is one + * prompt and one answer, so together these are its transcript as far as the + * provider exposes it — both are capped by the CLI at a few hundred + * characters. + */ + prompt: Schema.optional(Schema.NonEmptyString), + result: Schema.optional(Schema.NonEmptyString), +}); +export type OrchestrationV2WorkflowAgent = typeof OrchestrationV2WorkflowAgent.Type; + +/** + * Filesystem handles the provider reports when a run launches. The transcript + * directory is what makes a member's full answer readable: the progress + * snapshot only carries a capped excerpt, so the server reads the member's + * transcript from here to fill its thread. + */ +export const OrchestrationV2WorkflowRunHandles = Schema.Struct({ + runId: Schema.optional(TrimmedNonEmptyString), + transcriptDir: Schema.optional(TrimmedNonEmptyString), + scriptPath: Schema.optional(TrimmedNonEmptyString), +}); +export type OrchestrationV2WorkflowRunHandles = typeof OrchestrationV2WorkflowRunHandles.Type; + +/** + * Workflow-run shape carried by the coordinator subagent. The snapshot is the + * provider's own replace-in-full view of the run; members are additionally + * projected as subagents in their own right. + */ +export const OrchestrationV2SubagentWorkflow = Schema.Struct({ + name: Schema.optional(TrimmedNonEmptyString), + runHandles: Schema.optional(OrchestrationV2WorkflowRunHandles), + phases: Schema.Array(OrchestrationV2WorkflowPhase), + agents: Schema.Array(OrchestrationV2WorkflowAgent), + totalTokens: Schema.optional(NonNegativeInt), + toolCalls: Schema.optional(NonNegativeInt), + durationMs: Schema.optional(NonNegativeInt), +}); +export type OrchestrationV2SubagentWorkflow = typeof OrchestrationV2SubagentWorkflow.Type; + export const OrchestrationV2Subagent = Schema.Struct({ id: NodeId, threadId: ThreadId, @@ -698,6 +778,8 @@ export const OrchestrationV2Subagent = Schema.Struct({ "interrupted", ]), progress: Schema.optional(Schema.String), + /** Present only on a workflow coordinator task; absent on ordinary subagents. */ + workflow: Schema.optional(OrchestrationV2SubagentWorkflow), result: Schema.NullOr(Schema.String), startedAt: Schema.NullOr(Schema.DateTimeUtc), completedAt: Schema.NullOr(Schema.DateTimeUtc), @@ -3163,6 +3245,7 @@ export const ORCHESTRATION_V2_WS_METHODS = { getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", getThreadProjection: "orchestration.getThreadProjection", getWorkflowScript: "orchestration.getWorkflowScript", + stopWorkflow: "orchestration.stopWorkflow", getTurnItem: "orchestration.getTurnItem", launchThread: "orchestration.launchThread", subscribeArchivedShell: "orchestration.subscribeArchivedShell", @@ -3448,11 +3531,41 @@ export class OrchestrationV2ThreadLaunchError extends Schema.TaggedError()( + "OrchestrationV2StopWorkflowError", + { + threadId: ThreadId, + subagentId: NodeId, + reason: Schema.Literals(["not-running", "unsupported", "unavailable", "stop-failed"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + switch (this.reason) { + case "not-running": + return "This workflow is no longer running."; + case "unsupported": + return "This provider cannot stop a workflow."; + case "unavailable": + return "This workflow has no live provider task."; + case "stop-failed": + return "Failed to stop the workflow."; + } + } +} + export const OrchestrationV2RpcError = Schema.Union([ OrchestrationV2DispatchCommandError, OrchestrationV2GetThreadProjectionError, OrchestrationV2GetShellSnapshotError, OrchestrationV2ThreadLaunchError, + OrchestrationV2StopWorkflowError, ]); export type OrchestrationV2RpcError = typeof OrchestrationV2RpcError.Type; @@ -3473,6 +3586,44 @@ export const OrchestrationV2GetWorkflowScriptResult = Schema.Struct({ export type OrchestrationV2GetWorkflowScriptResult = typeof OrchestrationV2GetWorkflowScriptResult.Type; +/** + * Shared by every read of a file the Claude harness persisted for a workflow + * run — the script and each member's transcript. One vocabulary because both + * reads apply identical containment rules. + */ +const WORKFLOW_FILE_ERROR_MESSAGES = { + "invalid-path": "Workflow files must be absolute paths with the expected extension.", + "root-unavailable": "Workflow file root unavailable.", + "not-found": "Workflow file not found.", + "outside-root": "Path is outside the workflow files root.", + "wrong-extension": "Resolved file has the wrong extension.", + "not-regular-file": "Workflow file is not a regular file.", + "changed-during-read": "Workflow file changed between resolution and open.", + "read-failed": "Workflow file read failed.", +} as const; + +export class OrchestrationWorkflowFileError extends Schema.TaggedError()( + "OrchestrationWorkflowFileError", + { + reason: Schema.Literals([ + "invalid-path", + "root-unavailable", + "not-found", + "outside-root", + "wrong-extension", + "not-regular-file", + "changed-during-read", + "read-failed", + ]), + path: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return WORKFLOW_FILE_ERROR_MESSAGES[this.reason]; + } +} + export const OrchestrationV2GetTurnItemInput = Schema.Struct({ threadId: ThreadId, itemId: TurnItemId, @@ -3601,6 +3752,10 @@ export const OrchestrationV2RpcSchemas = { input: OrchestrationV2GetThreadProjectionInput, output: OrchestrationV2ThreadProjection, }, + stopWorkflow: { + input: OrchestrationV2StopWorkflowInput, + output: Schema.Void, + }, getWorkflowScript: { input: OrchestrationV2GetWorkflowScriptInput, output: OrchestrationV2GetWorkflowScriptResult, diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 61ee976e9e7d8247c5bbcc3719feb5c1971b2507..0738426a4449caadf19f228c5d0668dc40b43a45 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -399,6 +399,7 @@ export const OrchestratorMcpThreadTimelineItem = Schema.Struct({ visibility: Schema.Literals(["local", "inherited", "synthetic"]), sourceThreadId: ThreadId, itemId: TurnItemId, + subagentId: Schema.optional(NodeId), runId: Schema.NullOr(RunId), messageId: Schema.NullOr(MessageId), createdBy: Schema.NullOr(OrchestrationV2Actor), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 420930a62376a77479597a70dd352a30b0da7bed..225a38176e1528450493e7fec372931befb23a3e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -208,6 +208,7 @@ import { import { ORCHESTRATION_V2_WS_METHODS, OrchestrationGetWorkflowScriptError, + OrchestrationV2StopWorkflowError, OrchestrationV2DispatchCommandError, OrchestrationV2GetShellSnapshotError, OrchestrationV2GetThreadProjectionError, @@ -1662,6 +1663,12 @@ const WsOrchestrationV2GetThreadProjectionRpc = Rpc.make( }, ); +const WsOrchestrationV2StopWorkflowRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.stopWorkflow, { + payload: OrchestrationV2RpcSchemas.stopWorkflow.input, + success: OrchestrationV2RpcSchemas.stopWorkflow.output, + error: Schema.Union([OrchestrationV2StopWorkflowError, EnvironmentAuthorizationError]), +}); + const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make( ORCHESTRATION_V2_WS_METHODS.getWorkflowScript, { @@ -2032,6 +2039,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeResourceTelemetryRpc, WsOrchestrationV2DispatchCommandRpc, WsOrchestrationV2GetWorkflowScriptRpc, + WsOrchestrationV2StopWorkflowRpc, WsOrchestrationV2GetTurnItemRpc, WsOrchestrationV2GetTurnDiffRpc, WsOrchestrationV2GetFullThreadDiffRpc, diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 9094e8989fa46f05110aee9319804ac740b869fc..809dbfac0110f33cee739d9e896e55e26e153052 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -595,6 +595,10 @@ export interface ProviderAdapterV2SessionRuntime { readonly steerTurn: ( input: ProviderAdapterV2SteerInput, ) => Effect.Effect; + readonly stopTask?: (input: { + readonly providerThread: OrchestrationV2ProviderThread; + readonly taskId: string; + }) => Effect.Effect; readonly interruptTurn: ( input: ProviderAdapterV2InterruptInput, ) => Effect.Effect; diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 11abf77f704bea1787d896ba7bece94ba3152c00..5557d13bd9dc7c5a83db8bcaca01668874a7f79d 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -21,6 +21,7 @@ export type T3McpToolSummaryAction = | "thread-send" | "thread-wait" | "thread-interrupt" + | "workflow-stop" | "thread-configuration" | "thread-configure" | "thread-fork" @@ -138,6 +139,10 @@ const T3_MCP_TOOLS: Readonly> = { t3_thread_read: tool(["Read", "Reading", "Read", "a T3 thread"], "thread-read"), t3_thread_send: tool(["Send", "Sending", "Sent", "to a T3 thread"], "thread-send"), t3_thread_wait: tool(["Wait", "Waiting", "Waited", "for a T3 thread"], "thread-wait"), + t3_workflow_stop: tool( + ["Stop", "Stopping", "Requested a stop for", "a workflow"], + "workflow-stop", + ), t3_thread_interrupt: tool( ["Interrupt", "Interrupting", "Requested an interrupt of", "a T3 thread"], "thread-interrupt",