,
+ target: { readonly host: string; readonly repository: string },
+ preferred: OrchestrationProjectShell | undefined,
+): OrchestrationProjectShell | undefined {
+ // The same rule the guide service uses: a pull request reads through a project whose checkout
+ // names it with the same host-level key, which also maps Azure DevOps' org/project/_git/repo.
+ const matches = (project: OrchestrationProjectShell) => {
+ const own = pullRequestSyncKey(
+ { projectId: project.id, host: target.host, repository: target.repository, number: 1 },
+ project.repositoryIdentity ?? undefined,
+ );
+ const projectKey =
+ project.repositoryIdentity == null
+ ? null
+ : pullRequestSyncKey(
+ {
+ projectId: project.id,
+ repository: sourceControlRepositorySelector(project.repositoryIdentity) ?? "",
+ number: 1,
+ },
+ project.repositoryIdentity,
+ );
+ return (
+ own !== null &&
+ projectKey !== null &&
+ own.host === projectKey.host &&
+ own.repository === projectKey.repository
+ );
+ };
+ if (preferred !== undefined && matches(preferred)) return preferred;
+ return projects.find(matches);
+}
+
const make = Effect.gen(function* () {
const engine = yield* Orchestrator.OrchestratorV2;
+ const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService;
const projects = yield* ProjectService.ProjectService;
const crypto = yield* Crypto.Crypto;
@@ -264,6 +309,56 @@ const make = Effect.gen(function* () {
};
});
+ /**
+ * Resolves each requested pull request and the project it is read through. The caller's own
+ * project wins where it is a checkout of that repository; with no thread, any project here may.
+ */
+ const locateAll = Effect.fn("PullRequestsToolkit.locateAll")(function* (
+ targets: ReadonlyArray>,
+ ) {
+ const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests");
+ const callerThread =
+ scope.thread === undefined
+ ? undefined
+ : yield* engine.getThreadShell(scope.thread.threadId).pipe(
+ Effect.orElseSucceed(() => null),
+ Effect.map((shell) => shell ?? undefined),
+ );
+ const known = yield* projects
+ .listShells()
+ .pipe(Effect.mapError((cause) => new PullRequestListFailedError({ cause })));
+ const callerProject = known.find((project) => project.id === callerThread?.projectId);
+ // Each target resolves on its own, so one bad URL in a batch is that entry's failure only.
+ return yield* Effect.forEach(targets, (input) =>
+ resolveTarget(input, callerProject).pipe(
+ Effect.map((target) => ({
+ input,
+ target,
+ project: projectForPullRequest(known, target, callerProject),
+ failure: null,
+ })),
+ Effect.catch((error) =>
+ Effect.succeed({ input, target: null, project: undefined, failure: error.message }),
+ ),
+ ),
+ );
+ });
+
+ /** The service reads a pull request through one project's checkout and credentials. */
+ const guideReference = (
+ project: OrchestrationProjectShell,
+ target: { readonly host: string; readonly repository: string; readonly number: number },
+ ) => ({
+ projectId: project.id,
+ host: target.host,
+ repository: target.repository,
+ number: target.number,
+ });
+
+ /** A host or service error, said as its own detail rather than its operation's wrapper. */
+ const guideFailureDetail = (error: { readonly message: string }) =>
+ "detail" in error && typeof error.detail === "string" ? error.detail : error.message;
+
/** A tool that changes `threadId`, or the caller's own thread when it is omitted. */
const writesThread = (
handle: (params: P) => Effect.Effect,
@@ -342,6 +437,94 @@ const make = Effect.gen(function* () {
),
watch_pull_request: writesThread((input) => setWatching(input, true)),
unwatch_pull_request: writesThread((input) => setWatching(input, false)),
+ write_pull_request_guides: McpToolAccess.writesWithCapability((input) =>
+ Effect.gen(function* () {
+ const located = yield* locateAll(input.pullRequests);
+ // One pull request that cannot be guided does not stop the rest; it reports why.
+ const guides = yield* Effect.forEach(
+ located,
+ (entry) =>
+ Effect.gen(function* () {
+ if (entry.target === null) {
+ return {
+ host: entry.input.host ?? "",
+ repository: entry.input.repository ?? "",
+ number: entry.input.number ?? 0,
+ url: entry.input.url ?? "",
+ outcome: "failed" as const,
+ detail: entry.failure,
+ };
+ }
+ const identity = {
+ host: entry.target.host,
+ repository: entry.target.repository,
+ number: entry.target.number,
+ url: entry.target.url,
+ };
+ if (entry.project === undefined) {
+ return {
+ ...identity,
+ outcome: "failed" as const,
+ detail: new PullRequestProjectNotFoundError({
+ repository: entry.target.repository,
+ }).message,
+ };
+ }
+ return yield* reviewGuides.generate(guideReference(entry.project, entry.target)).pipe(
+ Effect.map((result) => ({
+ ...identity,
+ outcome: result.started ? ("started" as const) : ("running" as const),
+ detail: null,
+ })),
+ Effect.catch((error) =>
+ Effect.succeed({
+ ...identity,
+ outcome: "failed" as const,
+ detail: guideFailureDetail(error),
+ }),
+ ),
+ );
+ }),
+ { concurrency: 4 },
+ );
+ return { guides };
+ }),
+ ),
+ read_pull_request_guide: McpToolAccess.reads((input) =>
+ Effect.gen(function* () {
+ const [entry] = yield* locateAll([input]);
+ if (entry !== undefined && entry.target === null) {
+ return yield* new PullRequestGuideFailedError({ detail: entry.failure ?? "" });
+ }
+ if (entry === undefined || entry.target === null || entry.project === undefined) {
+ return yield* new PullRequestProjectNotFoundError({
+ repository: entry?.target.repository ?? input.repository ?? "",
+ });
+ }
+ const result = yield* reviewGuides.get(guideReference(entry.project, entry.target)).pipe(
+ Effect.mapError(
+ (error) =>
+ new PullRequestGuideFailedError({
+ detail: guideFailureDetail(error),
+ cause: error,
+ }),
+ ),
+ );
+ return {
+ host: entry.target.host,
+ repository: entry.target.repository,
+ number: entry.target.number,
+ url: entry.target.url,
+ guide: result.guide,
+ run: result.run,
+ currentHeadSha: result.currentHeadSha,
+ stale:
+ result.guide !== null &&
+ result.currentHeadSha !== null &&
+ result.guide.headSha !== result.currentHeadSha,
+ };
+ }),
+ ),
} satisfies McpToolAccess.Handlers;
});
diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts
index e5c3d53503ffb..35b13f3d2dcfd 100644
--- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts
+++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts
@@ -2,6 +2,8 @@ import {
McpCapabilityUnavailableError,
OrchestratorMcpFailure,
PositiveInt,
+ PullRequestReviewGuide,
+ PullRequestReviewGuideRun,
PullRequestState,
ThreadPullRequestLinkSource,
TrimmedNonEmptyString,
@@ -15,6 +17,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
import * as ProjectService from "../../../project/ProjectService.ts";
+import * as PullRequestReviewGuideService from "../../../pullRequest/PullRequestReviewGuideService.ts";
const dependencies = [
McpInvocationContext.McpInvocationContext,
@@ -23,6 +26,11 @@ const dependencies = [
ProjectService.ProjectService,
];
+const guideDependencies = [
+ ...dependencies,
+ PullRequestReviewGuideService.PullRequestReviewGuideService,
+];
+
const REGISTER_EVERY_PR =
"Register every pull request you open for this thread, including each layer of a stack, right after creating it.";
@@ -162,6 +170,24 @@ export class PullRequestListFailedError extends Schema.TaggedError()(
+ "PullRequestProjectNotFoundError",
+ { repository: Schema.String },
+) {
+ override get message(): string {
+ return `No project in this environment is a checkout of ${this.repository}, so its pull requests cannot be read here. Add one with t3_project_create.`;
+ }
+}
+
+export class PullRequestGuideFailedError extends Schema.TaggedError()(
+ "PullRequestGuideFailedError",
+ { detail: Schema.String, cause: Schema.optional(Schema.Defect()) },
+) {
+ override get message(): string {
+ return this.detail;
+ }
+}
+
export const PullRequestToolError = Schema.Union([
OrchestratorMcpFailure,
McpCapabilityUnavailableError,
@@ -176,6 +202,8 @@ export const PullRequestToolError = Schema.Union([
PullRequestWatchFailedError,
PullRequestNotOpenError,
PullRequestWatchFromSubagentError,
+ PullRequestProjectNotFoundError,
+ PullRequestGuideFailedError,
]);
export type PullRequestToolError = typeof PullRequestToolError.Type;
@@ -319,10 +347,76 @@ const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", {
.annotate(Tool.Idempotent, true)
.annotate(Tool.OpenWorld, false);
+/** A pull request to write or read a guide for, by URL or by repository and number. */
+const GuideTarget = Schema.Struct({
+ url: PullRequestTargetInput.fields.url,
+ repository: PullRequestTargetInput.fields.repository,
+ number: PullRequestTargetInput.fields.number,
+ host: PullRequestTargetInput.fields.host,
+});
+
+const GuideOutcome = Schema.Struct({
+ ...PullRequestIdentity,
+ outcome: Schema.Literals(["started", "running", "failed"]).annotate({
+ description:
+ "started: a run began. running: one was already going for this head and the call joined it. failed: this pull request could not be guided; see detail.",
+ }),
+ detail: Schema.NullOr(Schema.String),
+});
+
+export const WritePullRequestGuidesResult = Schema.Struct({
+ guides: Schema.Array(GuideOutcome),
+});
+export type WritePullRequestGuidesResult = typeof WritePullRequestGuidesResult.Type;
+
+export const ReadPullRequestGuideResult = Schema.Struct({
+ ...PullRequestIdentity,
+ guide: Schema.NullOr(PullRequestReviewGuide),
+ run: Schema.NullOr(PullRequestReviewGuideRun),
+ currentHeadSha: Schema.NullOr(Schema.String),
+ stale: Schema.Boolean.annotate({
+ description:
+ "True when the guide was written for an earlier head than the pull request has now.",
+ }),
+});
+export type ReadPullRequestGuideResult = typeof ReadPullRequestGuideResult.Type;
+
+const WritePullRequestGuidesTool = Tool.make("write_pull_request_guides", {
+ description:
+ "Write review guides for up to 20 pull requests, the tour a maintainer reads in a pull request's Guide tab. Each run reads its change in a temporary checkout and takes minutes, so this returns once the runs are under way; read a guide with read_pull_request_guide to see when it is written. A guide for the same head that is already running is joined, not restarted; a pull request whose guide is current is written again. Each pull request is read through the project in this environment whose checkout has that repository. Fails while review guides are off on this server.",
+ parameters: Schema.Struct({
+ pullRequests: Schema.Array(GuideTarget).check(Schema.isMinLength(1), Schema.isMaxLength(20)),
+ }),
+ success: WritePullRequestGuidesResult,
+ failure: PullRequestToolError,
+ dependencies: guideDependencies,
+})
+ .annotate(Tool.Title, "Write pull request guides")
+ .annotate(Tool.Readonly, false)
+ .annotate(Tool.Destructive, false)
+ .annotate(Tool.Idempotent, false)
+ .annotate(Tool.OpenWorld, true);
+
+const ReadPullRequestGuideTool = Tool.make("read_pull_request_guide", {
+ description:
+ "Read a pull request's stored review guide and its run: run.Running while one is being written, run.Failed with the reason when the last one failed, guide null when none was written. stale is true after a push the guide has not caught up with. Never starts a run.",
+ parameters: GuideTarget,
+ success: ReadPullRequestGuideResult,
+ failure: PullRequestToolError,
+ dependencies: guideDependencies,
+})
+ .annotate(Tool.Title, "Read pull request guide")
+ .annotate(Tool.Readonly, true)
+ .annotate(Tool.Destructive, false)
+ .annotate(Tool.Idempotent, true)
+ .annotate(Tool.OpenWorld, true);
+
export const PullRequestsToolkit = Toolkit.make(
LinkPullRequestTool,
UnlinkPullRequestTool,
ListThreadPullRequestsTool,
WatchPullRequestTool,
UnwatchPullRequestTool,
+ WritePullRequestGuidesTool,
+ ReadPullRequestGuideTool,
);
diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts
index 91257c2fdb809..c0006f7fe171d 100644
--- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts
+++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts
@@ -16,6 +16,7 @@ import * as GitWorkflowService from "../../../git/GitWorkflowService.ts";
import * as ProviderAdapterRegistry from "../../../orchestration-v2/ProviderAdapterRegistry.ts";
import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
import * as ProjectService from "../../../project/ProjectService.ts";
+import * as PullRequestReviewGuideService from "../../../pullRequest/PullRequestReviewGuideService.ts";
import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts";
import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts";
import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts";
@@ -44,6 +45,7 @@ const layerStubServices = Layer.mergeAll(
Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(ProjectService.ProjectService)({}),
+ Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({}),
ServerSettings.layerTest({}),
Layer.mock(GitWorkflowService.GitWorkflowService)({}),
Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}),
diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts
index db52eb31acf01..167ce8d11fff9 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",
@@ -106,6 +107,8 @@ const RPC_AGGREGATES = {
[WS_METHODS.pullRequestsPreview]: "pull-requests",
[WS_METHODS.pullRequestsChecks]: "pull-requests",
[WS_METHODS.pullRequestsActivity]: "pull-requests",
+ [WS_METHODS.pullRequestsReviewGuide]: "pull-requests",
+ [WS_METHODS.pullRequestsGenerateReviewGuide]: "pull-requests",
[WS_METHODS.pullRequestsThreadComments]: "pull-requests",
[WS_METHODS.pullRequestsDiffFileContents]: "pull-requests",
[WS_METHODS.pullRequestsFilesViewed]: "pull-requests",
diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
index 369f1c29a569c..cfde3367a593d 100644
--- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
@@ -58,6 +58,7 @@ import type * as EffectAcpSchema from "effect-acp/compat";
import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
+import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig";
import {
extractXAiAcpSubagentEndNotice,
extractXAiAcpSubagentUpdate,
@@ -880,6 +881,95 @@ describe("AcpAdapterV2", () => {
}).pipe(Effect.provide(layerTest), Effect.scoped),
);
+ it.live("reapplies a selection after a switch away from it failed partway", () =>
+ Effect.gen(function* () {
+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const path = yield* Path.Path;
+ const mockAgentPath = yield* path.fromFileUrl(
+ new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
+ );
+ const protocolEvents = yield* Queue.unbounded();
+ const instanceId = ProviderInstanceId.make("acp-test-partial-selection-reapply");
+ const threadId = ThreadId.make("thread-acp-partial-selection-reapply");
+ const adapter = yield* makeAcpAdapterV2({
+ instanceId,
+ flavor: {
+ driver: ACP_TEST_DRIVER,
+ capabilities: AcpProviderCapabilitiesV2,
+ makeRuntime: makeMockRuntime({
+ childProcessSpawner,
+ mockAgentPath,
+ protocolEvents,
+ // The model switch lands, then the agent rejects the mode step.
+ environment: { T3_ACP_FAIL_SET_MODE_CONFIG_OPTION: "1" },
+ }),
+ },
+ selfInvocation: yield* resolveSelfInvocation(),
+ });
+ const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ cwd: process.cwd(),
+ });
+ const selectionA = { instanceId, model: "composer-2" } as const;
+ const selectionB = {
+ instanceId,
+ model: "composer-2[fast=true]",
+ options: [{ id: ACP_SESSION_MODE_OPTION_ID, value: "code" }],
+ } as const;
+ const runtime = yield* adapter.openSession({
+ threadId,
+ providerSessionId: ProviderSessionId.make("provider-session-acp-partial-selection"),
+ modelSelection: selectionA,
+ runtimePolicy,
+ });
+ const providerThread = yield* runtime.ensureThread({
+ threadId,
+ modelSelection: selectionA,
+ runtimePolicy,
+ });
+ const now = yield* DateTime.now;
+ const turnInput = (ordinal: number, modelSelection: ModelSelection) =>
+ makeTurnInput({
+ threadId,
+ providerThread,
+ instanceId,
+ runtimePolicy,
+ now,
+ ordinal,
+ modelSelection,
+ });
+
+ const switchToB = yield* Effect.exit(runtime.startTurn(turnInput(1, selectionB)));
+ assert.isTrue(Exit.isFailure(switchToB), "the agent rejected B's mode step");
+ yield* Queue.clear(protocolEvents);
+
+ yield* runtime.startTurn(turnInput(2, selectionA));
+ yield* runtime.events.pipe(
+ Stream.takeUntil((event) => event.type === "turn.terminal"),
+ Stream.runDrain,
+ );
+ const modelSetsBeforePrompt: Array = [];
+ while (true) {
+ const event = yield* Queue.take(protocolEvents);
+ if (event.direction !== "outgoing") continue;
+ const method = rawProtocolMethod(event);
+ if (method === "session/prompt") break;
+ if (
+ method === "session/set_config_option" &&
+ rawProtocolRequestParam(event, "configId") === "model"
+ ) {
+ modelSetsBeforePrompt.push(rawProtocolRequestParam(event, "value"));
+ }
+ }
+ assert.deepEqual(
+ modelSetsBeforePrompt,
+ ["composer-2"],
+ "the native model was left on B, so the turn on A must set it back first",
+ );
+ }).pipe(Effect.provide(layerTest), Effect.scoped),
+ );
+
it.effect("starts a new replay message after ACP v2 plan boundaries", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -12226,7 +12316,11 @@ describe("AcpAdapterV2", () => {
});
assert.equal(taskkillCommands.length, 1);
yield* Scope.close(runtimeScope, Exit.void);
- assert.equal(taskkillCommands.length, 1);
+ // The scope finalizer picks its kill path from the host, not processGroupPlatform,
+ // so only a Windows host sends it through taskkill a second time.
+ const finalizerTaskkills = (yield* HostProcess.Platform) === "win32" ? 1 : 0;
+ assert.equal(taskkillCommands.length, 1 + finalizerTaskkills);
+ assert.deepEqual(taskkillCommands.at(-1), taskkillCommands[0]);
}).pipe(Effect.provide(layerTest)),
);
@@ -12343,6 +12437,7 @@ describe("AcpAdapterV2", () => {
it.live("poisons the session when hard teardown defects and blocks replacement work", () =>
Effect.gen(function* () {
+ if ((yield* HostProcess.Platform) !== "linux") return;
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fileSystem = yield* FileSystem.FileSystem;
const idAllocator = yield* IdAllocator.IdAllocatorV2;
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
index d80e76072388e..4bf007065a739 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,
@@ -2784,6 +2806,75 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);
+ it.effect.each([
+ { model: "claude-opus-5-5", expected: "assistant_message" },
+ { model: "claude-fable-5-1", expected: "assistant_message" },
+ { model: "claude-opus-5", expected: "reasoning" },
+ { model: "claude-sonnet-4-6", expected: "reasoning" },
+ ] as const)(
+ "shows $model thinking right before a tool call as $expected",
+ ({ model, expected }) =>
+ Effect.gen(function* () {
+ const harness = yield* makeWakeHarness;
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now: yield* DateTime.now,
+ attemptId: RunAttemptId.make("thinking-before-tool-attempt"),
+ text: "Check the memory file",
+ attachments: [],
+ }),
+ );
+ const snapshot = (uuid: string, content: ReadonlyArray) =>
+ claudeSdkFrame({
+ type: "assistant",
+ uuid,
+ session_id: WAKE_NATIVE_SESSION,
+ parent_tool_use_id: null,
+ message: { id: "reply-message", model, content },
+ });
+ const frames = [
+ snapshot("reasoning-snapshot", [
+ { type: "thinking", thinking: "Planning the check", signature: "sig" },
+ ]),
+ snapshot("reply-snapshot", [
+ {
+ type: "thinking",
+ thinking: "Found it: the memory file is the cause.",
+ signature: "sig",
+ },
+ ]),
+ snapshot("tool-snapshot", [
+ { type: "tool_use", id: "tool-1", name: "Bash", input: { command: "ls" } },
+ ]),
+ makeResultFrame({ uuid: "thinking-before-tool-result", result: "" }),
+ ];
+ for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame);
+ yield* Queue.take(harness.terminalReceipts);
+ const latest = new Map(
+ harness.events.flatMap((event) =>
+ event.type === "turn_item.updated" &&
+ (event.turnItem.type === "reasoning" || event.turnItem.type === "assistant_message")
+ ? [[event.turnItem.id, event.turnItem] as const]
+ : [],
+ ),
+ );
+ assert.deepEqual(
+ [...latest.values()].map((item) => [item.type, item.text]),
+ [
+ ["reasoning", "Planning the check"],
+ [expected, "Found it: the memory file is the cause."],
+ ],
+ );
+ }).pipe(
+ Effect.scoped,
+ Effect.provide(
+ Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
+ ),
+ ),
+ );
+
it.effect.each(["cancelled", "denied", "permission_denied", undefined])(
"preserves native tool non-execution metadata %s without inferring a denial from text",
(kind) =>
@@ -3333,6 +3424,74 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);
+ it.effect("keeps the measured context when a usage limit ends the turn", () =>
+ Effect.gen(function* () {
+ const harness = yield* makeWakeHarness;
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now: yield* DateTime.now,
+ attemptId: RunAttemptId.make("attempt-limit-context"),
+ text: "Continue.",
+ attachments: [],
+ }),
+ );
+ yield* Queue.offer(
+ harness.sdkMessages,
+ makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000660", text: "Working." }),
+ );
+ // The CLI answers a 429 with a locally generated frame that reports no usage.
+ yield* Queue.offer(
+ harness.sdkMessages,
+ claudeSdkFrame({
+ type: "assistant",
+ message: {
+ model: "",
+ id: "00000000-0000-4000-8000-000000000661",
+ type: "message",
+ role: "assistant",
+ content: [{ type: "text", text: "You've hit your session limit" }],
+ stop_reason: "stop_sequence",
+ stop_sequence: "",
+ usage: {
+ input_tokens: 0,
+ output_tokens: 0,
+ cache_creation_input_tokens: 0,
+ cache_read_input_tokens: 0,
+ },
+ },
+ parent_tool_use_id: null,
+ error: "rate_limit",
+ uuid: "00000000-0000-4000-8000-000000000661",
+ session_id: WAKE_NATIVE_SESSION,
+ }),
+ );
+ yield* Queue.offer(
+ harness.sdkMessages,
+ makeResultFrame({
+ uuid: "00000000-0000-4000-8000-000000000662",
+ result: "You've hit your session limit",
+ terminalReason: "api_error",
+ isError: true,
+ apiErrorStatus: 429,
+ }),
+ );
+ const terminal = yield* Queue.take(harness.terminalReceipts);
+ assert.equal(terminal.status, "failed");
+ const reportedContext = harness.events.flatMap((event) =>
+ event.type === "provider_turn.updated" && event.providerTurn.tokenUsage
+ ? [event.providerTurn.tokenUsage.usedTokens]
+ : [],
+ );
+ assert.deepEqual(reportedContext, [2]);
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
+ ),
+ ),
+ );
+
it.effect("surfaces a Claude safety model fallback without failing the turn", () =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
@@ -4709,6 +4868,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 +5128,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 +6708,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 +6770,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 +9855,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 +10045,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 +10287,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 +10486,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 +10669,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 +10808,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 +11043,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 730ed8a575f2c..de9254aeaf71c 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 da344c202ff0f..7199f73040ef9 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"),
@@ -1133,6 +1165,15 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content"
return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
}
+// Claude 5.1 and later return the text written ahead of a tool call as a
+// thinking block, at most one per call, instead of a text block.
+function claudeWritesUpdatesAsThinking(model: string): boolean {
+ const match = /claude-[a-z]+-(\d+)(?:-(\d{1,2}))?(?!\d)/u.exec(model);
+ if (match === null) return false;
+ const major = Number(match[1]);
+ return major > 5 || (major === 5 && Number(match[2] ?? 0) >= 1);
+}
+
// In SDK mode Claude reports `/goal` only through the transcript (its
// `active_goal` event is remote-only): synthetic command output names the
// goal, and each unmet evaluator check returns as Stop hook feedback.
@@ -1534,6 +1575,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);
}
@@ -2868,6 +2915,12 @@ interface ActiveClaudeTurnContext {
readonly nextBlockIndex: Map;
readonly snapshotBlockIndex: Map;
readonly snapshots: Set;
+ // The latest snapshot block, when it was non-empty thinking.
+ lastThinking: {
+ readonly messageId: string;
+ readonly itemId: string;
+ readonly text: string;
+ } | null;
readonly blocks: Map<
string,
{ readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string }
@@ -2908,6 +2961,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 +2991,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 +3395,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 +4419,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 +4470,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 +4817,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 +4868,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 +4911,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 +4985,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 +5002,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 +5014,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 +5021,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 +5076,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 +5157,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 +5227,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 +5702,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 +5736,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 +6019,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 +6100,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 +6683,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") {
@@ -6208,7 +6767,23 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
// The SDK emits one assistant snapshot per completed content block.
// Count thinking blocks separately so snapshots share the streamed ID.
for (const block of message.message.content) {
- if (block.type !== "thinking") continue;
+ if (block.type !== "thinking") {
+ const update = context.reasoning.lastThinking;
+ context.reasoning.lastThinking = null;
+ if (
+ block.type === "tool_use" &&
+ update?.messageId === message.message.id &&
+ claudeWritesUpdatesAsThinking(message.message.model)
+ ) {
+ // Same item ID, so the update replaces the Thought in place.
+ yield* emitAssistantTextArtifacts({
+ context,
+ nativeItemId: update.itemId,
+ text: update.text,
+ });
+ }
+ continue;
+ }
const index = context.reasoning.snapshotBlockIndex.get(message.message.id) ?? 0;
context.reasoning.snapshotBlockIndex.set(message.message.id, index + 1);
const itemId = `${message.message.id}:thinking:${index}`;
@@ -6220,6 +6795,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
...(finalText ? { finalText } : {}),
emitEmpty: false,
});
+ context.reasoning.lastThinking = finalText
+ ? { messageId: message.message.id, itemId, text: finalText }
+ : null;
}
}
@@ -6389,7 +6967,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
if (message.type === "assistant" && input.replayed !== true) {
const now = yield* DateTime.now;
yield* completeProviderRetry(context, now);
- if (message.parent_tool_use_id === null && message.message.usage !== undefined) {
+ // `` frames (usage limits, sign-in errors, /goal status)
+ // are generated by the CLI and report zero usage, not the context.
+ if (
+ message.parent_tool_use_id === null &&
+ message.message.usage !== undefined &&
+ message.message.model !== ""
+ ) {
yield* emitProviderEvent({
type: "provider_turn.updated",
driver: CLAUDE_PROVIDER,
@@ -6523,6 +7107,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 +7123,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 +7163,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 +7178,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 +7233,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 +7301,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 +7332,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 +7484,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 +8348,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 +8361,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 +8394,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
);
if (ownsLiveQuery) {
yield* finalizeActiveTurnAfterQueryExit(
+ context,
exit._tag === "Failure" ? exit.cause : undefined,
);
yield* endSubagentCallsOfClosedQuery(
@@ -7847,6 +8475,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
streamBlocks: new Map(),
nextBlockIndex: new Map(),
snapshotBlockIndex: new Map(),
+ lastThinking: null,
snapshots: new Set(),
blocks: new Map(),
},
@@ -7885,17 +8514,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 +8537,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,
- );
- 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),
+ // 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 });
+ }
+ }),
);
- if (hasOpaqueTaskNotification && !hasNativeWakeFrame) {
- const completedAt = yield* DateTime.now;
- yield* finalizeActiveTurn({ context, status: "completed", completedAt });
- }
},
(effect, turnInput) =>
effect.pipe(
@@ -8042,14 +8706,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 +8826,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 +8938,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 0000000000000..b782ad676423c
--- /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 398337b22e2c1..d8ef1461ef73c 100644
--- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
@@ -14,7 +14,10 @@ import {
} from "../../provider/CodexTurnTokenUsage.ts";
import type { ManagedServerProvider } from "@t3tools/provider-core/server/snapshot";
import type { CodexEffectiveRuntime } from "../../provider/CodexManagedRuntime.ts";
-import { buildCodexInitializeParams } from "../../provider/CodexProvider.ts";
+import {
+ buildCodexInitializeParams,
+ codexAppServerEnvironment,
+} from "../../provider/CodexProvider.ts";
import {
codexRateLimitsToUpdate,
mergeCodexRateLimits,
@@ -1511,7 +1514,7 @@ export const layerAppServerClientFactory: Layer.Layer<
Effect.gen(function* () {
const scope = yield* Scope.Scope;
const environment = {
- ...input.environment,
+ ...codexAppServerEnvironment(input.environment),
...(input.settings.homePath ? { CODEX_HOME: input.settings.homePath } : {}),
};
const command = yield* makeCodexAppServerSpawnCommand({
@@ -4976,15 +4979,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
node: artifacts.node,
});
yield* emitProviderEvent({
- type: "runtime_request.updated",
+ type: "turn_item.updated",
driver: CODEX_PROVIDER,
- threadId: artifacts.node.threadId,
- runtimeRequest: artifacts.request,
+ turnItem: artifacts.turnItem,
});
yield* emitProviderEvent({
- type: "turn_item.updated",
+ type: "runtime_request.updated",
driver: CODEX_PROVIDER,
- turnItem: artifacts.turnItem,
+ threadId: artifacts.node.threadId,
+ runtimeRequest: artifacts.request,
});
yield* Ref.update(finalAnswerItemIdsByTurn, (current) => {
const ids = current.get(payload.turnId);
@@ -5083,15 +5086,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
node: artifacts.node,
});
yield* emitProviderEvent({
- type: "runtime_request.updated",
+ type: "turn_item.updated",
driver: CODEX_PROVIDER,
- threadId: artifacts.node.threadId,
- runtimeRequest: artifacts.request,
+ turnItem: artifacts.turnItem,
});
yield* emitProviderEvent({
- type: "turn_item.updated",
+ type: "runtime_request.updated",
driver: CODEX_PROVIDER,
- turnItem: artifacts.turnItem,
+ threadId: artifacts.node.threadId,
+ runtimeRequest: artifacts.request,
});
const resolved = yield* Deferred.await(decision).pipe(
@@ -5143,15 +5146,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
node: artifacts.node,
});
yield* emitProviderEvent({
- type: "runtime_request.updated",
+ type: "turn_item.updated",
driver: CODEX_PROVIDER,
- threadId: artifacts.node.threadId,
- runtimeRequest: artifacts.request,
+ turnItem: artifacts.turnItem,
});
yield* emitProviderEvent({
- type: "turn_item.updated",
+ type: "runtime_request.updated",
driver: CODEX_PROVIDER,
- turnItem: artifacts.turnItem,
+ threadId: artifacts.node.threadId,
+ runtimeRequest: artifacts.request,
});
const resolved = yield* Deferred.await(decision).pipe(
@@ -5204,15 +5207,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
node: artifacts.node,
});
yield* emitProviderEvent({
- type: "runtime_request.updated",
+ type: "turn_item.updated",
driver: CODEX_PROVIDER,
- threadId: artifacts.node.threadId,
- runtimeRequest: artifacts.request,
+ turnItem: artifacts.turnItem,
});
yield* emitProviderEvent({
- type: "turn_item.updated",
+ type: "runtime_request.updated",
driver: CODEX_PROVIDER,
- turnItem: artifacts.turnItem,
+ threadId: artifacts.node.threadId,
+ runtimeRequest: artifacts.request,
});
const resolved = yield* Deferred.await(decision).pipe(
@@ -5289,15 +5292,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
node: artifacts.node,
});
yield* emitProviderEvent({
- type: "runtime_request.updated",
+ type: "turn_item.updated",
driver: CODEX_PROVIDER,
- threadId: artifacts.node.threadId,
- runtimeRequest: artifacts.request,
+ turnItem: artifacts.turnItem,
});
yield* emitProviderEvent({
- type: "turn_item.updated",
+ type: "runtime_request.updated",
driver: CODEX_PROVIDER,
- turnItem: artifacts.turnItem,
+ threadId: artifacts.node.threadId,
+ runtimeRequest: artifacts.request,
});
const resolved = yield* Deferred.await(decision).pipe(
@@ -5348,15 +5351,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
node: artifacts.node,
});
yield* emitProviderEvent({
- type: "runtime_request.updated",
+ type: "turn_item.updated",
driver: CODEX_PROVIDER,
- threadId: artifacts.node.threadId,
- runtimeRequest: artifacts.request,
+ turnItem: artifacts.turnItem,
});
yield* emitProviderEvent({
- type: "turn_item.updated",
+ type: "runtime_request.updated",
driver: CODEX_PROVIDER,
- turnItem: artifacts.turnItem,
+ threadId: artifacts.node.threadId,
+ runtimeRequest: artifacts.request,
});
const resolved = yield* Deferred.await(decision).pipe(
@@ -5408,15 +5411,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
node: artifacts.node,
});
yield* emitProviderEvent({
- type: "runtime_request.updated",
+ type: "turn_item.updated",
driver: CODEX_PROVIDER,
- threadId: artifacts.node.threadId,
- runtimeRequest: artifacts.request,
+ turnItem: artifacts.turnItem,
});
yield* emitProviderEvent({
- type: "turn_item.updated",
+ type: "runtime_request.updated",
driver: CODEX_PROVIDER,
- turnItem: artifacts.turnItem,
+ threadId: artifacts.node.threadId,
+ runtimeRequest: artifacts.request,
});
const resolved = yield* Deferred.await(decision).pipe(
@@ -5466,15 +5469,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
node: artifacts.node,
});
yield* emitProviderEvent({
- type: "runtime_request.updated",
+ type: "turn_item.updated",
driver: CODEX_PROVIDER,
- threadId: artifacts.node.threadId,
- runtimeRequest: artifacts.request,
+ turnItem: artifacts.turnItem,
});
yield* emitProviderEvent({
- type: "turn_item.updated",
+ type: "runtime_request.updated",
driver: CODEX_PROVIDER,
- turnItem: artifacts.turnItem,
+ threadId: artifacts.node.threadId,
+ runtimeRequest: artifacts.request,
});
const resolved = yield* Deferred.await(answers).pipe(
diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts
index 5c7161decd449..548ff5cb6643b 100644
--- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts
@@ -48,6 +48,10 @@ import {
t3McpServerName,
} from "@t3tools/provider-opencode/testing";
import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts";
+import {
+ makeProviderReplayGate,
+ type ProviderReplayGate,
+} from "@t3tools/provider-testing/replayGate";
const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX";
const WORK = "/work/opencode2";
@@ -64,12 +68,18 @@ const reply = (operation: string, data: unknown): ProviderReplayEntry => ({
frame: { type: "sdk.response", operation, data },
});
const replyData = (operation: string, data: unknown) => reply(operation, { data });
-const event = (type: string, data: Record): ProviderReplayEntry => ({
+const event = (
+ type: string,
+ data: Record,
+ /** Holds the event until a test's replay gate releases this label. */
+ label?: string,
+): ProviderReplayEntry => ({
type: "emit_inbound",
frame: {
type: "sdk.event",
event: { id: `evt_${type.replaceAll(".", "")}0000`, created: 1, type, data, ...durable },
},
+ ...(label === undefined ? {} : { label }),
});
const durable = { durable: { aggregateID: SESSION, seq: 1, version: 1 } };
@@ -316,7 +326,11 @@ const colorForm = {
*/
const resumed = (
entries: ReadonlyArray,
- options?: { readonly external?: boolean; readonly supervised?: boolean },
+ options?: {
+ readonly external?: boolean;
+ readonly supervised?: boolean;
+ readonly replayGate?: ProviderReplayGate;
+ },
) =>
Effect.gen(function* () {
const runtime = yield* openCode2ReplayRuntime(
@@ -335,7 +349,10 @@ const resumed = (
: []),
...entries,
]),
- options?.external === undefined ? undefined : { external: options.external },
+ {
+ ...(options?.external === undefined ? {} : { external: options.external }),
+ ...(options?.replayGate === undefined ? {} : { replayGate: options.replayGate }),
+ },
);
const thread = yield* runtime.resumeThread({
providerThread: providerThread(yield* DateTime.now),
@@ -698,6 +715,28 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
} as OrchestrationV2AppThread,
});
+ /**
+ * The continuation turn the orchestrator starts for an offered wake: its
+ * message is the one the offer named, as `ProviderContinuationService` dispatches it.
+ */
+ const continuationTurn = (
+ thread: OrchestrationV2ProviderThread,
+ offer: ProviderContinuationRequests.ProviderContinuationRequest,
+ suffix = "wake",
+ ) => ({
+ ...withLineage(thread),
+ runId: RunId.make(`run:opencode2-adapter:${suffix}`),
+ runOrdinal: 2,
+ providerTurnOrdinal: 2,
+ attemptId: RunAttemptId.make(`attempt:opencode2-adapter:${suffix}`),
+ message: {
+ ...turnInput(thread).message,
+ messageId: offer.messageId!,
+ text: offer.detail ?? "",
+ createdBy: "agent" as const,
+ creationSource: "provider" as const,
+ },
+ });
const CHILD = "ses_f1485c529ffea4URrYruwEg0Ja";
/**
* The single reader of the runtime's events: resolves `attached` once the
@@ -1724,6 +1763,88 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
}).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
);
+ it.effect("still checks the server when a failed prompt's execution starts late", () =>
+ Effect.gen(function* () {
+ const offered = yield* Deferred.make();
+ const { runtime, thread } = yield* resumed([
+ out("session.prompt", { sessionID: SESSION, text: "" }),
+ // The request failed, but the server took the prompt: its execution
+ // starts only after the turn already failed.
+ reply("session.prompt", {
+ status: 502,
+ body: { _tag: "UnknownError", message: "bad gateway" },
+ }),
+ event("session.execution.started", { sessionID: SESSION }),
+ // That run is still going, so the next turn stops it and fails.
+ out("session.active"),
+ reply("session.active", { data: { [SESSION]: { type: "running" } } }),
+ out("session.interrupt", { sessionID: SESSION }),
+ reply("session.interrupt", { interrupted: true }),
+ ]).pipe(
+ Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
+ offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid),
+ take: Effect.never,
+ }),
+ );
+ const ended = yield* terminals(runtime, 2);
+ yield* runtime.startTurn(turnInput(thread)).pipe(Effect.ignore);
+ // The late start has been read.
+ yield* Deferred.await(offered);
+ yield* runtime.startTurn(secondTurn(thread));
+ const [first, second] = yield* Fiber.join(ended);
+ assert.equal(first?.status, "failed");
+ assert.equal(second?.failure?.message, OPENCODE_2_STILL_STOPPING);
+ }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
+ );
+
+ it.effect("still checks the server when a failed prompt starts after a reconnect", () =>
+ Effect.gen(function* () {
+ const offered = yield* Deferred.make();
+ const gate = makeProviderReplayGate(["drop"]);
+ const { runtime, thread } = yield* resumed(
+ [
+ out("session.prompt", { sessionID: SESSION, text: "" }),
+ reply("session.prompt", {
+ status: 502,
+ body: { _tag: "UnknownError", message: "bad gateway" },
+ }),
+ // The stream drops before the prompt's execution starts, and the
+ // reconnect finds the session active: the prompt's run is still to come.
+ event("session.usage.updated", { sessionID: SESSION }, "drop"),
+ { type: "runtime_exit", status: "success" } as const,
+ out("event.subscribe"),
+ out("session.active"),
+ replyData("session.active", { [SESSION]: { type: "running" } }),
+ out("permission.list", { sessionID: SESSION }),
+ replyData("permission.list", []),
+ out("session.form.list", { sessionID: SESSION }),
+ replyData("session.form.list", []),
+ // Its own start: that run is still going, so the next turn stops it and fails.
+ event("session.execution.started", { sessionID: SESSION }),
+ out("session.active"),
+ reply("session.active", { data: { [SESSION]: { type: "running" } } }),
+ out("session.interrupt", { sessionID: SESSION }),
+ reply("session.interrupt", { interrupted: true }),
+ ],
+ { replayGate: gate },
+ ).pipe(
+ Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
+ offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid),
+ take: Effect.never,
+ }),
+ );
+ const ended = yield* terminals(runtime, 2);
+ yield* runtime.startTurn(turnInput(thread)).pipe(Effect.ignore);
+ gate.release("drop");
+ // The late start has been read.
+ yield* Deferred.await(offered);
+ yield* runtime.startTurn(secondTurn(thread));
+ const [first, second] = yield* Fiber.join(ended);
+ assert.equal(first?.status, "failed");
+ assert.equal(second?.failure?.message, OPENCODE_2_STILL_STOPPING);
+ }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
+ );
+
it.effect("prompts again without a check after the server refused a prompt", () =>
Effect.gen(function* () {
const { runtime, thread } = yield* resumed([
@@ -1764,85 +1885,436 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
}).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
);
- it.effect("does not report a Stop the server says did nothing", () =>
- Effect.gen(function* () {
- const { runtime, thread } = yield* resumed([
- out("session.prompt", { sessionID: SESSION, text: "" }),
- promptAccepted,
- out("session.interrupt", { sessionID: SESSION }),
- reply("session.interrupt", { interrupted: false }),
- ]);
- yield* runtime.startTurn(turnInput(thread));
- const failed = yield* runtime
- .interruptTurn({
- providerThread: thread,
- providerTurnId: yield* providerTurnId,
- })
- .pipe(Effect.flip);
- assert.equal(failed._tag, "ProviderAdapterProtocolError");
- }).pipe(Effect.scoped),
- );
-
- it.effect("reports a turn as completed when its Stop request failed", () =>
+ it.effect("ends a background reply's continuation after a lost stream took a Stop's end", () =>
Effect.gen(function* () {
- const { runtime, thread } = yield* resumed([
- out("session.prompt", { sessionID: SESSION, text: "" }),
- promptAccepted,
- out("session.interrupt", { sessionID: SESSION }),
- reply("session.interrupt", {
- status: 500,
- body: { _tag: "UnknownError", message: "interrupt failed" },
+ const offered =
+ yield* Deferred.make();
+ const gate = makeProviderReplayGate(["drop", "reply"]);
+ const { runtime, thread } = yield* resumed(
+ [
+ ...backgroundLaunch(CHILD),
+ // Restarting the turn stops only the parent, and the server never answers.
+ out("session.interrupt", { sessionID: SESSION }),
+ reply("session.interrupt", ""),
+ // The restarted turn finds the parent still stopping and fails.
+ out("session.active"),
+ replyData("session.active", { [SESSION]: { type: "running" } }),
+ out("session.interrupt", { sessionID: SESSION }),
+ reply("session.interrupt", { interrupted: true }),
+ // The stream drops before the parent's end; the reconnect finds it idle.
+ event("session.usage.updated", { sessionID: SESSION }, "drop"),
+ { type: "runtime_exit", status: "success" } as const,
+ out("event.subscribe"),
+ out("session.active"),
+ replyData("session.active", { [CHILD]: { type: "running" } }),
+ out("permission.list", { sessionID: CHILD }),
+ replyData("permission.list", []),
+ out("session.form.list", { sessionID: CHILD }),
+ replyData("session.form.list", []),
+ // The subagent ends and OpenCode answers its report on its own.
+ event("session.execution.succeeded", { sessionID: CHILD }),
+ event("session.inbox.enqueued", {
+ inboxID: "msg_report",
+ sessionID: SESSION,
+ item: {
+ type: "synthetic",
+ payload: {
+ text: `\nCHILD_OK\n`,
+ description: "Sleep",
+ metadata: {
+ source: "subagent",
+ childID: CHILD,
+ agent: "General",
+ state: "completed",
+ },
+ },
+ delivery: "steer",
+ },
+ }),
+ event("session.execution.started", { sessionID: SESSION }),
+ event(
+ "session.text.ended",
+ {
+ sessionID: SESSION,
+ assistantMessageID: "msg_followup",
+ ordinal: 0,
+ text: "CHILD_OK",
+ },
+ "reply",
+ ),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ // The subagent's session runs again: its turn marks that the reply's end was handled.
+ event("session.execution.started", { sessionID: CHILD }),
+ ],
+ { replayGate: gate },
+ ).pipe(
+ Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
+ offer: (request) => Deferred.succeed(offered, request).pipe(Effect.asVoid),
+ take: Effect.never,
}),
- event("session.execution.succeeded", { sessionID: SESSION }),
- ]);
- const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
- yield* runtime.startTurn(turnInput(thread));
- const failed = yield* runtime
+ );
+ // The single reader of the runtime's events.
+ const attached = yield* Deferred.make();
+ const collected = yield* runtime.events.pipe(
+ Stream.tap((event) =>
+ event.type === "subagent.updated" && event.subagent.childThreadId !== null
+ ? Deferred.succeed(attached, undefined)
+ : Effect.void,
+ ),
+ Stream.takeUntil(
+ (event) =>
+ event.type === "provider_turn.updated" &&
+ event.providerTurn.nativeTurnRef?.nativeId?.startsWith(`${CHILD}:turn:`) === true,
+ ),
+ Stream.runCollect,
+ Effect.forkScoped,
+ );
+ yield* runtime.startTurn(withLineage(thread));
+ yield* Deferred.await(attached);
+ const interrupt = yield* runtime
.interruptTurn({ providerThread: thread, providerTurnId: yield* providerTurnId })
- .pipe(Effect.flip);
- assert.equal(failed._tag, "ProviderAdapterInterruptError");
- assert.equal((yield* Fiber.join(terminal))?.status, "completed");
- }).pipe(Effect.scoped),
- );
-
- it.effect("refuses to resume a thread without an OpenCode session as a protocol error", () =>
- Effect.gen(function* () {
- const runtime = yield* openCode2ReplayRuntime(opening);
- const failed = yield* runtime
- .resumeThread({
- providerThread: { ...providerThread(yield* DateTime.now), nativeThreadRef: null },
- })
- .pipe(Effect.flip);
- assert.equal(failed._tag, "ProviderAdapterProtocolError");
- }).pipe(Effect.scoped),
+ .pipe(Effect.forkScoped);
+ yield* TestClock.adjust("11 seconds");
+ yield* Fiber.join(interrupt);
+ yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread });
+ gate.release("drop");
+ yield* runtime.startTurn({
+ ...continuationTurn(thread, yield* Deferred.await(offered)),
+ runOrdinal: 3,
+ providerTurnOrdinal: 3,
+ });
+ gate.release("reply");
+ // The reply's own end finishes its continuation: the lost Stop's end does not take it.
+ const events = [...(yield* Fiber.join(collected))];
+ assert.deepEqual(
+ events.flatMap((event) => (event.type === "turn.terminal" ? [event.status] : [])),
+ ["interrupted", "failed", "completed"],
+ );
+ // The continuation replayed the reply.
+ assert.include(
+ events.flatMap((event) =>
+ event.type === "turn_item.updated" && event.turnItem.type === "assistant_message"
+ ? [event.turnItem.text]
+ : [],
+ ),
+ "CHILD_OK",
+ );
+ }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
);
- it.effect("gives a resumed session T3's rules when it was made with others", () =>
+ it.effect("lets a prompt join a background reply after a lost stream took a Stop's end", () =>
Effect.gen(function* () {
- const runtime = yield* openCode2ReplayRuntimeWithInstructions([
- ...opening,
- out("session.get", { sessionID: SESSION }),
- // Made by an earlier build that denied subagents; resuming drops the deny.
- replyData(
- "session.get",
- sessionInfo({
- permissions: [
- { action: "*", resource: "*", effect: "allow" },
- { action: "subagent", resource: "*", effect: "deny" },
- ],
+ const offered = yield* Deferred.make();
+ const gate = makeProviderReplayGate(["drop"]);
+ const { runtime, thread } = yield* resumed(
+ [
+ ...backgroundLaunch(CHILD),
+ // Restarting the turn stops only the parent, and the server never answers.
+ out("session.interrupt", { sessionID: SESSION }),
+ reply("session.interrupt", ""),
+ // The restarted turn finds the parent still stopping and fails.
+ out("session.active"),
+ replyData("session.active", { [SESSION]: { type: "running" } }),
+ out("session.interrupt", { sessionID: SESSION }),
+ reply("session.interrupt", { interrupted: true }),
+ // The stream drops before the parent's end; the reconnect finds it idle.
+ event("session.usage.updated", { sessionID: SESSION }, "drop"),
+ { type: "runtime_exit", status: "success" } as const,
+ out("event.subscribe"),
+ out("session.active"),
+ replyData("session.active", { [CHILD]: { type: "running" } }),
+ out("permission.list", { sessionID: CHILD }),
+ replyData("permission.list", []),
+ out("session.form.list", { sessionID: CHILD }),
+ replyData("session.form.list", []),
+ // The subagent ends and OpenCode answers its report on its own.
+ event("session.execution.succeeded", { sessionID: CHILD }),
+ event("session.inbox.enqueued", {
+ inboxID: "msg_report",
+ sessionID: SESSION,
+ item: {
+ type: "synthetic",
+ payload: {
+ text: `\nCHILD_OK\n`,
+ description: "Sleep",
+ metadata: {
+ source: "subagent",
+ childID: CHILD,
+ agent: "General",
+ state: "completed",
+ },
+ },
+ delivery: "steer",
+ },
+ }),
+ event("session.execution.started", { sessionID: SESSION }),
+ // A prompt sent while that reply runs joins it, with no check first.
+ out("session.prompt", { sessionID: SESSION, text: "" }),
+ promptAccepted,
+ event("session.text.ended", {
+ sessionID: SESSION,
+ assistantMessageID: "msg_followup",
+ ordinal: 0,
+ text: "CHILD_OK",
}),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ ],
+ { replayGate: gate },
+ ).pipe(
+ Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
+ offer: () => Deferred.succeed(offered, undefined).pipe(Effect.asVoid),
+ take: Effect.never,
+ }),
+ );
+ // The single reader of the runtime's events.
+ const attached = yield* Deferred.make();
+ const ended = yield* runtime.events.pipe(
+ Stream.tap((event) =>
+ event.type === "subagent.updated" && event.subagent.childThreadId !== null
+ ? Deferred.succeed(attached, undefined)
+ : Effect.void,
),
- ...noOpenRequests,
- out("session.update", { sessionID: SESSION, permissions: t3Rules }),
- reply("session.update", null),
- ]);
- yield* runtime.resumeThread({
- providerThread: providerThread(yield* DateTime.now),
- threadId,
- modelSelection: bigPickle,
- runtimePolicy: policy(),
+ Stream.filter(
+ (
+ event,
+ ): event is Extract =>
+ event.type === "turn.terminal",
+ ),
+ Stream.take(3),
+ Stream.runCollect,
+ Effect.forkScoped,
+ );
+ yield* runtime.startTurn(withLineage(thread));
+ yield* Deferred.await(attached);
+ const interrupt = yield* runtime
+ .interruptTurn({ providerThread: thread, providerTurnId: yield* providerTurnId })
+ .pipe(Effect.forkScoped);
+ yield* TestClock.adjust("11 seconds");
+ yield* Fiber.join(interrupt);
+ yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread });
+ gate.release("drop");
+ // The reply has started.
+ yield* Deferred.await(offered);
+ yield* runtime.startTurn({
+ ...withLineage(thread),
+ runId: RunId.make("run:opencode2-adapter:3"),
+ runOrdinal: 3,
+ providerTurnOrdinal: 3,
+ attemptId: RunAttemptId.make("attempt:opencode2-adapter:3"),
});
- }).pipe(Effect.scoped),
+ // The reply's start cleared the Stop's mark, so its end finishes the joined turn.
+ assert.deepEqual(
+ [...(yield* Fiber.join(ended))].map((terminal) => terminal.status),
+ ["interrupted", "failed", "completed"],
+ );
+ }).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
+ );
+
+ /**
+ * A prompt fails without a clear rejection, OpenCode never took it, and a
+ * background subagent's report then wakes the parent into a reply. With
+ * `endedFirst` that reply ends before its continuation takes it.
+ */
+ const replyAfterUnclearFailure = (endedFirst: boolean) =>
+ Effect.gen(function* () {
+ const offered =
+ yield* Deferred.make();
+ const gate = makeProviderReplayGate(endedFirst ? ["wake"] : ["wake", "reply"]);
+ const { runtime, thread } = yield* resumed(
+ [
+ ...backgroundLaunch(CHILD),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ out("session.update", { sessionID: CHILD, permissions: t3Rules }),
+ reply("session.update", null),
+ out("session.prompt", { sessionID: SESSION, text: "" }),
+ reply("session.prompt", {
+ status: 502,
+ body: { _tag: "UnknownError", message: "bad gateway" },
+ }),
+ // The subagent ends and OpenCode answers its report on its own.
+ event("session.execution.succeeded", { sessionID: CHILD }, "wake"),
+ event("session.inbox.enqueued", {
+ inboxID: "msg_report",
+ sessionID: SESSION,
+ item: {
+ type: "synthetic",
+ payload: {
+ text: `\nCHILD_OK\n`,
+ description: "Sleep",
+ metadata: {
+ source: "subagent",
+ childID: CHILD,
+ agent: "General",
+ state: "completed",
+ },
+ },
+ delivery: "steer",
+ },
+ }),
+ event("session.execution.started", { sessionID: SESSION }),
+ event(
+ "session.text.ended",
+ {
+ sessionID: SESSION,
+ assistantMessageID: "msg_followup",
+ ordinal: 0,
+ text: "CHILD_OK",
+ },
+ endedFirst ? undefined : "reply",
+ ),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ // The subagent's session runs again: a turn of its own, after the reply's end.
+ event("session.execution.started", { sessionID: CHILD }),
+ ],
+ { replayGate: gate },
+ ).pipe(
+ Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
+ offer: (request) => Deferred.succeed(offered, request).pipe(Effect.asVoid),
+ take: Effect.never,
+ }),
+ );
+ // The single reader of the runtime's events.
+ const launched = yield* Deferred.make();
+ const childRan = yield* Deferred.make();
+ const ended: Array = [];
+ const allEnded = yield* Deferred.make();
+ yield* runtime.events.pipe(
+ Stream.tap((event) =>
+ Effect.gen(function* () {
+ if (
+ event.type === "provider_turn.updated" &&
+ event.providerTurn.nativeTurnRef?.nativeId?.startsWith(`${CHILD}:turn:`) === true
+ ) {
+ yield* Deferred.succeed(childRan, undefined);
+ }
+ if (event.type !== "turn_item.updated" && event.type !== "turn.terminal") return;
+ ended.push(event);
+ if (event.type === "turn.terminal") yield* Deferred.succeed(launched, undefined);
+ if (ended.filter((entry) => entry.type === "turn.terminal").length === 3) {
+ yield* Deferred.succeed(allEnded, undefined);
+ }
+ }),
+ ),
+ Stream.runDrain,
+ Effect.forkScoped,
+ );
+ yield* runtime.startTurn(withLineage(thread));
+ yield* Deferred.await(launched);
+ yield* runtime
+ .startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread })
+ .pipe(Effect.ignore);
+ gate.release("wake");
+ // Everything up to the subagent's next run has been read: the reply ended.
+ if (endedFirst) yield* Deferred.await(childRan);
+ yield* runtime.startTurn({
+ ...continuationTurn(thread, yield* Deferred.await(offered)),
+ runOrdinal: 3,
+ providerTurnOrdinal: 3,
+ });
+ if (!endedFirst) gate.release("reply");
+ // The reply's own end finishes its continuation: the failed prompt's mark does not take it.
+ yield* Deferred.await(allEnded);
+ assert.deepEqual(
+ ended.flatMap((event) => (event.type === "turn.terminal" ? [event.status] : [])),
+ ["completed", "failed", "completed"],
+ );
+ assert.include(
+ ended.flatMap((event) =>
+ event.type === "turn_item.updated" && event.turnItem.type === "assistant_message"
+ ? [event.turnItem.text]
+ : [],
+ ),
+ "CHILD_OK",
+ );
+ }).pipe(Effect.scoped, Effect.provide(TestClock.layer()));
+
+ it.effect("ends a background reply's continuation after a prompt failed unclearly", () =>
+ replyAfterUnclearFailure(false),
+ );
+
+ it.effect("ends a finished background reply's continuation after a prompt failed unclearly", () =>
+ replyAfterUnclearFailure(true),
+ );
+
+ it.effect("does not report a Stop the server says did nothing", () =>
+ Effect.gen(function* () {
+ const { runtime, thread } = yield* resumed([
+ out("session.prompt", { sessionID: SESSION, text: "" }),
+ promptAccepted,
+ out("session.interrupt", { sessionID: SESSION }),
+ reply("session.interrupt", { interrupted: false }),
+ ]);
+ yield* runtime.startTurn(turnInput(thread));
+ const failed = yield* runtime
+ .interruptTurn({
+ providerThread: thread,
+ providerTurnId: yield* providerTurnId,
+ })
+ .pipe(Effect.flip);
+ assert.equal(failed._tag, "ProviderAdapterProtocolError");
+ }).pipe(Effect.scoped),
+ );
+
+ it.effect("reports a turn as completed when its Stop request failed", () =>
+ Effect.gen(function* () {
+ const { runtime, thread } = yield* resumed([
+ out("session.prompt", { sessionID: SESSION, text: "" }),
+ promptAccepted,
+ out("session.interrupt", { sessionID: SESSION }),
+ reply("session.interrupt", {
+ status: 500,
+ body: { _tag: "UnknownError", message: "interrupt failed" },
+ }),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ ]);
+ const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
+ yield* runtime.startTurn(turnInput(thread));
+ const failed = yield* runtime
+ .interruptTurn({ providerThread: thread, providerTurnId: yield* providerTurnId })
+ .pipe(Effect.flip);
+ assert.equal(failed._tag, "ProviderAdapterInterruptError");
+ assert.equal((yield* Fiber.join(terminal))?.status, "completed");
+ }).pipe(Effect.scoped),
+ );
+
+ it.effect("refuses to resume a thread without an OpenCode session as a protocol error", () =>
+ Effect.gen(function* () {
+ const runtime = yield* openCode2ReplayRuntime(opening);
+ const failed = yield* runtime
+ .resumeThread({
+ providerThread: { ...providerThread(yield* DateTime.now), nativeThreadRef: null },
+ })
+ .pipe(Effect.flip);
+ assert.equal(failed._tag, "ProviderAdapterProtocolError");
+ }).pipe(Effect.scoped),
+ );
+
+ it.effect("gives a resumed session T3's rules when it was made with others", () =>
+ Effect.gen(function* () {
+ const runtime = yield* openCode2ReplayRuntimeWithInstructions([
+ ...opening,
+ out("session.get", { sessionID: SESSION }),
+ // Made by an earlier build that denied subagents; resuming drops the deny.
+ replyData(
+ "session.get",
+ sessionInfo({
+ permissions: [
+ { action: "*", resource: "*", effect: "allow" },
+ { action: "subagent", resource: "*", effect: "deny" },
+ ],
+ }),
+ ),
+ ...noOpenRequests,
+ out("session.update", { sessionID: SESSION, permissions: t3Rules }),
+ reply("session.update", null),
+ ]);
+ yield* runtime.resumeThread({
+ providerThread: providerThread(yield* DateTime.now),
+ threadId,
+ modelSelection: bigPickle,
+ runtimePolicy: policy(),
+ });
+ }).pipe(Effect.scoped),
);
it.effect("moves the session when the thread's worktree changed", () =>
@@ -4028,19 +4500,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
yield* Effect.gen(function* () {
while (offers.length === 0) yield* Effect.yieldNow;
}).pipe(Effect.timeout("2 seconds"), Effect.orDie);
- yield* runtime.startTurn({
- ...withLineage(thread),
- runId: RunId.make("run:opencode2-adapter:wake"),
- runOrdinal: 2,
- providerTurnOrdinal: 2,
- attemptId: RunAttemptId.make("attempt:opencode2-adapter:wake"),
- message: {
- ...turnInput(thread).message,
- messageId: MessageId.make("message:opencode2-adapter:wake"),
- createdBy: "agent" as const,
- creationSource: "provider" as const,
- },
- });
+ yield* runtime.startTurn(continuationTurn(thread, offers[0]!));
yield* Deferred.await(bothEnded);
const [first, second] = turns;
return { runtime, thread, first: first!, second: second! };
@@ -4107,6 +4567,556 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
}).pipe(Effect.scoped),
);
+ it.effect("keeps a subagent a stopped background reply started off the next turn", () =>
+ Effect.gen(function* () {
+ const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB";
+ const SPAWNED = "ses_f1485c529ffeDDDDDDDDDDDDDD";
+ const NESTED = "ses_f1485c529ffeNNNNNNNNNNNNNN";
+ const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" };
+ const spawn = { sessionID: SESSION, assistantMessageID: "msg_reply", id: "call-spawn" };
+ const offers: Array = [];
+ const gate = makeProviderReplayGate(["settled"]);
+ const { runtime, thread } = yield* resumed(
+ [
+ ...backgroundLaunch(CHILD),
+ // The same turn starts a second background subagent, which runs on.
+ event("session.tool.input.started", { ...toolB, name: "subagent" }),
+ event("session.tool.called", {
+ ...toolB,
+ name: "subagent",
+ input: { description: "Sleep", prompt: "sleep", background: true },
+ executed: false,
+ }),
+ event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }),
+ event("session.tool.progress", {
+ ...toolB,
+ metadata: { sessionID: CHILD_B, status: "running" },
+ }),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ event("session.execution.started", { sessionID: CHILD_B }),
+ // The first subagent's report wakes the parent into a reply T3 holds
+ // for its continuation.
+ event("session.execution.succeeded", { sessionID: CHILD }),
+ event("session.inbox.enqueued", {
+ inboxID: "msg_report",
+ sessionID: SESSION,
+ item: {
+ type: "synthetic",
+ payload: {
+ text: `\nCHILD_OK\n`,
+ description: "Sleep",
+ metadata: {
+ source: "subagent",
+ childID: CHILD,
+ agent: "General",
+ state: "completed",
+ },
+ },
+ delivery: "steer",
+ },
+ }),
+ event("session.execution.started", { sessionID: SESSION }),
+ // The reply starts a background subagent of its own, named only by
+ // the reply's held events.
+ event("session.tool.input.started", { ...spawn, name: "subagent" }),
+ event("session.tool.called", {
+ ...spawn,
+ name: "subagent",
+ input: { description: "Sleep", prompt: "sleep", background: true },
+ executed: false,
+ }),
+ event("session.created", { ...childCreated(SPAWNED), sessionID: SPAWNED }),
+ event("session.tool.progress", {
+ ...spawn,
+ metadata: { sessionID: SPAWNED, status: "running" },
+ }),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ // The second subagent's text, shown live, marks that the reply has ended.
+ event("session.text.ended", {
+ sessionID: CHILD_B,
+ assistantMessageID: "msg_child_b",
+ ordinal: 0,
+ text: "B_WORKING",
+ }),
+ // The user's Stop stops the second subagent and drops the held reply.
+ out("session.interrupt", { sessionID: CHILD_B }),
+ reply("session.interrupt", { interrupted: true }),
+ event("session.execution.interrupted", { sessionID: CHILD_B }),
+ // The subagent only the dropped reply named is stopped too; that Stop
+ // fails, so it runs on.
+ out("session.interrupt", { sessionID: SPAWNED }),
+ reply("session.interrupt", {
+ status: 500,
+ body: { _tag: "UnknownError", message: "interrupt failed" },
+ }),
+ // An unrelated prompt runs while that subagent, and a subagent it
+ // starts, ask for permissions, which no turn shows: they are refused.
+ out("session.prompt", { sessionID: SESSION, text: "" }),
+ promptAccepted,
+ event("session.execution.started", { sessionID: SESSION }),
+ event("permission.asked", { ...shellAsk.data, id: "per_spawned", sessionID: SPAWNED }),
+ out("permission.reply", {
+ sessionID: SPAWNED,
+ requestID: "per_spawned",
+ decision: "reject",
+ }),
+ reply("permission.reply", null),
+ event("session.created", {
+ ...childCreated(NESTED),
+ sessionID: NESTED,
+ parentID: SPAWNED,
+ }),
+ event("permission.asked", { ...shellAsk.data, id: "per_nested", sessionID: NESTED }),
+ out("permission.reply", {
+ sessionID: NESTED,
+ requestID: "per_nested",
+ decision: "reject",
+ }),
+ reply("permission.reply", null),
+ event("session.text.ended", {
+ sessionID: SESSION,
+ assistantMessageID: "msg_unrelated",
+ ordinal: 0,
+ text: "UNRELATED",
+ }),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ // The next Stop tries the subagent again, and the one it started, and reaches both.
+ out("session.interrupt", { sessionID: SPAWNED }),
+ reply("session.interrupt", { interrupted: true }),
+ out("session.interrupt", { sessionID: NESTED }),
+ reply("session.interrupt", { interrupted: true }),
+ // OpenCode reports the stopped subagent to the thread's session and
+ // wakes it; that execution answers no turn, so it is stopped and no
+ // continuation is offered for it.
+ event("session.execution.interrupted", { sessionID: SPAWNED }),
+ event("session.inbox.enqueued", {
+ inboxID: "msg_report_spawned",
+ sessionID: SESSION,
+ item: {
+ type: "synthetic",
+ payload: {
+ text: `\n`,
+ description: "Sleep",
+ metadata: {
+ source: "subagent",
+ childID: SPAWNED,
+ agent: "General",
+ state: "cancelled",
+ },
+ },
+ delivery: "steer",
+ },
+ }),
+ event("session.execution.started", { sessionID: SESSION }),
+ out("session.interrupt", { sessionID: SESSION }),
+ reply("session.interrupt", { interrupted: true }),
+ event("session.execution.interrupted", { sessionID: SESSION }, "settled"),
+ ],
+ { replayGate: gate },
+ ).pipe(
+ Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
+ offer: (request) => Effect.sync(() => void offers.push(request)),
+ take: Effect.never,
+ }),
+ );
+ const collected: Array = [];
+ const replyEnded = yield* Deferred.make();
+ const bothEnded = yield* Deferred.make();
+ yield* runtime.events.pipe(
+ Stream.tap((event) =>
+ Effect.gen(function* () {
+ collected.push(event);
+ if (event.type === "turn_item.updated" && event.turnItem.type === "assistant_message") {
+ if (event.turnItem.text === "B_WORKING")
+ yield* Deferred.succeed(replyEnded, undefined);
+ }
+ if (collected.filter((entry) => entry.type === "turn.terminal").length === 2) {
+ yield* Deferred.succeed(bothEnded, undefined);
+ }
+ }),
+ ),
+ Stream.runDrain,
+ Effect.forkScoped,
+ );
+ yield* runtime.startTurn(withLineage(thread));
+ yield* Deferred.await(replyEnded);
+ assert.lengthOf(offers, 1);
+ // The user stops the thread before the reply's continuation runs.
+ yield* runtime.interruptTurn({
+ providerThread: thread,
+ providerTurnId: yield* providerTurnId,
+ requestRuntimeRestart: true,
+ });
+ yield* runtime.startTurn({ ...secondTurn(thread), appThread: withLineage(thread).appThread });
+ yield* Deferred.await(bothEnded);
+ // A later Stop reaches both subagents; once stopped they are not tried
+ // again (the replay fails on any further request).
+ for (let stops = 0; stops < 2; stops++) {
+ yield* runtime.interruptTurn({
+ providerThread: thread,
+ providerTurnId: yield* providerTurnId,
+ requestRuntimeRestart: true,
+ });
+ }
+ // The stopped subagent's report wakes nothing T3 runs.
+ yield* Effect.promise(() => gate.waitForReached("settled"));
+ gate.release("settled");
+ assert.lengthOf(offers, 1);
+ // The unrelated turn shows no request it never made.
+ assert.deepEqual(
+ collected.flatMap((event) =>
+ event.type === "runtime_request.updated"
+ ? [`${event.runtimeRequest.nativeRequestRef?.nativeId}:${event.runtimeRequest.status}`]
+ : [],
+ ),
+ [],
+ );
+ }).pipe(Effect.scoped),
+ );
+
+ it.effect(
+ "keeps every subagent a stopped background reply's subagent started off the next turn",
+ () =>
+ Effect.gen(function* () {
+ const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB";
+ const SPAWNED = "ses_f1485c529ffeDDDDDDDDDDDDDD";
+ const NESTED = "ses_f1485c529ffeNNNNNNNNNNNNNN";
+ const DEEP = "ses_f1485c529ffeEEEEEEEEEEEEEE";
+ const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" };
+ const spawn = { sessionID: SESSION, assistantMessageID: "msg_reply", id: "call-spawn" };
+ const { runtime, thread } = yield* resumed([
+ ...backgroundLaunch(CHILD),
+ // The same turn starts a second background subagent, which runs on.
+ event("session.tool.input.started", { ...toolB, name: "subagent" }),
+ event("session.tool.called", {
+ ...toolB,
+ name: "subagent",
+ input: { description: "Sleep", prompt: "sleep", background: true },
+ executed: false,
+ }),
+ event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }),
+ event("session.tool.progress", {
+ ...toolB,
+ metadata: { sessionID: CHILD_B, status: "running" },
+ }),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ event("session.execution.started", { sessionID: CHILD_B }),
+ // The first subagent's report wakes the parent into a reply T3 holds.
+ event("session.execution.succeeded", { sessionID: CHILD }),
+ event("session.inbox.enqueued", {
+ inboxID: "msg_report",
+ sessionID: SESSION,
+ item: {
+ type: "synthetic",
+ payload: {
+ text: `\nCHILD_OK\n`,
+ description: "Sleep",
+ metadata: {
+ source: "subagent",
+ childID: CHILD,
+ agent: "General",
+ state: "completed",
+ },
+ },
+ delivery: "steer",
+ },
+ }),
+ event("session.execution.started", { sessionID: SESSION }),
+ // The held reply starts a subagent, which starts one, which starts another.
+ event("session.tool.input.started", { ...spawn, name: "subagent" }),
+ event("session.tool.called", {
+ ...spawn,
+ name: "subagent",
+ input: { description: "Sleep", prompt: "sleep", background: true },
+ executed: false,
+ }),
+ event("session.created", { ...childCreated(SPAWNED), sessionID: SPAWNED }),
+ event("session.tool.progress", {
+ ...spawn,
+ metadata: { sessionID: SPAWNED, status: "running" },
+ }),
+ event("session.created", {
+ ...childCreated(NESTED),
+ sessionID: NESTED,
+ parentID: SPAWNED,
+ }),
+ event("session.created", { ...childCreated(DEEP), sessionID: DEEP, parentID: NESTED }),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ // The second subagent's text, shown live, marks that the reply has ended.
+ event("session.text.ended", {
+ sessionID: CHILD_B,
+ assistantMessageID: "msg_child_b",
+ ordinal: 0,
+ text: "B_WORKING",
+ }),
+ // The user's Stop stops the second subagent and drops the held reply,
+ // then each subagent under the reply, the deepest of which runs on.
+ out("session.interrupt", { sessionID: CHILD_B }),
+ reply("session.interrupt", { interrupted: true }),
+ event("session.execution.interrupted", { sessionID: CHILD_B }),
+ out("session.interrupt", { sessionID: SPAWNED }),
+ reply("session.interrupt", { interrupted: true }),
+ out("session.interrupt", { sessionID: NESTED }),
+ reply("session.interrupt", { interrupted: true }),
+ out("session.interrupt", { sessionID: DEEP }),
+ reply("session.interrupt", {
+ status: 500,
+ body: { _tag: "UnknownError", message: "interrupt failed" },
+ }),
+ // An unrelated prompt runs while the deepest asks for a permission,
+ // which no turn shows: it is refused.
+ out("session.prompt", { sessionID: SESSION, text: "" }),
+ promptAccepted,
+ event("session.execution.started", { sessionID: SESSION }),
+ event("permission.asked", { ...shellAsk.data, id: "per_deep", sessionID: DEEP }),
+ out("permission.reply", { sessionID: DEEP, requestID: "per_deep", decision: "reject" }),
+ reply("permission.reply", null),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ ]);
+ const collected: Array = [];
+ const replyEnded = yield* Deferred.make();
+ const bothEnded = yield* Deferred.make();
+ yield* runtime.events.pipe(
+ Stream.tap((event) =>
+ Effect.gen(function* () {
+ collected.push(event);
+ if (
+ event.type === "turn_item.updated" &&
+ event.turnItem.type === "assistant_message" &&
+ event.turnItem.text === "B_WORKING"
+ ) {
+ yield* Deferred.succeed(replyEnded, undefined);
+ }
+ if (collected.filter((entry) => entry.type === "turn.terminal").length === 2) {
+ yield* Deferred.succeed(bothEnded, undefined);
+ }
+ }),
+ ),
+ Stream.runDrain,
+ Effect.forkScoped,
+ );
+ yield* runtime.startTurn(withLineage(thread));
+ yield* Deferred.await(replyEnded);
+ yield* runtime.interruptTurn({
+ providerThread: thread,
+ providerTurnId: yield* providerTurnId,
+ requestRuntimeRestart: true,
+ });
+ yield* runtime.startTurn({
+ ...secondTurn(thread),
+ appThread: withLineage(thread).appThread,
+ });
+ yield* Deferred.await(bothEnded);
+ // The unrelated turn shows no request it never made.
+ assert.deepEqual(
+ collected.flatMap((event) =>
+ event.type === "runtime_request.updated"
+ ? [
+ `${event.runtimeRequest.nativeRequestRef?.nativeId}:${event.runtimeRequest.status}`,
+ ]
+ : [],
+ ),
+ [],
+ );
+ }).pipe(Effect.scoped),
+ );
+
+ it.effect("ends a continuation while a skipped reply's subagent does not answer its Stop", () =>
+ Effect.gen(function* () {
+ const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB";
+ const SPAWNED = "ses_f1485c529ffeDDDDDDDDDDDDDD";
+ const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" };
+ const spawn = { sessionID: SESSION, assistantMessageID: "msg_reply_a", id: "call-spawn" };
+ const report = (inboxID: string, child: string) =>
+ event("session.inbox.enqueued", {
+ inboxID,
+ sessionID: SESSION,
+ item: {
+ type: "synthetic",
+ payload: {
+ text: `\nOK\n`,
+ description: "Sleep",
+ metadata: {
+ source: "subagent",
+ childID: child,
+ agent: "General",
+ state: "completed",
+ },
+ },
+ delivery: "steer",
+ },
+ });
+ const offers: Array = [];
+ const bothOffered = yield* Deferred.make();
+ const { runtime, thread } = yield* resumed([
+ ...backgroundLaunch(CHILD),
+ event("session.tool.input.started", { ...toolB, name: "subagent" }),
+ event("session.tool.called", {
+ ...toolB,
+ name: "subagent",
+ input: { description: "Sleep", prompt: "sleep", background: true },
+ executed: false,
+ }),
+ event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }),
+ event("session.tool.progress", {
+ ...toolB,
+ metadata: { sessionID: CHILD_B, status: "running" },
+ }),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ // Reply A starts a subagent of its own, named only by its held events.
+ event("session.execution.succeeded", { sessionID: CHILD }),
+ report("msg_report_a", CHILD),
+ event("session.execution.started", { sessionID: SESSION }),
+ event("session.tool.input.started", { ...spawn, name: "subagent" }),
+ event("session.tool.called", {
+ ...spawn,
+ name: "subagent",
+ input: { description: "Sleep", prompt: "sleep", background: true },
+ executed: false,
+ }),
+ event("session.created", { ...childCreated(SPAWNED), sessionID: SPAWNED }),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ event("session.execution.succeeded", { sessionID: CHILD_B }),
+ report("msg_report_b", CHILD_B),
+ event("session.execution.started", { sessionID: SESSION }),
+ event("session.text.ended", {
+ sessionID: SESSION,
+ assistantMessageID: "msg_reply_b",
+ ordinal: 0,
+ text: "REPLY_B",
+ }),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ // B's continuation skips reply A and stops its subagent, which never answers.
+ out("session.interrupt", { sessionID: SPAWNED }),
+ reply("session.interrupt", ""),
+ ]).pipe(
+ Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
+ offer: (request) =>
+ Effect.suspend(() =>
+ offers.push(request) === 2
+ ? Deferred.succeed(bothOffered, undefined).pipe(Effect.asVoid)
+ : Effect.void,
+ ),
+ take: Effect.never,
+ }),
+ );
+ const ended = yield* terminals(runtime, 2);
+ yield* runtime.startTurn(withLineage(thread));
+ yield* Deferred.await(bothOffered);
+ yield* runtime.startTurn(continuationTurn(thread, offers[1]!, "wake-b"));
+ // B's reply replays and ends its turn while that Stop still waits.
+ assert.deepEqual(
+ [...(yield* Fiber.join(ended))].map((terminal) => terminal.status),
+ ["completed", "completed"],
+ );
+ }).pipe(Effect.scoped),
+ );
+
+ it.effect("replays only its own background reply into a continuation turn", () =>
+ Effect.gen(function* () {
+ const CHILD_B = "ses_f1485c529ffeBBBBBBBBBBBBBB";
+ const toolB = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-b" };
+ const report = (inboxID: string, child: string, text: string) =>
+ event("session.inbox.enqueued", {
+ inboxID,
+ sessionID: SESSION,
+ item: {
+ type: "synthetic",
+ payload: {
+ text: `\n${text}\n`,
+ description: "Sleep",
+ metadata: {
+ source: "subagent",
+ childID: child,
+ agent: "General",
+ state: "completed",
+ },
+ },
+ delivery: "steer",
+ },
+ });
+ const reply = (assistantMessageID: string, text: string) =>
+ event("session.text.ended", { sessionID: SESSION, assistantMessageID, ordinal: 0, text });
+ const offers: Array = [];
+ const bothOffered = yield* Deferred.make();
+ const { runtime, thread } = yield* resumed([
+ ...backgroundLaunch(CHILD),
+ // The same turn starts a second background subagent.
+ event("session.tool.input.started", { ...toolB, name: "subagent" }),
+ event("session.tool.called", {
+ ...toolB,
+ name: "subagent",
+ input: { description: "Sleep", prompt: "sleep", background: true },
+ executed: false,
+ }),
+ event("session.created", { ...childCreated(CHILD_B), sessionID: CHILD_B }),
+ event("session.tool.progress", {
+ ...toolB,
+ metadata: { sessionID: CHILD_B, status: "running" },
+ }),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ // Each subagent's report wakes the parent into a reply OpenCode runs on its own.
+ event("session.execution.succeeded", { sessionID: CHILD }),
+ report("msg_report_a", CHILD, "A_OK"),
+ event("session.execution.started", { sessionID: SESSION }),
+ event("session.inbox.delivered", { sessionID: SESSION, inboxID: "msg_report_a" }),
+ reply("msg_reply_a", "REPLY_A"),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ event("session.execution.succeeded", { sessionID: CHILD_B }),
+ report("msg_report_b", CHILD_B, "B_OK"),
+ event("session.execution.started", { sessionID: SESSION }),
+ event("session.inbox.delivered", { sessionID: SESSION, inboxID: "msg_report_b" }),
+ reply("msg_reply_b", "REPLY_B"),
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ ]).pipe(
+ Effect.provideService(ProviderContinuationRequests.ProviderContinuationRequests, {
+ offer: (request) =>
+ Effect.suspend(() =>
+ offers.push(request) === 2
+ ? Deferred.succeed(bothOffered, undefined).pipe(Effect.asVoid)
+ : Effect.void,
+ ),
+ take: Effect.never,
+ }),
+ );
+ const collected: Array = [];
+ const ended = yield* Deferred.make();
+ yield* runtime.events.pipe(
+ Stream.tap((event) =>
+ Effect.gen(function* () {
+ collected.push(event);
+ if (collected.filter((entry) => entry.type === "turn.terminal").length === 2) {
+ yield* Deferred.succeed(ended, undefined);
+ }
+ }),
+ ),
+ Stream.runDrain,
+ Effect.forkScoped,
+ );
+ yield* runtime.startTurn(withLineage(thread));
+ yield* Deferred.await(bothOffered);
+ // A's continuation was cancelled before it started; B's runs.
+ yield* runtime.startTurn(continuationTurn(thread, offers[1]!, "wake-b"));
+ yield* Deferred.await(ended);
+ const continuationId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
+ driver: OPENCODE_PROVIDER,
+ nativeTurnId: `${SESSION}:attempt:attempt:opencode2-adapter:wake-b`,
+ });
+ assert.deepEqual(
+ collected.flatMap((event) =>
+ event.type === "turn_item.updated" &&
+ event.turnItem.type === "assistant_message" &&
+ event.turnItem.providerTurnId === continuationId
+ ? [event.turnItem.text]
+ : [],
+ ),
+ ["REPLY_B"],
+ );
+ // Nothing is left held for a continuation that will never run.
+ assert.isFalse(yield* runtime.hasPendingBackgroundWork!);
+ }).pipe(Effect.scoped, Effect.provide(IdAllocator.layer)),
+ );
+
it.effect("ends a follow-up turn a steer joined exactly once", () =>
Effect.gen(function* () {
const reportText = `\nCHILD_OK\n`;
@@ -4184,19 +5194,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
yield* Effect.gen(function* () {
while (offers.length === 0) yield* Effect.yieldNow;
}).pipe(Effect.timeout("2 seconds"), Effect.orDie);
- const followUp = {
- ...withLineage(thread),
- runId: RunId.make("run:opencode2-adapter:wake"),
- runOrdinal: 2,
- providerTurnOrdinal: 2,
- attemptId: RunAttemptId.make("attempt:opencode2-adapter:wake"),
- message: {
- ...turnInput(thread).message,
- messageId: MessageId.make("message:opencode2-adapter:wake"),
- createdBy: "agent" as const,
- creationSource: "provider" as const,
- },
- };
+ const followUp = continuationTurn(thread, offers[0]!);
yield* runtime.startTurn(followUp);
const followUpTurnId = (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
driver: OPENCODE_PROVIDER,
diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts
index 7eb2959974838..db251e005d797 100644
--- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts
+++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts
@@ -286,7 +286,11 @@ function layerRegistry(
*/
export const openCode2ReplayRuntime = (
entries: ReadonlyArray,
- options?: { readonly external?: boolean; readonly borrowers?: { current: number } },
+ options?: {
+ readonly external?: boolean;
+ readonly borrowers?: { current: number };
+ readonly replayGate?: ProviderReplayGate;
+ },
) =>
Effect.gen(function* () {
const adapter = yield* makeReplayAdapter(
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 0000000000000..2ece93ea3fb9c
--- /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 0000000000000..dd28a5d9b8480
--- /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 d1e62aad91bbc..ed560fad6fd46 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 15e175aa7c742..279c9bced95ee 100644
--- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts
+++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts
@@ -29,6 +29,7 @@ import * as SecretRequests from "../secrets/SecretRequests.ts";
import * as Scheduler from "../scheduling/Scheduler.ts";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as ClaudeAdapterV2 from "./Adapters/ClaudeAdapterV2.ts";
+import * as CommandReceiptStore from "./CommandReceiptStore.ts";
import * as EffectWorker from "./EffectWorker.ts";
import * as EventSink from "./EventSink.ts";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
@@ -38,6 +39,7 @@ import * as Orchestrator from "./Orchestrator.ts";
import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
import * as ThreadLaunchService from "./ThreadLaunchService.ts";
import * as ThreadManagementService from "./ThreadManagementService.ts";
+import * as ServerSettings from "../serverSettings.ts";
import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts";
import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
@@ -158,6 +160,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,
}),
@@ -312,6 +315,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con
Effect.provide(
ScheduledTaskService.layer.pipe(
Layer.provide(ThreadManagementService.layer),
+ Layer.provide(ServerSettings.layerTest()),
Layer.provide(
Layer.mock(LegacyV1ThreadImporter.LegacyV1ThreadImporter)({
ensureTranscript: () =>
@@ -320,6 +324,9 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con
),
Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
+ Layer.provide(
+ CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)),
+ ),
Layer.provide(
Layer.mergeAll(
NodeCrypto.layer,
diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts
index ddfc54b533205..06ba83ca0d427 100644
--- a/apps/server/src/orchestration-v2/EffectWorker.ts
+++ b/apps/server/src/orchestration-v2/EffectWorker.ts
@@ -256,41 +256,61 @@ export const layerExecutor: Layer.Layer<
if (message === undefined || run === undefined) return yield* error;
// Reuse the message identity and a stable command receipt so an outbox
// retry cannot append a duplicate message or start a second follow-up.
- yield* threads.dispatch({
- type: "message.dispatch",
- commandId: CommandId.make(`command:steer-follow-up:${effect.id}`),
- threadId: effect.threadId,
- messageId: message.id,
- text: message.text,
- ...(message.context ? { context: message.context } : {}),
- attachments: message.attachments,
- // A user's follow-up starts on the thread's saved selection,
- // which already holds the steer's choice. A delegated
- // completion stays pinned to the run it reports to.
- ...(message.delegatedCompletion === undefined
- ? {}
- : { modelSelection: run.modelSelection }),
- dispatchMode: {
- type:
- message.delegatedCompletion === undefined
- ? "start_immediately"
- : "queue_after_active",
- },
- createdBy: message.createdBy,
- creationSource: message.creationSource,
- ...(message.delegatedCompletion === undefined
- ? {}
- : { delegatedCompletion: message.delegatedCompletion }),
- ...(message.notification === undefined
- ? {}
- : { notification: message.notification }),
- ...(message.scheduledTaskId === undefined
- ? {}
- : { scheduledTaskId: message.scheduledTaskId }),
- ...(message.senderThreadId === undefined
- ? {}
- : { senderThreadId: message.senderThreadId }),
- });
+ yield* threads
+ .dispatch({
+ type: "message.dispatch",
+ commandId: CommandId.make(`command:steer-follow-up:${effect.id}`),
+ threadId: effect.threadId,
+ messageId: message.id,
+ text: message.text,
+ ...(message.context ? { context: message.context } : {}),
+ attachments: message.attachments,
+ // A user's follow-up starts on the thread's saved selection,
+ // which already holds the steer's choice. A delegated
+ // completion stays pinned to the run it reports to.
+ ...(message.delegatedCompletion === undefined
+ ? {}
+ : { modelSelection: run.modelSelection }),
+ dispatchMode: {
+ type:
+ message.delegatedCompletion === undefined
+ ? "start_immediately"
+ : "queue_after_active",
+ },
+ createdBy: message.createdBy,
+ creationSource: message.creationSource,
+ ...(message.delegatedCompletion === undefined
+ ? {}
+ : { delegatedCompletion: message.delegatedCompletion }),
+ ...(message.notification === undefined
+ ? {}
+ : { notification: message.notification }),
+ ...(message.scheduledTaskId === undefined
+ ? {}
+ : { scheduledTaskId: message.scheduledTaskId }),
+ ...(message.senderThreadId === undefined
+ ? {}
+ : { senderThreadId: message.senderThreadId }),
+ })
+ .pipe(
+ // The turn that just completed leaves no further retry
+ // target once the instance is off: the completed turn
+ // stands as the thread's result instead of going
+ // through the outbox's bounded retry on a refusal that
+ // retrying cannot change.
+ Effect.catchTags({
+ ThreadManagementProviderInstanceDisabledError: (disabledError) =>
+ Effect.logInfo(
+ "orchestration-v2.effect-worker.steer-follow-up-disabled",
+ {
+ threadId: effect.threadId,
+ messageId: message.id,
+ projectId: disabledError.projectId,
+ instanceId: disabledError.instanceId,
+ },
+ ),
+ }),
+ );
}),
),
Effect.mapError(
diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts
index e3f5f72665b35..d85504bb264e7 100644
--- a/apps/server/src/orchestration-v2/Orchestrator.ts
+++ b/apps/server/src/orchestration-v2/Orchestrator.ts
@@ -53,6 +53,7 @@ import {
orchestrationV2RunWorkStartedAt,
ProviderInstanceId,
ProviderInteractionMode,
+ resolveProviderInstanceEnabled,
type ProviderSessionId,
RunId,
RuntimeMode,
@@ -131,6 +132,7 @@ import {
} from "./ThreadForkService.ts";
import { planThreadDeletion } from "./ThreadDeletion.ts";
import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
+import * as ServerSettings from "../serverSettings.ts";
export class OrchestratorDispatchError extends Schema.TaggedError()(
"OrchestratorDispatchError",
@@ -832,6 +834,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
const providerSessions = yield* ProviderSessionManagerV2;
const providerSwitchService = yield* ProviderSwitchServiceV2;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
const runtimePolicy = yield* RuntimePolicyV2;
const threadForkService = yield* ThreadForkServiceV2;
const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor;
@@ -1318,8 +1321,29 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
failedRun?.id === options?.endedRunId &&
failedRun?.workspacePreparation?.type === "worktree" &&
projection.thread.worktreePath === null;
+ // A project can turn a provider off while a message waits in the queue,
+ // including between a run ending and this check, or while the queue is
+ // held and someone clicks resume. Neither path may start that run. Only
+ // an explicit turn-off holds the queue: an instance with no settings
+ // entry was already admitted when the message was queued.
+ const settings = yield* serverSettings.getSettings.pipe(Effect.orDie);
+ const projectOverride =
+ settings.projectSettingsOverrides[projection.thread.projectId]?.providerInstances?.[
+ queuedRun.providerInstanceId
+ ]?.enabled;
+ const machineInstance = Object.hasOwn(
+ settings.providerInstances,
+ queuedRun.providerInstanceId,
+ )
+ ? settings.providerInstances[queuedRun.providerInstanceId]
+ : undefined;
+ const instanceDisabledForProject =
+ projectOverride !== undefined
+ ? !projectOverride
+ : machineInstance !== undefined && !resolveProviderInstanceEnabled(machineInstance);
if (
worktreeMissing ||
+ instanceDisabledForProject ||
(failureClass !== undefined &&
failureClass !== "validation_error" &&
failedRun?.providerInstanceId === queuedRun.providerInstanceId)
@@ -8421,7 +8445,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 +8493,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) {
@@ -11420,6 +11461,7 @@ export const layer: Layer.Layer<
| ProviderSwitchServiceV2
| ProjectionStoreV2
| RuntimePolicyV2
+ | ServerSettings.ServerSettingsService
| ThreadForkServiceV2
> = Layer.effect(OrchestratorV2, makeOrchestrator()).pipe(
Layer.provide(ThreadCommandExecutor.layer),
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts
index c939749e8cd32..bda7b896e1876 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/ProviderContinuationService.test.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts
index a6afc4a4b2ffd..689c66a1d4021 100644
--- a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts
+++ b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts
@@ -1,7 +1,9 @@
import { assert, describe, it } from "@effect/vitest";
import {
MessageId,
+ ProjectId,
ProviderDriverKind,
+ ProviderInstanceId,
ProviderThreadId,
RunId,
ThreadId,
@@ -11,6 +13,7 @@ import {
import * as Deferred from "effect/Deferred";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
@@ -180,6 +183,25 @@ describe("ProviderContinuationService", () => {
});
});
+ it.effect("dispatches a wake under the message its adapter named", () => {
+ return Effect.gen(function* () {
+ const dispatched = yield* Queue.unbounded();
+ const messageId = MessageId.make("message-provider-continuation-adapter-named");
+ yield* Effect.gen(function* () {
+ const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
+ yield* requests.offer({ ...request(), messageId });
+ const command = (yield* Queue.take(dispatched)) as { readonly messageId: MessageId };
+ // The adapter matches the continuation run's message to the wake it holds.
+ assert.equal(command.messageId, messageId);
+ }).pipe(
+ Effect.provide(
+ layerTest({ dispatched, getThreadRecords: () => Effect.succeed(projection) }),
+ ),
+ Effect.scoped,
+ );
+ });
+ });
+
it.effect("delivers a message_text wake as a real prompt, not a buffered wake", () => {
return Effect.gen(function* () {
const dispatched = yield* Queue.unbounded();
@@ -510,6 +532,191 @@ describe("ProviderContinuationService", () => {
});
});
+ it.effect(
+ "a project-disabled instance refuses a delegated-completion wake exactly once, acknowledges it, and never retries",
+ () => {
+ return Effect.gen(function* () {
+ const attempts = yield* Ref.make(0);
+ const cleared = yield* Ref.make(false);
+ const acknowledged = yield* Queue.unbounded();
+ const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({
+ getThreadRecords: () => Effect.succeed(delegatedProjection()),
+ dispatch: (command) => {
+ if ((command as { type: string }).type === "message.dispatch") {
+ return Ref.update(attempts, (count) => count + 1).pipe(
+ Effect.andThen(
+ Effect.fail(
+ new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({
+ projectId: ProjectId.make("project-provider-continuation-disabled"),
+ instanceId: ProviderInstanceId.make(
+ "instance-provider-continuation-disabled",
+ ),
+ }) as never,
+ ),
+ ),
+ );
+ }
+ return Queue.offer(acknowledged, command).pipe(Effect.as({} as never));
+ },
+ });
+ const layerWorker = ProviderContinuationService.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads),
+ ),
+ );
+
+ yield* Effect.gen(function* () {
+ const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
+ yield* requests.offer({
+ threadId,
+ providerThreadId,
+ driver,
+ detail: null,
+ delivery: "message_text",
+ clearIfCurrent: () => Ref.set(cleared, true),
+ delegatedCompletion: {
+ parentRunId,
+ generation: 1,
+ messageId: delegatedMessageId,
+ },
+ });
+ yield* Effect.yieldNow;
+ assert.equal(yield* Ref.get(attempts), 1);
+ assert.isTrue(yield* Ref.get(cleared));
+
+ // The refused delivery is acknowledged administratively, task by
+ // task, so the cohort's delivery is consumed instead of left open.
+ const acknowledgements = (yield* Queue.takeAll(acknowledged)) as ReadonlyArray<{
+ type: string;
+ taskId: string;
+ observedByRunId: string | null;
+ }>;
+ assert.deepEqual(
+ acknowledgements.map((command) => command.taskId),
+ ["node-provider-continuation-first", "node-provider-continuation-second"],
+ );
+ for (const command of acknowledgements) {
+ assert.equal(command.type, "delegated_task.completion-delivery.acknowledge");
+ assert.isNull(command.observedByRunId);
+ }
+
+ // A refusal is terminal: no backoff timer is pending behind it,
+ // so no further dispatch attempt can ever land.
+ yield* TestClock.adjust("1 hour");
+ yield* Effect.yieldNow;
+ assert.equal(yield* Ref.get(attempts), 1);
+ assert.equal(yield* Queue.size(acknowledged), 0);
+ }).pipe(
+ Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)),
+ Effect.scoped,
+ );
+ });
+ },
+ );
+
+ it.effect(
+ "clears the adapter's sticky continuation flag when a non-delegated wake is refused",
+ () => {
+ return Effect.gen(function* () {
+ const requested = yield* Ref.make(true);
+ const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({
+ getThreadRecords: () => Effect.succeed(projection),
+ dispatch: () =>
+ Effect.fail(
+ new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({
+ projectId: ProjectId.make("project-provider-continuation-disabled"),
+ instanceId: ProviderInstanceId.make("instance-provider-continuation-disabled"),
+ }) as never,
+ ),
+ });
+ const layerWorker = ProviderContinuationService.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads),
+ ),
+ );
+
+ // Mirrors the ACP adapter's own dispatchIfCurrent: on a failed effect
+ // it clears its sticky "continuation requested" flag before
+ // re-raising. If this service pre-recovered the failure before
+ // handing the effect here, this flag would stay stuck set and
+ // hasPendingBackgroundWork would never clear.
+ const dispatchIfCurrent = (effect: Effect.Effect) =>
+ Effect.gen(function* () {
+ const exit = yield* Effect.exit(effect);
+ if (Exit.isFailure(exit)) {
+ yield* Ref.set(requested, false);
+ return yield* Effect.failCause(exit.cause);
+ }
+ return Option.some(exit.value);
+ });
+
+ yield* Effect.gen(function* () {
+ const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
+ yield* requests.offer(request(dispatchIfCurrent));
+ yield* Effect.yieldNow;
+ yield* Effect.yieldNow;
+ assert.isFalse(
+ yield* Ref.get(requested),
+ "a disabled instance must still clear the adapter's sticky offer flag",
+ );
+ }).pipe(
+ Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)),
+ Effect.scoped,
+ );
+ });
+ },
+ );
+
+ it.effect(
+ "clears a sticky continuation offer for an adapter whose dispatchIfCurrent does not self-clear on failure",
+ () => {
+ return Effect.gen(function* () {
+ const wakeHeld = yield* Ref.make(true);
+ const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({
+ getThreadRecords: () => Effect.succeed(projection),
+ dispatch: () =>
+ Effect.fail(
+ new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({
+ projectId: ProjectId.make("project-provider-continuation-disabled"),
+ instanceId: ProviderInstanceId.make("instance-provider-continuation-disabled"),
+ }) as never,
+ ),
+ });
+ const layerWorker = ProviderContinuationService.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads),
+ ),
+ );
+
+ // Mirrors the Muse adapter: dispatchIfCurrent forwards the dispatch
+ // effect without any failure handling of its own (unlike ACP or Pi),
+ // so only the explicit clearIfCurrent callback can clear the sticky
+ // "wake held" flag. Without it, a disabled instance leaves the flag
+ // set forever and no later background turn is ever offered.
+ const dispatchIfCurrent = (effect: Effect.Effect) =>
+ Effect.asSome(effect);
+
+ yield* Effect.gen(function* () {
+ const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
+ yield* requests.offer({
+ ...request(dispatchIfCurrent),
+ clearIfCurrent: () => Ref.set(wakeHeld, false),
+ });
+ yield* Effect.yieldNow;
+ yield* Effect.yieldNow;
+ assert.isFalse(
+ yield* Ref.get(wakeHeld),
+ "a disabled instance must clear the offer via the explicit callback " +
+ "when the adapter itself does not clear on dispatch failure",
+ );
+ }).pipe(
+ Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)),
+ Effect.scoped,
+ );
+ });
+ },
+ );
+
it.effect("keeps a Grok delegated completion queued instead of restarting active work", () => {
return Effect.gen(function* () {
const dispatched = yield* Queue.unbounded();
diff --git a/apps/server/src/orchestration-v2/ProviderContinuationService.ts b/apps/server/src/orchestration-v2/ProviderContinuationService.ts
index b8a57a88937f2..c0b0c55b72210 100644
--- a/apps/server/src/orchestration-v2/ProviderContinuationService.ts
+++ b/apps/server/src/orchestration-v2/ProviderContinuationService.ts
@@ -126,31 +126,88 @@ export const layer = Layer.effectDiscard(
fixtureName: "delegated-completion",
commandName: "dispatch",
});
- yield* threads.dispatch({
- type: "message.dispatch",
- commandId,
- threadId: request.threadId,
- messageId: delivery.messageId,
- text: delegatedCompletionText(delivery.taskIds),
- attachments: [],
- dispatchMode: { type: "queue_after_active" },
- createdBy: "agent",
- creationSource: "server",
- delegatedCompletion: {
- parentRunId: request.delegatedCompletion.parentRunId,
- generation: delivery.generation,
- taskIds: delivery.taskIds,
- },
- });
+ yield* threads
+ .dispatch({
+ type: "message.dispatch",
+ commandId,
+ threadId: request.threadId,
+ messageId: delivery.messageId,
+ text: delegatedCompletionText(delivery.taskIds),
+ attachments: [],
+ dispatchMode: { type: "queue_after_active" },
+ createdBy: "agent",
+ creationSource: "server",
+ delegatedCompletion: {
+ parentRunId: request.delegatedCompletion.parentRunId,
+ generation: delivery.generation,
+ taskIds: delivery.taskIds,
+ },
+ })
+ .pipe(
+ // The project turned the target instance off between the cohort
+ // read above and this dispatch. The finalize that claimed this
+ // delivery already wrote the children's results to the parent's
+ // history unconditionally, so nothing is lost: acknowledge each
+ // task administratively (observedByRunId: null, the same shape
+ // task_status uses when no run observed it) to consume this
+ // delivery instead of leaving it open to retry. The parent's
+ // next turn against an enabled instance reads those results from
+ // history the same way any turn does; nothing here re-offers
+ // this generation.
+ Effect.catchTags({
+ ThreadManagementProviderInstanceDisabledError: (error) =>
+ Effect.logInfo(
+ "orchestration-v2.provider-continuation.delegated-completion-disabled",
+ {
+ threadId: request.threadId,
+ projectId: error.projectId,
+ instanceId: error.instanceId,
+ },
+ ).pipe(
+ Effect.andThen(
+ Effect.forEach(
+ delivery.taskIds,
+ (taskId) =>
+ ids.allocate
+ .command({
+ fixtureName: "delegated-completion",
+ commandName: "acknowledge-disabled",
+ })
+ .pipe(
+ Effect.flatMap((ackCommandId) =>
+ threads.dispatch({
+ type: "delegated_task.completion-delivery.acknowledge",
+ commandId: ackCommandId,
+ parentThreadId: request.threadId,
+ taskId,
+ observedByRunId: null,
+ }),
+ ),
+ ),
+ { discard: true },
+ ),
+ ),
+ Effect.andThen(
+ request.clearIfCurrent !== undefined
+ ? request.clearIfCurrent()
+ : request.dispatchIfCurrent !== undefined
+ ? request.dispatchIfCurrent(Effect.void)
+ : Effect.void,
+ ),
+ ),
+ }),
+ );
yield* clearRetryAttempt(retryKey);
return;
}
// The ordinal is display metadata only: allocate.message appends a
// random UUID, so a stale projection read here cannot collide ids.
- const messageId = yield* ids.allocate.message({
- threadId: request.threadId,
- ordinal: projection.messages.length + 1,
- });
+ const messageId =
+ request.messageId ??
+ (yield* ids.allocate.message({
+ threadId: request.threadId,
+ ordinal: projection.messages.length + 1,
+ }));
const commandId = CommandId.make(`provider-continuation:${messageId}`);
const dispatch = threads.dispatch({
type: "message.dispatch",
@@ -172,11 +229,40 @@ export const layer = Layer.effectDiscard(
// marker or the turn settles immediately having prompted nothing.
creationSource: request.delivery === "message_text" ? "server" : "provider",
});
+ // The adapter's buffered wake has nowhere else to go once the
+ // instance is off: drop it rather than warn, since nothing here
+ // retries it either way. This catch sits outside dispatchIfCurrent so
+ // an adapter that clears its own sticky offer flag on dispatch
+ // failure (inside dispatchIfCurrent) observes the raw failure first.
+ // Not every adapter self-clears that way, so the explicit callback is
+ // called here too; clearIfCurrent implementations are idempotent, so
+ // a source that already cleared is unaffected.
+ const logInstanceDisabled = (
+ error: ThreadManagementService.ThreadManagementProviderInstanceDisabledError,
+ ) =>
+ Effect.logInfo("orchestration-v2.provider-continuation.instance-disabled", {
+ threadId: request.threadId,
+ providerThreadId: request.providerThreadId,
+ projectId: error.projectId,
+ instanceId: error.instanceId,
+ }).pipe(
+ Effect.andThen(
+ request.clearIfCurrent !== undefined ? request.clearIfCurrent() : Effect.void,
+ ),
+ );
if (request.dispatchIfCurrent === undefined) {
- yield* dispatch;
+ yield* dispatch.pipe(
+ Effect.catchTags({
+ ThreadManagementProviderInstanceDisabledError: logInstanceDisabled,
+ }),
+ );
return;
}
- yield* request.dispatchIfCurrent(dispatch);
+ yield* request.dispatchIfCurrent(dispatch).pipe(
+ Effect.catchTags({
+ ThreadManagementProviderInstanceDisabledError: logInstanceDisabled,
+ }),
+ );
},
);
diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts
index a6b7cc23d087b..19232ddeeac4f 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 308b8b210cebe..d02e78ce55bc9 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 c8802a30f4d42..05c2b41c01164 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/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts
index b7e905d0f4a9a..19a257883cdc1 100644
--- a/apps/server/src/orchestration-v2/RestartContinuation.ts
+++ b/apps/server/src/orchestration-v2/RestartContinuation.ts
@@ -159,28 +159,45 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta
: note.settled
? noteText
: `${noteText}\n\n${CONTINUE_PROMPT}`;
- yield* threads.dispatch({
- type: "message.dispatch",
- commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`),
- threadId: input.threadId,
- messageId,
- text,
- // Shown as a work log row; the prompt the agent got is its detail.
- notification: {
- source: { kind: "system" },
- outcome: "updated",
- summary: note.settled
- ? "T3 Code restarted and stopped background work"
- : "T3 Code restarted and resumed this turn",
- detail: text,
- },
- attachments: [],
- modelSelection: source.modelSelection,
- dispatchMode: { type: "start_immediately" },
- createdBy: "agent",
- creationSource: "server",
- restartContinuationOfRunId: input.sourceRunId,
- });
+ yield* threads
+ .dispatch({
+ type: "message.dispatch",
+ commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`),
+ threadId: input.threadId,
+ messageId,
+ text,
+ // Shown as a work log row; the prompt the agent got is its detail.
+ notification: {
+ source: { kind: "system" },
+ outcome: "updated",
+ summary: note.settled
+ ? "T3 Code restarted and stopped background work"
+ : "T3 Code restarted and resumed this turn",
+ detail: text,
+ },
+ attachments: [],
+ modelSelection: source.modelSelection,
+ dispatchMode: { type: "start_immediately" },
+ createdBy: "agent",
+ creationSource: "server",
+ restartContinuationOfRunId: input.sourceRunId,
+ })
+ .pipe(
+ // The project turned this instance off since the restart. No turn
+ // resumes, and the success path below still runs so a delegated
+ // child waiting on this run is recovered immediately instead of
+ // after EffectWorker exhausts its retries on a refusal that will
+ // not change.
+ Effect.catchTags({
+ ThreadManagementProviderInstanceDisabledError: (error) =>
+ Effect.logInfo("orchestration-v2.restart-continuation.instance-disabled", {
+ threadId: input.threadId,
+ sourceRunId: input.sourceRunId,
+ projectId: error.projectId,
+ instanceId: error.instanceId,
+ }),
+ }),
+ );
},
// A delegated child this declined to continue still owes its parent a
// result. Once a continuation run exists this is a no-op; that run settles it.
diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts
index c42c402003d40..d877922a644a6 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 fa6d0fbd7f125..b2fe77f6cd85b 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 958fb89cc8e46..03fbfa59331e8 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/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
index f7eede36b8e41..3012e09411ac1 100644
--- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
@@ -118,8 +118,13 @@ function makeHarness(options: HarnessOptions = {}) {
layerRegistry,
{ databaseLayer: layerDatabase, runEffectWorker: false },
);
- const layerThreadManagement = ThreadManagement.layer.pipe(Layer.provide(layerOrchestrator));
+ const layerServerSettings = ServerSettings.layerTest(options.serverSettings);
const layerReceipts = CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase));
+ const layerThreadManagement = ThreadManagement.layer.pipe(
+ Layer.provide(layerOrchestrator),
+ Layer.provide(layerServerSettings),
+ Layer.provide(layerReceipts),
+ );
const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase));
const createWorktree = vi.fn(
options.createWorktree ??
@@ -185,7 +190,7 @@ function makeHarness(options: HarnessOptions = {}) {
generateThreadTitle,
generateBranchName,
}),
- ServerSettings.layerTest(options.serverSettings),
+ layerServerSettings,
ProviderRegistryMock.layer(options.providers),
options.managedFolders ??
Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
@@ -1765,6 +1770,70 @@ it.effect("replays a server-allocated launch", () =>
}),
);
+it.effect("refuses a launch when the project disables the model's provider instance", () => {
+ const harness = makeHarness({
+ serverSettings: {
+ projectSettingsOverrides: {
+ [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } },
+ },
+ },
+ });
+ return Effect.gen(function* () {
+ const launches = yield* ThreadLaunch.ThreadLaunchService;
+ const failed = yield* launches
+ .launch(
+ launchInput({
+ command: "command:launch:project-disabled-instance",
+ thread: "thread:launch:project-disabled-instance",
+ message: "Hello",
+ }),
+ )
+ .pipe(Effect.flip);
+ if (failed._tag !== "ThreadManagementProviderInstanceDisabledError") {
+ assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${failed._tag}`);
+ }
+ assert.equal(failed.projectId, projectId);
+ assert.equal(failed.instanceId, modelSelection.instanceId);
+ }).pipe(Effect.provide(harness.layer));
+});
+
+it.effect("replays an accepted launch after the project disables the instance", () => {
+ const harness = makeHarness();
+ return Effect.gen(function* () {
+ const launches = yield* ThreadLaunch.ThreadLaunchService;
+ const settings = yield* ServerSettings.ServerSettingsService;
+ const input = launchInput({
+ command: "command:launch:retry-after-disable",
+ thread: "thread:launch:retry-after-disable",
+ message: "Hello",
+ });
+ const first = yield* launches.launch(input);
+
+ yield* settings.updateSettings({
+ projectSettingsOverrides: {
+ [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } },
+ },
+ });
+
+ const retry = yield* launches.launch(input);
+ assert.equal(retry.threadId, first.threadId);
+ assert.isTrue(retry.resumed);
+
+ const fresh = yield* launches
+ .launch(
+ launchInput({
+ command: "command:launch:fresh-after-disable",
+ thread: "thread:launch:fresh-after-disable",
+ message: "Hello",
+ }),
+ )
+ .pipe(Effect.flip);
+ if (fresh._tag !== "ThreadManagementProviderInstanceDisabledError") {
+ assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${fresh._tag}`);
+ }
+ }).pipe(Effect.provide(harness.layer));
+});
+
it.effect("rejects a server-allocated launch replay with a mismatching thread id", () => {
const harness = makeHarness();
return Effect.gen(function* () {
@@ -1782,7 +1851,9 @@ it.effect("rejects a server-allocated launch replay with a mismatching thread id
})
.pipe(Effect.flip);
assert.notEqual(first.threadId, ThreadId.make("thread:launch:allocated-mismatch"));
- assert.equal(failed._tag, "ThreadLaunchError");
+ if (failed._tag !== "ThreadLaunchError") {
+ assert.fail(`expected ThreadLaunchError, got ${failed._tag}`);
+ }
assert.equal(failed.operation, "create-thread");
assert.include(String(failed.cause), "cannot be replayed");
}).pipe(Effect.provide(harness.layer));
@@ -1804,7 +1875,9 @@ it.effect("rejects a server-allocated launch receipt from another project", () =
projectId: otherProjectId,
})
.pipe(Effect.flip);
- assert.equal(failed._tag, "ThreadLaunchError");
+ if (failed._tag !== "ThreadLaunchError") {
+ assert.fail(`expected ThreadLaunchError, got ${failed._tag}`);
+ }
assert.equal(failed.operation, "resolve-project");
assert.equal(failed.threadId, first.threadId);
assert.equal(failed.cause, "Project identity changed.");
@@ -1828,7 +1901,9 @@ it.effect("rejects a server-allocated launch retry after the thread is deleted",
threadId: first.threadId,
});
const failed = yield* launches.launch(rest).pipe(Effect.flip);
- assert.equal(failed._tag, "ThreadLaunchError");
+ if (failed._tag !== "ThreadLaunchError") {
+ assert.fail(`expected ThreadLaunchError, got ${failed._tag}`);
+ }
assert.equal(failed.operation, "create-thread");
assert.equal(failed.threadId, first.threadId);
assert.equal(failed.cause, "Thread not found.");
@@ -1869,7 +1944,9 @@ it.effect("does not treat an unrelated accepted command receipt as a launch", ()
message: "Should not become a launch",
});
const failed = yield* launches.launch(rest).pipe(Effect.flip);
- assert.equal(failed._tag, "ThreadLaunchError");
+ if (failed._tag !== "ThreadLaunchError") {
+ assert.fail(`expected ThreadLaunchError, got ${failed._tag}`);
+ }
assert.equal(failed.operation, "create-thread");
assert.include(String(failed.cause), "cannot be replayed");
const projection = yield* threads.getThreadProjection(threadId);
@@ -1914,7 +1991,9 @@ it.effect("bounds concurrent first launches to one thread per command", () =>
continue;
}
const error = Cause.findErrorOption(result.cause).pipe(Option.getOrThrow);
- assert.equal(error._tag, "ThreadLaunchError");
+ if (error._tag !== "ThreadLaunchError") {
+ assert.fail(`expected ThreadLaunchError, got ${error._tag}`);
+ }
assert.equal(error.operation, "create-thread");
assert.include(String(error.cause), "cannot be replayed");
const retried = yield* launches.launch(rest);
@@ -2530,3 +2609,99 @@ it.effect.each([
}).pipe(Effect.provide(harness.layer));
}),
);
+
+it.effect("replays an accepted send after the project disables the instance", () => {
+ const harness = makeHarness();
+ const layerFiles = ServerConfig.layerTest(process.cwd(), { prefix: "t3-send-retry-" }).pipe(
+ Layer.provideMerge(NodeServices.layer),
+ );
+ return Effect.gen(function* () {
+ const launches = yield* ThreadLaunch.ThreadLaunchService;
+ const settings = yield* ServerSettings.ServerSettingsService;
+ const created = yield* launches.launch(
+ launchInput({
+ command: "command:send:retry-setup",
+ thread: "thread:send:retry-after-disable",
+ }),
+ );
+
+ const sendInput = {
+ commandId: CommandId.make("command:send:retry-after-disable"),
+ projectId,
+ threadId: created.threadId,
+ messageId: MessageId.make("message:send:retry-after-disable"),
+ text: "Hello again",
+ attachments: [],
+ mode: "auto" as const,
+ createdBy: "user" as const,
+ creationSource: "web" as const,
+ };
+ const first = yield* ThreadMessageIntake.sendToThread(sendInput);
+
+ yield* settings.updateSettings({
+ projectSettingsOverrides: {
+ [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } },
+ },
+ });
+
+ const retry = yield* ThreadMessageIntake.sendToThread(sendInput);
+ assert.equal(retry.message.id, first.message.id);
+
+ const fresh = yield* ThreadMessageIntake.sendToThread({
+ ...sendInput,
+ commandId: CommandId.make("command:send:fresh-after-disable"),
+ messageId: MessageId.make("message:send:fresh-after-disable"),
+ }).pipe(Effect.flip);
+ if (fresh._tag !== "ThreadManagementProviderInstanceDisabledError") {
+ assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${fresh._tag}`);
+ }
+ }).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerFiles)));
+});
+
+it.effect("replays an accepted message.dispatch after the project disables the instance", () => {
+ const harness = makeHarness();
+ const layerFiles = ServerConfig.layerTest(process.cwd(), { prefix: "t3-dispatch-retry-" }).pipe(
+ Layer.provideMerge(NodeServices.layer),
+ );
+ return Effect.gen(function* () {
+ const launches = yield* ThreadLaunch.ThreadLaunchService;
+ const settings = yield* ServerSettings.ServerSettingsService;
+ const created = yield* launches.launch(
+ launchInput({
+ command: "command:dispatch:retry-setup",
+ thread: "thread:dispatch:retry-after-disable",
+ }),
+ );
+
+ const dispatchInput = {
+ type: "message.dispatch" as const,
+ commandId: CommandId.make("command:dispatch:retry-after-disable"),
+ threadId: created.threadId,
+ messageId: MessageId.make("message:dispatch:retry-after-disable"),
+ text: "Hello again",
+ attachments: [],
+ dispatchMode: { type: "queue_after_active" as const },
+ createdBy: "user" as const,
+ creationSource: "web" as const,
+ };
+ const first = yield* ThreadMessageIntake.dispatchCommand(dispatchInput);
+
+ yield* settings.updateSettings({
+ projectSettingsOverrides: {
+ [projectId]: { providerInstances: { [modelSelection.instanceId]: { enabled: false } } },
+ },
+ });
+
+ const retry = yield* ThreadMessageIntake.dispatchCommand(dispatchInput);
+ assert.deepEqual(retry, first);
+
+ const fresh = yield* ThreadMessageIntake.dispatchCommand({
+ ...dispatchInput,
+ commandId: CommandId.make("command:dispatch:fresh-after-disable"),
+ messageId: MessageId.make("message:dispatch:fresh-after-disable"),
+ }).pipe(Effect.flip);
+ if (fresh._tag !== "ThreadManagementProviderInstanceDisabledError") {
+ assert.fail(`expected ThreadManagementProviderInstanceDisabledError, got ${fresh._tag}`);
+ }
+ }).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerFiles)));
+});
diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts
index d1a432bc9b52f..42b1d9cef1e42 100644
--- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts
+++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts
@@ -167,7 +167,10 @@ export class ThreadLaunchService extends Context.Service<
{
readonly launch: (
input: ThreadLaunchInput,
- ) => Effect.Effect;
+ ) => Effect.Effect<
+ ThreadLaunchResult,
+ ThreadLaunchError | ThreadManagement.ThreadManagementProviderInstanceDisabledError
+ >;
/**
* Fails when a new worktree's base ref cannot give provisioning a commit.
* `launch` accepts such a launch and reports the failure inside the
@@ -177,9 +180,13 @@ export class ThreadLaunchService extends Context.Service<
input: Pick,
) => Effect.Effect;
/** Dispatches prepared-run.retry and prepares the run's workspace again. */
- readonly retryPreparation: (
- input: ThreadLaunchRetryInput,
- ) => Effect.Effect;
+ readonly retryPreparation: (input: ThreadLaunchRetryInput) => Effect.Effect<
+ Orchestrator.OrchestratorV2DispatchResult,
+ // Dispatches prepared-run.retry, a command the enablement chokepoint
+ // never gates; the type stays accurate anyway.
+ | Orchestrator.OrchestratorV2Error
+ | ThreadManagement.ThreadManagementProviderInstanceDisabledError
+ >;
}
>()("t3/orchestration-v2/ThreadLaunchService") {}
@@ -341,6 +348,7 @@ const make = Effect.gen(function* () {
: ServerSettings.resolveSourceControlWriterModelSelection(
settings,
yield* providerRegistry.getProviders,
+ input.projectId,
);
return yield* textGeneration
.generateBranchName({
@@ -764,6 +772,11 @@ const make = Effect.gen(function* () {
}
const launchReceipt = yield* readReceipt(input, input.commandId);
+ yield* threads.assertProviderInstanceEnabled({
+ projectId: input.projectId,
+ commandId: input.commandId,
+ instanceId: input.modelSelection.instanceId,
+ });
return yield* Effect.gen(function* () {
// A retried launch has no client-supplied id to replay against, so
// recover the thread id its accepted create was recorded under before
diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts
index 94e17bdc567e7..fb6e9af45424c 100644
--- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts
@@ -5,25 +5,41 @@ 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 ServerSettings from "../serverSettings.ts";
+import * as SqlitePersistence from "../persistence/Sqlite.ts";
+import * as CommandReceiptStore from "./CommandReceiptStore.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 = {
@@ -260,6 +276,206 @@ it("derives thread management messages from structural error attributes", () =>
);
});
+const layerReceiptsTest = CommandReceiptStore.layer.pipe(
+ Layer.provide(SqlitePersistence.layerMemory),
+);
+
+const layerThreadManagementTest = (settingsLayer: ReturnType) =>
+ ThreadManagementService.layer.pipe(
+ Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
+ Layer.provide(Layer.mock(Orchestrator.OrchestratorV2)({})),
+ Layer.provide(settingsLayer),
+ Layer.provide(layerReceiptsTest),
+ );
+
+it.effect("refuses a new turn when the project effectively disables the instance", () => {
+ const projectId = ProjectId.make("project:thread-management:gate-disabled");
+ const instanceId = ProviderInstanceId.make("codex_work");
+ const commandId = CommandId.make("command:thread-management:gate-disabled");
+ const layerTest = layerThreadManagementTest(
+ ServerSettings.layerTest({
+ providerInstances: { [instanceId]: { driver: "codex", enabled: true } },
+ projectSettingsOverrides: {
+ [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
+ },
+ }),
+ );
+
+ return Effect.gen(function* () {
+ const threads = yield* ThreadManagementService.ThreadManagementService;
+ const error = yield* Effect.flip(
+ threads.assertProviderInstanceEnabled({ projectId, commandId, instanceId }),
+ );
+
+ expect(error).toBeInstanceOf(
+ ThreadManagementService.ThreadManagementProviderInstanceDisabledError,
+ );
+ expect(error).toMatchObject({ projectId, instanceId });
+ expect(error.message).toBe(
+ `Provider instance "${instanceId}" is disabled for project ${projectId}.`,
+ );
+ }).pipe(Effect.provide(layerTest));
+});
+
+it.effect("allows a new turn when the project enables an instance the machine disabled", () => {
+ const projectId = ProjectId.make("project:thread-management:gate-enabled");
+ const instanceId = ProviderInstanceId.make("codex_work");
+ const commandId = CommandId.make("command:thread-management:gate-enabled");
+ const layerTest = layerThreadManagementTest(
+ ServerSettings.layerTest({
+ providerInstances: { [instanceId]: { driver: "codex", enabled: false } },
+ projectSettingsOverrides: {
+ [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
+ },
+ }),
+ );
+
+ return Effect.gen(function* () {
+ const threads = yield* ThreadManagementService.ThreadManagementService;
+ yield* threads.assertProviderInstanceEnabled({ projectId, commandId, instanceId });
+ }).pipe(Effect.provide(layerTest));
+});
+
+it.effect("inherits the machine value when the project has no override", () => {
+ const projectId = ProjectId.make("project:thread-management:gate-inherit");
+ const instanceId = ProviderInstanceId.make("codex_work");
+ const commandId = CommandId.make("command:thread-management:gate-inherit");
+ const layerTest = layerThreadManagementTest(
+ ServerSettings.layerTest({
+ providerInstances: { [instanceId]: { driver: "codex", enabled: false } },
+ }),
+ );
+
+ return Effect.gen(function* () {
+ const threads = yield* ThreadManagementService.ThreadManagementService;
+ const error = yield* Effect.flip(
+ threads.assertProviderInstanceEnabled({ projectId, commandId, instanceId }),
+ );
+
+ expect(error).toBeInstanceOf(
+ ThreadManagementService.ThreadManagementProviderInstanceDisabledError,
+ );
+ }).pipe(Effect.provide(layerTest));
+});
+
+const layerDispatchChokepointTest =
+ (settingsLayer: ReturnType) => (dispatched: Array) =>
+ ThreadManagementService.layer.pipe(
+ Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
+ Layer.provide(
+ Layer.mock(Orchestrator.OrchestratorV2)({
+ getThreadRecords: () =>
+ Effect.succeed({
+ thread: {
+ id: ThreadId.make("thread:thread-management:chokepoint"),
+ projectId: ProjectId.make("project:thread-management:chokepoint"),
+ deletedAt: null,
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("codex_work"),
+ model: "test",
+ },
+ },
+ } as unknown as OrchestrationV2ThreadProjection),
+ dispatch: (command) =>
+ Effect.sync(() => {
+ dispatched.push(command.type);
+ return { sequence: 1, storedEvents: [] } as never;
+ }),
+ }),
+ ),
+ Layer.provide(settingsLayer),
+ Layer.provideMerge(layerReceiptsTest),
+ );
+
+it.effect(
+ "the dispatch chokepoint refuses a continuation message.dispatch on a project-disabled instance before reaching the orchestrator",
+ () => {
+ const projectId = ProjectId.make("project:thread-management:chokepoint");
+ const threadId = ThreadId.make("thread:thread-management:chokepoint");
+ const instanceId = ProviderInstanceId.make("codex_work");
+ const commandId = CommandId.make("command:thread-management:chokepoint-disabled");
+ const dispatched: Array = [];
+ const layerTest = layerDispatchChokepointTest(
+ ServerSettings.layerTest({
+ providerInstances: { [instanceId]: { driver: "codex", enabled: true } },
+ projectSettingsOverrides: {
+ [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
+ },
+ }),
+ )(dispatched);
+
+ return Effect.gen(function* () {
+ const threads = yield* ThreadManagementService.ThreadManagementService;
+ const error = yield* Effect.flip(
+ threads.dispatch({
+ type: "message.dispatch",
+ commandId,
+ threadId,
+ messageId: MessageId.make("message:thread-management:chokepoint-disabled"),
+ text: "Background task completed.",
+ attachments: [],
+ dispatchMode: { type: "queue_after_active" },
+ createdBy: "agent",
+ creationSource: "server",
+ }),
+ );
+
+ expect(error).toBeInstanceOf(
+ ThreadManagementService.ThreadManagementProviderInstanceDisabledError,
+ );
+ expect(error).toMatchObject({ projectId, instanceId });
+ expect(dispatched).toEqual([]);
+ }).pipe(Effect.provide(layerTest));
+ },
+);
+
+it.effect(
+ "a replay of an already-accepted command id dispatches despite the instance now being disabled",
+ () => {
+ const projectId = ProjectId.make("project:thread-management:chokepoint");
+ const threadId = ThreadId.make("thread:thread-management:chokepoint");
+ const instanceId = ProviderInstanceId.make("codex_work");
+ const commandId = CommandId.make("command:thread-management:chokepoint-replay");
+ const dispatched: Array = [];
+ const layerTest = layerDispatchChokepointTest(
+ ServerSettings.layerTest({
+ providerInstances: { [instanceId]: { driver: "codex", enabled: true } },
+ projectSettingsOverrides: {
+ [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
+ },
+ }),
+ )(dispatched);
+
+ return Effect.gen(function* () {
+ const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2;
+ yield* receipts.insertIfAbsent({
+ commandId,
+ threadId,
+ commandType: "message.dispatch",
+ acceptedAt: yield* DateTime.now,
+ resultSequence: 1,
+ status: "accepted",
+ error: null,
+ });
+
+ const threads = yield* ThreadManagementService.ThreadManagementService;
+ yield* threads.dispatch({
+ type: "message.dispatch",
+ commandId,
+ threadId,
+ messageId: MessageId.make("message:thread-management:chokepoint-replay"),
+ text: "Background task completed.",
+ attachments: [],
+ dispatchMode: { type: "queue_after_active" },
+ createdBy: "agent",
+ creationSource: "server",
+ });
+
+ expect(dispatched).toEqual(["message.dispatch"]);
+ }).pipe(Effect.provide(layerTest));
+ },
+);
+
it.effect("classifies projection infrastructure failures separately from a missing thread", () => {
const projectId = ProjectId.make("project:thread-management:projection-failure");
const threadId = ThreadId.make("thread:thread-management:projection-failure");
@@ -269,11 +485,14 @@ 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),
}),
),
+ Layer.provide(ServerSettings.layerTest()),
+ Layer.provide(layerReceiptsTest),
);
return Effect.gen(function* () {
@@ -302,11 +521,14 @@ 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),
}),
),
+ Layer.provide(ServerSettings.layerTest()),
+ Layer.provide(layerReceiptsTest),
);
return Effect.gen(function* () {
@@ -327,6 +549,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)({
@@ -338,6 +561,8 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte
}),
),
),
+ Layer.provide(ServerSettings.layerTest()),
+ Layer.provide(layerReceiptsTest),
);
return Effect.gen(function* () {
const service = yield* ThreadManagementService.ThreadManagementService;
@@ -368,6 +593,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),
@@ -385,6 +611,8 @@ it.effect.each([
}),
}),
),
+ Layer.provide(ServerSettings.layerTest()),
+ Layer.provide(layerReceiptsTest),
);
const service = yield* ThreadManagementService.ThreadManagementService.pipe(
Effect.provide(layerTest),
@@ -434,6 +662,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),
@@ -455,6 +684,8 @@ it.effect("waitForThread reads the run again only when the run updates", () =>
}),
}),
),
+ Layer.provide(ServerSettings.layerTest()),
+ Layer.provide(layerReceiptsTest),
);
const service = yield* ThreadManagementService.ThreadManagementService.pipe(
Effect.provide(layerTest),
@@ -474,6 +705,224 @@ 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));
+ },
+ }),
+ ),
+ ),
+ Layer.provide(ServerSettings.layerTest()),
+ Layer.provide(layerReceiptsTest),
+ );
+ 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 +934,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),
@@ -500,6 +950,8 @@ it.effect.each([
}),
}),
),
+ Layer.provide(ServerSettings.layerTest()),
+ Layer.provide(layerReceiptsTest),
);
const service = yield* ThreadManagementService.ThreadManagementService.pipe(
Effect.provide(layerTest),
diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts
index 3da3ef12438d8..a3d6f7b852609 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,
@@ -24,6 +26,8 @@ import {
type OrchestrationV2ThreadShell,
type OrchestrationV2TurnItem,
ProjectId,
+ ProviderInstanceId,
+ resolveProjectProviderInstanceEnabled,
RunId,
type ScheduledTaskId,
ThreadId,
@@ -35,12 +39,18 @@ 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 CommandReceiptStore from "./CommandReceiptStore.ts";
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";
+import * as ServerSettings from "../serverSettings.ts";
export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart";
@@ -260,6 +270,18 @@ export class ThreadManagementDurableRunProjectionError extends Schema.TaggedErro
}
}
+export class ThreadManagementProviderInstanceDisabledError extends Schema.TaggedError()(
+ "ThreadManagementProviderInstanceDisabledError",
+ {
+ projectId: ProjectId,
+ instanceId: ProviderInstanceId,
+ },
+) {
+ override get message(): string {
+ return `Provider instance "${this.instanceId}" is disabled for project ${this.projectId}.`;
+ }
+}
+
export const ThreadManagementError = Schema.Union([
ThreadManagementThreadNotFoundError,
ThreadManagementRunNotFoundError,
@@ -269,6 +291,7 @@ export const ThreadManagementError = Schema.Union([
ThreadManagementProjectionLoadError,
ThreadManagementProjectThreadsListError,
ThreadManagementDurableRunProjectionError,
+ ThreadManagementProviderInstanceDisabledError,
]);
export type ThreadManagementError = typeof ThreadManagementError.Type;
@@ -284,9 +307,18 @@ export interface ThreadManagementServiceShape {
readonly ensureLegacyTranscript: (
threadId: ThreadId,
) => Effect.Effect;
+ /**
+ * The single chokepoint for commands that can start or wake provider work:
+ * `message.dispatch` and `delegated_task.request` are refused here when the
+ * resolved instance is off for the project, except a command id that
+ * already has a receipt, which only replays its recorded result.
+ */
readonly dispatch: (
command: OrchestrationV2ServerCommand,
- ) => Effect.Effect;
+ ) => Effect.Effect<
+ Orchestrator.OrchestratorV2DispatchResult,
+ Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError
+ >;
readonly getThreadHistoryPage: Orchestrator.OrchestratorV2["Service"]["getThreadHistoryPage"];
readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"];
readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"];
@@ -334,6 +366,32 @@ export interface ThreadManagementServiceShape {
readonly sendToThread: (
input: ThreadManagementSendInput,
) => Effect.Effect;
+ /**
+ * Refuses a new turn on an instance the project has effectively disabled.
+ * A command that already has a receipt was let through before, so its
+ * retry replays instead of being refused under newer settings.
+ */
+ readonly assertProviderInstanceEnabled: (input: {
+ readonly projectId: ProjectId;
+ readonly commandId: CommandId;
+ readonly instanceId: ProviderInstanceId;
+ }) => Effect.Effect;
+ /**
+ * Effective enablement for candidate selection, without the receipt-replay
+ * exemption `assertProviderInstanceEnabled` grants an accepted command.
+ */
+ readonly isProviderInstanceEnabledForProject: (input: {
+ readonly projectId: ProjectId;
+ readonly instanceId: ProviderInstanceId;
+ }) => Effect.Effect;
+ /**
+ * Whether `commandId` already has a receipt. A caller resolving a target
+ * ahead of a dispatch under this same command id can use it to skip
+ * project-enablement filtering: the dispatch will only replay the recorded
+ * result, never start anything new, so the filter would just refuse a
+ * retry the project's settings no longer allow fresh.
+ */
+ readonly hasReceipt: (commandId: CommandId) => Effect.Effect;
readonly waitForThread: (
input: ThreadManagementWaitInput,
) => Effect.Effect;
@@ -359,11 +417,20 @@ export interface ThreadManagementServiceShape {
readonly byOwnAgent: boolean;
}) => Effect.Effect<
{ readonly sequence: number } | { readonly settlesWhenTurnEnds: true },
- Orchestrator.OrchestratorV2Error
+ // Dispatches thread.settle, a command the enablement chokepoint never
+ // gates; the type stays accurate anyway.
+ Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError
>;
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
@@ -373,7 +440,12 @@ export interface ThreadManagementServiceShape {
readonly threadId: ThreadId;
readonly commandId: CommandId;
readonly reason?: string | undefined;
- }) => Effect.Effect;
+ }) => Effect.Effect<
+ void,
+ // Dispatches thread.stop, a command the enablement chokepoint never
+ // gates; the type stays accurate anyway.
+ Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError
+ >;
readonly getThreadEventSequence: Orchestrator.OrchestratorV2["Service"]["getThreadEventSequence"];
readonly recoverDelegatedTask: Orchestrator.OrchestratorV2["Service"]["recoverDelegatedTask"];
readonly delegatedTaskResultPending: Orchestrator.OrchestratorV2["Service"]["delegatedTaskResultPending"];
@@ -440,9 +512,49 @@ 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;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
+ const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2;
+
+ const assertProviderInstanceEnabled: ThreadManagementServiceShape["assertProviderInstanceEnabled"] =
+ (input) =>
+ receipts.getByCommandId(input.commandId).pipe(
+ Effect.orElseSucceed(() => Option.none()),
+ Effect.flatMap((receipt) =>
+ Option.isSome(receipt)
+ ? Effect.void
+ : serverSettings.getSettings.pipe(
+ Effect.orDie,
+ Effect.flatMap((current) =>
+ resolveProjectProviderInstanceEnabled(current, input.projectId, input.instanceId)
+ ? Effect.void
+ : new ThreadManagementProviderInstanceDisabledError({
+ projectId: input.projectId,
+ instanceId: input.instanceId,
+ }),
+ ),
+ ),
+ ),
+ );
+
+ const isProviderInstanceEnabledForProject: ThreadManagementServiceShape["isProviderInstanceEnabledForProject"] =
+ (input) =>
+ serverSettings.getSettings.pipe(
+ Effect.orDie,
+ Effect.map((current) =>
+ resolveProjectProviderInstanceEnabled(current, input.projectId, input.instanceId),
+ ),
+ );
+
+ const hasReceipt: ThreadManagementServiceShape["hasReceipt"] = (commandId) =>
+ receipts.getByCommandId(commandId).pipe(
+ Effect.orElseSucceed(() => Option.none()),
+ Effect.map(Option.isSome),
+ );
const ensureLegacyTranscript = Effect.fn(
"orchestrationV2.threadManagement.ensureLegacyTranscript",
@@ -509,8 +621,41 @@ const make = Effect.gen(function* () {
Effect.andThen(orchestrator.getThreadSnapshotWindow(threadId, options)),
);
+ // Every path that can start or wake a provider turn funnels through this
+ // dispatch, so resolving and checking the target instance here (instead of
+ // at each caller) is the one place "off" is enforced.
+ const assertDispatchTargetEnabled = (command: OrchestrationV2ServerCommand) => {
+ switch (command.type) {
+ case "message.dispatch":
+ return orchestrator.getThreadRecords(command.threadId, []).pipe(
+ Effect.flatMap((projection) =>
+ assertProviderInstanceEnabled({
+ projectId: projection.thread.projectId,
+ commandId: command.commandId,
+ instanceId: (command.modelSelection ?? projection.thread.modelSelection).instanceId,
+ }),
+ ),
+ );
+ case "delegated_task.request":
+ return orchestrator.getThreadRecords(command.parentThreadId, []).pipe(
+ Effect.flatMap((parent) =>
+ assertProviderInstanceEnabled({
+ projectId: parent.thread.projectId,
+ commandId: command.commandId,
+ instanceId: command.modelSelection.instanceId,
+ }),
+ ),
+ );
+ default:
+ return Effect.void;
+ }
+ };
+
const dispatch: ThreadManagementServiceShape["dispatch"] = (command) =>
- ensureCommandTranscripts(command).pipe(Effect.andThen(orchestrator.dispatch(command)));
+ ensureCommandTranscripts(command).pipe(
+ Effect.andThen(assertDispatchTargetEnabled(command)),
+ Effect.andThen(orchestrator.dispatch(command)),
+ );
const getProjectThread: ThreadManagementServiceShape["getProjectThread"] = (input) =>
getThreadProjection(input.threadId).pipe(
@@ -593,7 +738,6 @@ const make = Effect.gen(function* () {
threadId: input.threadId,
});
}
-
const steerableRun = latestSteerableRun(target);
let dispatchMode: Extract<
OrchestrationV2Command,
@@ -618,7 +762,7 @@ const make = Effect.gen(function* () {
};
}
- const dispatch = yield* orchestrator.dispatch({
+ const dispatched = yield* dispatch({
type: "message.dispatch",
commandId: input.commandId,
threadId: input.threadId,
@@ -670,7 +814,7 @@ const make = Effect.gen(function* () {
: input.mode === "restart"
? "restarted"
: "steered";
- return { dispatch, projection, message, run, turnItem, delivery };
+ return { dispatch: dispatched, projection, message, run, turnItem, delivery };
});
const waitForThread: ThreadManagementServiceShape["waitForThread"] = (input) =>
@@ -841,10 +985,96 @@ 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"]);
- const failures: Array = [];
+ const failures: Array<
+ Orchestrator.OrchestratorV2Error | ThreadManagementProviderInstanceDisabledError
+ > = [];
for (const task of subagents) {
if (task.origin !== "app_owned" || task.childThreadId === null) continue;
const threadId = task.childThreadId;
@@ -914,11 +1144,15 @@ const make = Effect.gen(function* () {
getThreadShell: orchestrator.getThreadShell,
listProjectThreads,
sendToThread,
+ assertProviderInstanceEnabled,
+ isProviderInstanceEnabledForProject,
+ hasReceipt,
waitForThread,
settleAfterRun,
settleThread,
interruptThread,
stopDelegatedTasks,
+ stopWorkflow,
getThreadEventSequence: orchestrator.getThreadEventSequence,
recoverDelegatedTask: orchestrator.recoverDelegatedTask,
delegatedTaskResultPending: orchestrator.delegatedTaskResultPending,
@@ -938,11 +1172,24 @@ 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
+ | ServerSettings.ServerSettingsService
+ | CommandReceiptStore.CommandReceiptStoreV2
+> = 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
+ | ServerSettings.ServerSettingsService
+ | CommandReceiptStore.CommandReceiptStoreV2
+> = Layer.effect(ThreadManagementService, make).pipe(Layer.provide(ThreadCommandExecutor.layer));
diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts
index 81dd25847799f..c073b6c545dfb 100644
--- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts
@@ -7,6 +7,9 @@ import {
ChatAttachmentId,
CommandId,
EventId,
+ MessageId,
+ ProjectId,
+ ProviderInstanceId,
RuntimeRequestId,
ThreadId,
TurnItemId,
@@ -20,16 +23,17 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { createPendingAttachmentId, resolveAttachmentPath } from "../attachmentStore.ts";
import * as ServerConfig from "../config.ts";
+import * as ServerSettings from "../serverSettings.ts";
import {
OrchestratorCommandPreviouslyRejectedError,
OrchestratorDispatchError,
} from "./Orchestrator.ts";
import * as ThreadManagementService from "./ThreadManagementService.ts";
-import { dispatchCommand } from "./ThreadMessageIntake.ts";
+import { dispatchCommand, sendToThread } from "./ThreadMessageIntake.ts";
const layerIntakeTest = ServerConfig.layerTest(process.cwd(), {
prefix: "t3-question-intake-",
-}).pipe(Layer.provideMerge(NodeServices.layer));
+}).pipe(Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerSettings.layerTest()));
const layerFailingDispatch = (captured: OrchestrationV2ServerCommand[]) =>
Layer.mock(ThreadManagementService.ThreadManagementService)({
@@ -764,3 +768,53 @@ it.effect("applies the image budget across all questions before dispatch", () =>
expect(captured).toEqual([]);
}).pipe(Effect.provide(layerIntakeTest)),
);
+
+it.effect("releases claimed copies when a project disables the message's provider instance", () =>
+ Effect.gen(function* () {
+ const config = yield* ServerConfig.ServerConfig;
+ const pendingId = ChatAttachmentId.make(createPendingAttachmentId()!);
+ NodeFS.writeFileSync(
+ NodePath.join(config.attachmentsDir, `${pendingId}.png`),
+ new Uint8Array([1, 2, 3]),
+ );
+ const result = yield* sendToThread({
+ projectId: ProjectId.make("project-disabled-send"),
+ commandId: CommandId.make("command-disabled-send"),
+ threadId: ThreadId.make("thread-disabled-send"),
+ messageId: MessageId.make("message-disabled-send"),
+ text: "Hello",
+ attachments: [
+ {
+ type: "image",
+ id: pendingId,
+ name: "screen.png",
+ mimeType: "image/png",
+ sizeBytes: 3,
+ },
+ ],
+ mode: "auto",
+ createdBy: "user",
+ creationSource: "web",
+ }).pipe(
+ Effect.provide(
+ Layer.mock(ThreadManagementService.ThreadManagementService)({
+ sendToThread: (input) =>
+ Effect.fail(
+ new ThreadManagementService.ThreadManagementProviderInstanceDisabledError({
+ projectId: input.projectId,
+ instanceId: ProviderInstanceId.make("codex"),
+ }),
+ ),
+ }),
+ ),
+ Effect.result,
+ );
+ expect(result._tag).toBe("Failure");
+ expect(
+ NodeFS.readdirSync(config.attachmentsDir).filter((entry) =>
+ entry.startsWith("thread-disabled-send-"),
+ ),
+ ).toEqual([]);
+ expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${pendingId}.png`))).toBe(true);
+ }).pipe(Effect.provide(layerIntakeTest)),
+);
diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts
index c8f65fd0340af..21540703ffbcf 100644
--- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts
+++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts
@@ -13,6 +13,8 @@ import * as ThreadManagement from "./ThreadManagementService.ts";
// These dispatcher failures occur in receipt validation or planning, before
// commitCommand. Generic dispatch errors can follow a commit and remain uncertain.
+// A disabled-instance refusal comes from ThreadManagementService.dispatch's
+// own enablement check, which also runs before any commit is attempted.
function dispatchWasNotAccepted(
error: Orchestrator.OrchestratorV2Error | ThreadManagement.ThreadManagementError,
) {
@@ -24,12 +26,14 @@ function dispatchWasNotAccepted(
case "OrchestratorCommandIdConflictError":
case "OrchestratorSubagentThreadReadOnlyError":
case "OrchestratorThreadAboveModeLimitError":
+ case "ThreadManagementProviderInstanceDisabledError":
return true;
default:
return false;
}
}
const isOrchestratorError = Schema.is(Orchestrator.OrchestratorV2Error);
+const isThreadManagementError = Schema.is(ThreadManagement.ThreadManagementError);
const releaseUnusedClaims = Effect.fn("ThreadMessageIntake.releaseUnusedClaims")(function* (
claimedPaths: ReadonlyArray,
@@ -221,13 +225,19 @@ export const launchThread = Effect.fn("ThreadMessageIntake.launchThread")(functi
),
),
Effect.tapError((error) => {
+ if (error._tag === "ThreadManagementProviderInstanceDisabledError") {
+ return AttachmentClaims.releaseClaimedAttachments(claimed.claimedPaths);
+ }
// Project/receipt reads precede message dispatch. The create-thread error
// also wraps post-message projection reads, so its tag alone is not proof.
+ // The initial message's own dispatch can still find the instance newly
+ // disabled, which wraps the same tag handled above instead of surfacing
+ // it directly, so the guard below also recognizes it.
const notAccepted =
error.operation === "resolve-project" ||
error.operation === "read-receipt" ||
((error.operation === "create-thread" || error.operation === "dispatch-message") &&
- isOrchestratorError(error.cause) &&
+ (isOrchestratorError(error.cause) || isThreadManagementError(error.cause)) &&
// Projection errors under create-thread can occur after the message commit.
(error.operation !== "create-thread" ||
error.cause._tag !== "OrchestratorProjectionError") &&
diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts
index 6b058f25a7e88..c0732ba2a54cf 100644
--- a/apps/server/src/orchestration-v2/ThreadStop.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts
@@ -21,8 +21,10 @@ import * as SqlClient from "effect/sql/SqlClient";
import * as TestClock from "effect/testing/TestClock";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
+import * as ServerSettings from "../serverSettings.ts";
import { CLAUDE_PROVIDER } from "./Adapters/ClaudeAdapterV2.ts";
import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
+import * as CommandReceiptStore from "./CommandReceiptStore.ts";
import { OrchestrationEffectRequestV2 } from "./EffectOutbox.ts";
import * as Orchestrator from "./Orchestrator.ts";
import * as ProjectionStore from "./ProjectionStore.ts";
@@ -54,6 +56,8 @@ const layerTest = ThreadManagementService.layer.pipe(
),
),
),
+ Layer.provideMerge(ServerSettings.layerTest()),
+ Layer.provideMerge(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
);
const encodeEffectRequest = Schema.encodeSync(Schema.fromJsonString(OrchestrationEffectRequestV2));
diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts
index 06f7aa2488c80..34ab4ed716109 100644
--- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts
@@ -21,6 +21,7 @@ import * as ProjectStore from "./ProjectStore.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as TextGeneration from "../textGeneration/TextGeneration.ts";
import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
+import * as CommandReceiptStore from "./CommandReceiptStore.ts";
import * as EffectOutbox from "./EffectOutbox.ts";
import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
import * as ThreadManagement from "./ThreadManagementService.ts";
@@ -54,7 +55,12 @@ function makeHarness(
layerRegistry,
{ databaseLayer: layerDatabase, runEffectWorker: false },
);
- const layerThreadManagement = ThreadManagement.layer.pipe(Layer.provide(layerOrchestrator));
+ const layerServerSettings = ServerSettings.layerTest({});
+ const layerThreadManagement = ThreadManagement.layer.pipe(
+ Layer.provide(layerOrchestrator),
+ Layer.provide(layerServerSettings),
+ Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
+ );
const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase));
const generateThreadTitle = vi.fn(
options.generateTitle ?? (() => Effect.succeed({ title: "Generated title" })),
@@ -86,7 +92,7 @@ function makeHarness(
layerThreadManagement,
layerProjectedProjects,
Layer.mock(TextGeneration.TextGeneration)({ generateThreadTitle }),
- ServerSettings.layerTest({}),
+ layerServerSettings,
),
),
);
diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts
index dd3aeb52797f1..86333c51405b3 100644
--- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts
+++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts
@@ -33,7 +33,12 @@ export class ThreadTitleRegenerationService extends Context.Service<
| { readonly type: "regenerate" };
}) => Effect.Effect<
void,
- OrchestratorV2Error | ProjectStore.ProjectStoreV2Error | ServerSettingsError
+ | OrchestratorV2Error
+ | ProjectStore.ProjectStoreV2Error
+ | ServerSettingsError
+ // `complete` dispatches thread.title.regeneration.complete, a command
+ // the enablement chokepoint never gates; the type stays accurate anyway.
+ | ThreadManagementService.ThreadManagementProviderInstanceDisabledError
>;
}
>()("t3/orchestration-v2/ThreadTitleRegenerationService") {}
diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts
index 4d3ff429f0c66..d122289ecc08e 100644
--- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts
+++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts
@@ -5453,3 +5453,133 @@ it.layer(layerTest)("usage-limit recovery", (it) => {
}),
);
});
+
+const queueResumeDisabledInstanceProjectId = ProjectId.make(
+ "project:queue-resume-disabled-instance",
+);
+
+const layerQueueResumeDisabledInstanceTest = Layer.mergeAll(
+ RuntimeLayer.layer,
+ RuntimeLayer.layerEventSink,
+ ProjectStore.layer,
+ ProjectionStore.layer,
+ EffectOutbox.layer,
+ ThreadCommandExecutor.layer,
+).pipe(
+ Layer.provide(McpSessionRegistryTestkit.layer),
+ Layer.provide(McpProviderSessions.layer),
+ Layer.provide(SqlitePersistence.layerMemory),
+ Layer.provide(layerCheckpointStoreTest),
+ Layer.provide(layerServerConfig),
+ Layer.provide(
+ ServerSettings.layerTest({
+ providerInstances: {
+ [alternateInstanceId]: { driver: "codex", enabled: true },
+ },
+ projectSettingsOverrides: {
+ [queueResumeDisabledInstanceProjectId]: {
+ providerInstances: { [alternateInstanceId]: { enabled: false } },
+ },
+ },
+ }),
+ ),
+ Layer.provide(layerTestProviderInstanceRegistry),
+ Layer.provide(layerGitWorkflowTest),
+ Layer.provide(layerProjectServiceTest),
+ Layer.provide(layerPlatformTest),
+);
+
+it.layer(layerQueueResumeDisabledInstanceTest)(
+ "queue.resume on a project-disabled instance",
+ (it) => {
+ it.effect(
+ "holds a queued run instead of starting it on an instance the project turned off",
+ () =>
+ Effect.gen(function* () {
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const events = yield* EventSink.EventSinkV2;
+ const threadId = ThreadId.make("queue-resume-disabled-instance-thread");
+ const queuedModelSelection = {
+ instanceId: alternateInstanceId,
+ model: "gpt-5.5",
+ } satisfies ModelSelection;
+
+ yield* orchestrator.dispatch({
+ type: "thread.create",
+ createdBy: "user",
+ creationSource: "web",
+ commandId: CommandId.make(`${threadId}:create`),
+ threadId,
+ projectId: queueResumeDisabledInstanceProjectId,
+ title: "Queue resume disabled instance",
+ modelSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ });
+ yield* orchestrator.dispatch({
+ type: "message.dispatch",
+ createdBy: "user",
+ creationSource: "web",
+ commandId: CommandId.make(`${threadId}:message:active`),
+ threadId,
+ messageId: MessageId.make(`${threadId}:message:active`),
+ text: "Active",
+ attachments: [],
+ dispatchMode: { type: "defer_start" },
+ });
+ yield* orchestrator.dispatch({
+ type: "message.dispatch",
+ createdBy: "user",
+ creationSource: "web",
+ commandId: CommandId.make(`${threadId}:message:queued`),
+ threadId,
+ messageId: MessageId.make(`${threadId}:message:queued`),
+ text: "Queued",
+ attachments: [],
+ modelSelection: queuedModelSelection,
+ dispatchMode: { type: "queue_after_active" },
+ });
+
+ const before = yield* orchestrator.getThreadProjection(threadId);
+ const activeRun = before.runs.find((run) => run.status !== "queued")!;
+ const queuedRun = before.runs.find((run) => run.status === "queued")!;
+ const now = yield* DateTime.now;
+ // A raw event ends the active run without going through dispatch,
+ // so nothing has yet tried to advance the queue onto the disabled
+ // instance.
+ yield* events.write({
+ events: [
+ {
+ id: EventId.make(`${threadId}:active-completed`),
+ type: "run.updated",
+ threadId,
+ runId: activeRun.id,
+ occurredAt: now,
+ payload: { ...activeRun, status: "completed", completedAt: now },
+ },
+ ],
+ });
+
+ yield* orchestrator.dispatch({
+ type: "queue.resume",
+ commandId: CommandId.make(`${threadId}:resume`),
+ threadId,
+ });
+
+ const resumed = yield* orchestrator.getThreadProjection(threadId);
+ const resumedQueuedRun = resumed.runs.find((run) => run.id === queuedRun.id);
+ assert.equal(
+ resumedQueuedRun?.status,
+ "queued",
+ "a disabled instance must not start the queued run",
+ );
+ assert.isTrue(
+ resumedQueuedRun?.queueHeld,
+ "a refused resume re-holds the queue instead of leaving it looking resumable",
+ );
+ }),
+ );
+ },
+);
diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts
index 2442a4fa010c8..18ad606d97678 100644
--- a/apps/server/src/orchestration-v2/runtimeLayer.ts
+++ b/apps/server/src/orchestration-v2/runtimeLayer.ts
@@ -240,7 +240,14 @@ const layerAgentSessionImporterProvided = AgentSessionImporter.layer.pipe(
);
const layerThreadManagementProvided = ThreadManagementService.layerWithLegacyImporter.pipe(
- Layer.provide(Layer.merge(layerOrchestratorProvided, layerLegacyV1ThreadImporterProvided)),
+ Layer.provide(
+ Layer.mergeAll(
+ layerOrchestratorProvided,
+ layerLegacyV1ThreadImporterProvided,
+ layerCommandReceiptStoreProvided,
+ 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 27c1cc28ae5a4..1ee5fb9964843 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(
@@ -447,6 +450,7 @@ export function layerWithRegistry(
layerProviderSessionManagerProvided,
layerProviderSwitchServiceProvided,
layerRunExecutionServiceProvided,
+ layerServerSettings,
ThreadForkService.layer,
),
),
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 bc4a61641a07c..e22e961aaa37b 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 f7765b03be632..28b2d173833c2 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 0000000000000..94c7e9fbcb152
--- /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 0000000000000..fdeb043c40d69
--- /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 0000000000000..0e8fd9edf5cf8
--- /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 0000000000000..86c9496394023
--- /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 311b894276972..39f14c53b85cc 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 ef2ea88c574a6..1775202a79a3b 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/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts
index 67290369b0f42..cf9d743ea45df 100644
--- a/apps/server/src/persistence/Errors.ts
+++ b/apps/server/src/persistence/Errors.ts
@@ -129,5 +129,6 @@ export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | Persis
export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError;
export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError;
export type PullRequestFilesViewedRepositoryError = PersistenceSqlError | PersistenceDecodeError;
+export type PullRequestReviewGuideRepositoryError = PersistenceSqlError | PersistenceDecodeError;
export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError;
diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts
index 35c1a3fdce4bb..7990c53ac454b 100644
--- a/apps/server/src/persistence/Migrations.ts
+++ b/apps/server/src/persistence/Migrations.ts
@@ -74,6 +74,7 @@ import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts";
import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts";
import Migration0059 from "./Migrations/059_McpAppModelContext.ts";
import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts";
+import Migration0061 from "./Migrations/061_PullRequestReviewGuides.ts";
/**
* Migration loader with all migrations defined inline.
@@ -148,6 +149,7 @@ export const migrationEntries = [
[58, "WebhookRelayDeliveries", Migration0058],
[59, "McpAppModelContext", Migration0059],
[60, "ThreadSnapshotWindowIndexes", Migration0060],
+ [61, "PullRequestReviewGuides", Migration0061],
] as const;
export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts
index 5e45ed05d3024..7d5c845cbf22a 100644
--- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts
+++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts
@@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => {
Effect.sync(() => {
assert.deepStrictEqual(
migrationEntries.map(([id]) => id),
- Array.from({ length: 60 }, (_, index) => index + 1),
+ Array.from({ length: 61 }, (_, index) => index + 1),
);
}),
);
@@ -32,6 +32,7 @@ layer("055_OrchestrationV2", (it) => {
[58, "WebhookRelayDeliveries"],
[59, "McpAppModelContext"],
[60, "ThreadSnapshotWindowIndexes"],
+ [61, "PullRequestReviewGuides"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);
@@ -58,6 +59,7 @@ layer("055_OrchestrationV2", (it) => {
{ migration_id: 58, name: "WebhookRelayDeliveries" },
{ migration_id: 59, name: "McpAppModelContext" },
{ migration_id: 60, name: "ThreadSnapshotWindowIndexes" },
+ { migration_id: 61, name: "PullRequestReviewGuides" },
]);
const tables = yield* sql<{ readonly name: string }>`
diff --git a/apps/server/src/persistence/Migrations/061_PullRequestReviewGuides.ts b/apps/server/src/persistence/Migrations/061_PullRequestReviewGuides.ts
new file mode 100644
index 0000000000000..bf8476670204f
--- /dev/null
+++ b/apps/server/src/persistence/Migrations/061_PullRequestReviewGuides.ts
@@ -0,0 +1,19 @@
+import * as Effect from "effect/Effect";
+import * as SqlClient from "effect/sql/SqlClient";
+
+export default Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+
+ // The latest guide per pull request, keyed by its host-level identity so every project that
+ // checks out the same repository shares one. Guides cost a model run, so a push leaves the old
+ // one in place for the page to call stale rather than deleting it.
+ yield* sql`
+ CREATE TABLE IF NOT EXISTS pull_request_review_guides (
+ host TEXT NOT NULL,
+ repository TEXT NOT NULL,
+ number INTEGER NOT NULL,
+ guide_json TEXT NOT NULL,
+ PRIMARY KEY (host, repository, number)
+ ) WITHOUT ROWID
+ `;
+});
diff --git a/apps/server/src/persistence/PullRequestReviewGuides.ts b/apps/server/src/persistence/PullRequestReviewGuides.ts
new file mode 100644
index 0000000000000..dd8cd0708046d
--- /dev/null
+++ b/apps/server/src/persistence/PullRequestReviewGuides.ts
@@ -0,0 +1,84 @@
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+import * as SqlClient from "effect/sql/SqlClient";
+import * as SqlSchema from "effect/sql/SqlSchema";
+
+import { PullRequestReviewGuide, ThreadPullRequestKey } from "@t3tools/contracts";
+
+import {
+ PersistenceDecodeError,
+ PersistenceSqlError,
+ type PullRequestReviewGuideRepositoryError,
+} from "./Errors.ts";
+
+const GuideRow = Schema.Struct({
+ guide: Schema.fromJsonString(PullRequestReviewGuide),
+});
+
+/** The latest review guide per pull request, keyed by host-level identity. */
+export class PullRequestReviewGuideRepository extends Context.Service<
+ PullRequestReviewGuideRepository,
+ {
+ readonly get: (
+ key: ThreadPullRequestKey,
+ ) => Effect.Effect<
+ Option.Option,
+ PullRequestReviewGuideRepositoryError
+ >;
+ readonly put: (
+ key: ThreadPullRequestKey,
+ guide: PullRequestReviewGuide,
+ ) => Effect.Effect;
+ }
+>()("t3/persistence/PullRequestReviewGuides/PullRequestReviewGuideRepository") {}
+
+const make = Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+
+ const findGuide = SqlSchema.findOneOption({
+ Request: ThreadPullRequestKey,
+ Result: GuideRow,
+ execute: ({ host, repository, number }) =>
+ sql`
+ SELECT guide_json AS "guide"
+ FROM pull_request_review_guides
+ WHERE host = ${host} AND repository = ${repository} AND number = ${number}
+ `,
+ });
+ const encodeGuide = Schema.encodeEffect(Schema.fromJsonString(PullRequestReviewGuide));
+
+ return PullRequestReviewGuideRepository.of({
+ get: (key) =>
+ findGuide(key).pipe(
+ Effect.map(Option.map((row) => row.guide)),
+ Effect.mapError((cause) =>
+ Schema.isSchemaError(cause)
+ ? PersistenceDecodeError.fromSchemaError("PullRequestReviewGuide", cause)
+ : new PersistenceSqlError({ operation: "getPullRequestReviewGuide", cause }),
+ ),
+ ),
+ put: (key, guide) =>
+ encodeGuide(guide).pipe(
+ Effect.mapError((cause) =>
+ PersistenceDecodeError.fromSchemaError("PullRequestReviewGuide", cause),
+ ),
+ Effect.flatMap((guideJson) =>
+ sql`
+ INSERT INTO pull_request_review_guides (host, repository, number, guide_json)
+ VALUES (${key.host}, ${key.repository}, ${key.number}, ${guideJson})
+ ON CONFLICT (host, repository, number) DO UPDATE SET guide_json = excluded.guide_json
+ `.pipe(
+ Effect.mapError(
+ (cause) => new PersistenceSqlError({ operation: "putPullRequestReviewGuide", cause }),
+ ),
+ ),
+ ),
+ Effect.asVoid,
+ ),
+ });
+});
+
+export const layer = Layer.effect(PullRequestReviewGuideRepository, make);
diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
index 675d5f5db0215..5881fdfda408b 100644
--- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
+++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts
@@ -41,6 +41,7 @@ describe("V2 preview upgrade", () => {
[58, "WebhookRelayDeliveries"],
[59, "McpAppModelContext"],
[60, "ThreadSnapshotWindowIndexes"],
+ [61, "PullRequestReviewGuides"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);
assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports);
@@ -124,6 +125,7 @@ describe("V2 preview upgrade", () => {
[58, "WebhookRelayDeliveries"],
[59, "McpAppModelContext"],
[60, "ThreadSnapshotWindowIndexes"],
+ [61, "PullRequestReviewGuides"],
]);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
);
diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts
index 393c858a69313..2ff9246018b97 100644
--- a/apps/server/src/project/ManagedProjectFolders.test.ts
+++ b/apps/server/src/project/ManagedProjectFolders.test.ts
@@ -23,6 +23,7 @@ import * as GitWorkflow from "../git/GitWorkflowService.ts";
import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as ProcessRunner from "../processRunner.ts";
+import * as ServerSettings from "../serverSettings.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
@@ -87,6 +88,7 @@ const layer = (baseDir: string, options?: HarnessOptions) =>
Layer.provideMerge(layerGitWorkflow(options?.vcsProcess)),
Layer.provideMerge(options?.git ?? layerRealGit),
Layer.provideMerge(SqlitePersistence.layerMemory),
+ Layer.provideMerge(ServerSettings.layerTest()),
Layer.provideMerge(ServerConfig.layerTest(baseDir, baseDir)),
Layer.provideMerge(NodeServices.layer),
);
diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts
index a64896b637f8f..acbd382abb8d1 100644
--- a/apps/server/src/project/ProjectService.deletion.test.ts
+++ b/apps/server/src/project/ProjectService.deletion.test.ts
@@ -28,6 +28,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
+import * as ServerSettings from "../serverSettings.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
@@ -63,6 +64,7 @@ const layerServices = Layer.mergeAll(
),
),
),
+ Layer.provideMerge(ServerSettings.layerTest()),
);
const layerDatabase = SqlitePersistence.layerMemory.pipe(
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "project-deletion-test-" })),
@@ -494,3 +496,31 @@ it.effect("deletes a project without force once its imported threads were delete
}).pipe(Effect.provide(layerServices));
}).pipe(Effect.provide(layerDatabase)),
);
+
+it.effect(
+ "clears a deleted project's settings overrides so a machine-disabled instance cannot stay alive",
+ () =>
+ Effect.gen(function* () {
+ const projectId = ProjectId.make("project:overrides-cleanup");
+ const instanceId = ProviderInstanceId.make("codex_work");
+ yield* seedProject(projectId);
+
+ yield* Effect.gen(function* () {
+ const service = yield* ProjectService.make;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
+ yield* serverSettings.updateSettings({
+ projectSettingsOverrides: {
+ [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
+ },
+ });
+
+ yield* service.delete({
+ commandId: CommandId.make("command:overrides-cleanup:project-delete"),
+ projectId,
+ });
+
+ const settingsAfterDelete = yield* serverSettings.getSettings;
+ assert.isUndefined(settingsAfterDelete.projectSettingsOverrides[projectId]);
+ }).pipe(Effect.provide(layerServices));
+ }).pipe(Effect.provide(layerDatabase)),
+);
diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts
index 09d4f82297718..844a20c168491 100644
--- a/apps/server/src/project/ProjectService.test.ts
+++ b/apps/server/src/project/ProjectService.test.ts
@@ -19,6 +19,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
+import * as ServerSettings from "../serverSettings.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
@@ -60,6 +61,7 @@ const layerTestFor = (
Layer.provideMerge(layerWorkspacePaths),
Layer.provideMerge(projectMetadataLayer),
Layer.provideMerge(SqlitePersistence.layerMemory),
+ Layer.provideMerge(ServerSettings.layerTest()),
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-test-" })),
Layer.provide(NodeServices.layer),
);
@@ -79,6 +81,7 @@ const layerProjectServiceDependencies = Layer.mergeAll(
Layer.provideMerge(layerWorkspacePaths),
Layer.provideMerge(layerMetadata),
Layer.provideMerge(SqlitePersistence.layerMemory),
+ Layer.provideMerge(ServerSettings.layerTest()),
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "project-service-race-" })),
Layer.provide(NodeServices.layer),
);
diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts
index f37b4e383061f..eef94210b3d21 100644
--- a/apps/server/src/project/ProjectService.ts
+++ b/apps/server/src/project/ProjectService.ts
@@ -30,6 +30,7 @@ import {
import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts";
+import * as ServerSettings from "../serverSettings.ts";
import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
@@ -153,6 +154,7 @@ export const make = Effect.gen(function* () {
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter;
const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
// Commands for one project run in order. Commands that claim a workspace root
// also hold that root, so two projects cannot both claim it.
const projectLocks = yield* KeyedLock.make();
@@ -486,6 +488,22 @@ export const make = Effect.gen(function* () {
);
});
+ // A machine-disabled provider instance that a deleted project's override
+ // once turned on must not stay probed and alive forever. Nothing else
+ // removes the entry, so deletion is where it goes; best-effort because a
+ // settings write failure should not block the deletion it is cleaning up
+ // after, and a server crash before a replay never revisits this project.
+ const clearProjectSettingsOverrides = (projectId: ProjectId) =>
+ serverSettings.updateSettings({ projectSettingsOverrides: { [projectId]: null } }).pipe(
+ Effect.asVoid,
+ Effect.catchCause((cause) =>
+ Effect.logWarning(
+ `Failed to clear settings overrides for deleted project ${projectId}.`,
+ cause,
+ ),
+ ),
+ );
+
const deleteProject: ProjectService["Service"]["delete"] = Effect.fn("ProjectService.delete")(
function* (input) {
const { projectId } = input;
@@ -496,10 +514,14 @@ export const make = Effect.gen(function* () {
return yield* new ProjectNotFoundError({ projectId });
}
- if (existing.value.deletedAt === null) {
+ const isFirstDelete = existing.value.deletedAt === null;
+ if (isFirstDelete) {
yield* deleteChildThreads(input);
}
yield* commit({ type: "project.delete", commandId: input.commandId, projectId });
+ if (isFirstDelete) {
+ yield* clearProjectSettingsOverrides(projectId);
+ }
yield* projectEnrichment.invalidate([existing.value.workspaceRoot]);
return yield* readCommitted(projectId);
},
diff --git a/apps/server/src/provider/CodexProvider.catalog.test.ts b/apps/server/src/provider/CodexProvider.catalog.test.ts
new file mode 100644
index 0000000000000..d862ac8bc8f47
--- /dev/null
+++ b/apps/server/src/provider/CodexProvider.catalog.test.ts
@@ -0,0 +1,111 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, it } from "@effect/vitest";
+import { CodexSettings } from "@t3tools/contracts";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+import * as Schema from "effect/Schema";
+
+import { checkCodexProviderStatus } from "./CodexProvider.ts";
+
+/**
+ * Stand-in for Codex 0.162's app-server catalog behavior:
+ * - `model/list` answers from `CODEX_HOME/models_cache.json` while it is fresh.
+ * - A startup refresh runs before `initialize`, so it uses the originator set
+ * at spawn (the override env var, else the plain CLI's `codex_cli_rs`).
+ * - The backend grants Ultrafast only to entitled accounts on app originators.
+ */
+const FAKE_CODEX_APP_SERVER = String.raw`#!/usr/bin/env node
+const fs = require("node:fs");
+const path = require("node:path");
+const readline = require("node:readline");
+const cachePath = path.join(process.env.CODEX_HOME, "models_cache.json");
+const originator = process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE ?? "codex_cli_rs";
+const ultrafast = process.env.FAKE_ULTRAFAST_ENTITLED === "1" && originator !== "codex_cli_rs";
+const model = (tiers) => ({
+ id: "gpt-6.1-sol", model: "gpt-6.1-sol", displayName: "GPT-6.1 Sol", description: "",
+ hidden: false, isDefault: true, defaultReasoningEffort: "high",
+ supportedReasoningEfforts: [{ reasoningEffort: "high", description: "" }],
+ serviceTiers: tiers.map((id) => ({ id, name: id, description: "" })),
+});
+setTimeout(() => {
+ const tiers = ultrafast ? ["priority", "ultrafast"] : ["priority"];
+ fs.writeFileSync(cachePath, JSON.stringify({ fetched_at: new Date().toISOString(), models: [model(tiers)] }));
+}, 300);
+const write = (message) => process.stdout.write(JSON.stringify(message) + "\n");
+readline.createInterface({ input: process.stdin }).on("line", (line) => {
+ const { id, method } = JSON.parse(line);
+ if (id === undefined) return;
+ if (method === "initialize") {
+ write({ id, result: { userAgent: "codex/0.162.0", codexHome: process.env.CODEX_HOME, platformFamily: "unix", platformOs: "macos" } });
+ } else if (method === "account/read") {
+ write({ id, result: { account: { type: "chatgpt", email: "test@example.com", planType: "promax" }, requiresOpenaiAuth: false } });
+ } else if (method === "skills/list") {
+ write({ id, result: { data: [] } });
+ } else if (method === "model/list") {
+ write({ id, result: { data: JSON.parse(fs.readFileSync(cachePath, "utf8")).models, nextCursor: null } });
+ } else {
+ write({ id, error: { code: -32601, message: "unsupported" } });
+ }
+});
+`;
+
+const decodeCodexSettings = Schema.decodeSync(CodexSettings);
+
+const solServiceTiers = Effect.fn("solServiceTiers")(function* (ultrafastEntitled: boolean) {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-catalog-" });
+ const binaryPath = path.join(dir, "codex");
+ yield* fs.writeFileString(binaryPath, FAKE_CODEX_APP_SERVER);
+ yield* fs.chmod(binaryPath, 0o755);
+ // A fresh cache written by another client: the plain CLI's (no Ultrafast)
+ // when entitled, or a stale entitled catalog when the entitlement is gone.
+ yield* fs.writeFileString(
+ path.join(dir, "models_cache.json"),
+ JSON.stringify({
+ fetched_at: DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { minutes: 1 })),
+ models: [
+ {
+ id: "gpt-6.1-sol",
+ model: "gpt-6.1-sol",
+ displayName: "GPT-6.1 Sol",
+ description: "",
+ hidden: false,
+ isDefault: true,
+ defaultReasoningEffort: "high",
+ supportedReasoningEfforts: [{ reasoningEffort: "high", description: "" }],
+ serviceTiers: (ultrafastEntitled ? ["priority"] : ["priority", "ultrafast"]).map(
+ (id) => ({ id, name: id, description: "" }),
+ ),
+ },
+ ],
+ }),
+ );
+
+ const { CODEX_INTERNAL_ORIGINATOR_OVERRIDE: _ambient, ...environment } = process.env;
+ const provider = yield* checkCodexProviderStatus(
+ decodeCodexSettings({ binaryPath, homePath: dir }),
+ undefined,
+ { ...environment, FAKE_ULTRAFAST_ENTITLED: ultrafastEntitled ? "1" : "0" },
+ );
+ const serviceTier = provider.models
+ .find((model) => model.slug === "gpt-6.1-sol")
+ ?.capabilities?.optionDescriptors?.find((descriptor) => descriptor.id === "serviceTier");
+ return serviceTier?.type === "select" ? serviceTier.options.map((option) => option.id) : [];
+});
+
+it.layer(NodeServices.layer, { excludeTestServices: true })("Codex catalog discovery", (it) => {
+ it.effect("keeps Ultrafast when another Codex client wrote the shared catalog cache", () =>
+ Effect.gen(function* () {
+ assert.deepStrictEqual(yield* solServiceTiers(true), ["default", "priority", "ultrafast"]);
+ }).pipe(Effect.scoped),
+ );
+
+ it.effect("drops Ultrafast once the account is no longer entitled to it", () =>
+ Effect.gen(function* () {
+ assert.deepStrictEqual(yield* solServiceTiers(false), ["default", "priority"]);
+ }).pipe(Effect.scoped),
+ );
+});
diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts
index 5109bb840f561..9ed238bf5d2ce 100644
--- a/apps/server/src/provider/CodexProvider.ts
+++ b/apps/server/src/provider/CodexProvider.ts
@@ -1,10 +1,13 @@
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
+import * as Path from "effect/Path";
import * as Result from "effect/Result";
import * as Schema from "effect/Schema";
+import * as Schedule from "effect/Schedule";
import * as Scope from "effect/Scope";
import * as Types from "effect/Types";
import * as ChildProcess from "effect/process/ChildProcess";
@@ -66,6 +69,9 @@ type CodexRateLimitsProbe =
| { readonly failure: string };
const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;
+const CODEX_CATALOG_REFRESH_TIMEOUT = "3 seconds" as const;
+const CODEX_CATALOG_REFRESH_POLL = "100 millis" as const;
+const CODEX_CLIENT_NAME = "T3 Code";
const CODEX_PRESENTATION = {
displayName: "Codex",
@@ -347,11 +353,81 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* (
return models;
});
+const decodeCodexModelsCacheStamp = Schema.decodeUnknownOption(
+ Schema.fromJsonString(Schema.Struct({ fetched_at: Schema.String })),
+);
+
+/** When Codex last wrote `models_cache.json`, in epoch millis; NaN when unreadable. */
+const readCodexModelsCacheFetchedAt = Effect.fn("readCodexModelsCacheFetchedAt")(function* (
+ codexHome: string,
+) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const text = yield* fileSystem
+ .readFileString(path.join(codexHome, "models_cache.json"))
+ .pipe(Effect.option);
+ return Option.flatMap(text, decodeCodexModelsCacheStamp).pipe(
+ Option.flatMap((stamp) => DateTime.make(stamp.fetched_at)),
+ Option.match({ onNone: () => Number.NaN, onSome: DateTime.toEpochMillis }),
+ );
+});
+
+/**
+ * Lists the ChatGPT model catalog as fetched for T3 Code.
+ *
+ * `model/list` serves `CODEX_HOME/models_cache.json` while it is under five
+ * minutes old. Every Codex client on the machine shares that file, and the
+ * backend tailors the catalog to each client's originator: the plain CLI does
+ * not get Sol Ultrafast. So the first answer can be another client's catalog.
+ * App-server refreshes the catalog online at startup, so wait until a write
+ * newer than this process's spawn lands, then list again. If no refresh
+ * lands in time, keep the first answer. Another client writing in the same
+ * few hundred milliseconds can still win; the next probe corrects it.
+ */
+const requestCodexChatGptModels = Effect.fn("requestCodexChatGptModels")(function* (input: {
+ readonly client: CodexClient.CodexAppServerClient["Service"];
+ readonly codexHome: string;
+ readonly spawnedAtMs: number;
+}) {
+ const models = yield* requestAllCodexModels(input.client);
+ const refreshed = yield* readCodexModelsCacheFetchedAt(input.codexHome).pipe(
+ Effect.repeat({
+ schedule: Schedule.spaced(CODEX_CATALOG_REFRESH_POLL),
+ until: (fetchedAtMs) => fetchedAtMs >= input.spawnedAtMs,
+ }),
+ Effect.timeoutOption(CODEX_CATALOG_REFRESH_TIMEOUT),
+ );
+ if (Option.isNone(refreshed)) {
+ yield* Effect.logWarning(
+ "Codex did not refresh its model catalog in time; using the cached catalog.",
+ );
+ return models;
+ }
+ return yield* requestAllCodexModels(input.client);
+});
+
+/**
+ * Environment for every `codex app-server` T3 Code spawns.
+ *
+ * Codex sends its client name as the `originator` header, and the backend
+ * tailors the model catalog to it. App-server starts its catalog refresh
+ * before `initialize` names the client, so that first fetch goes out as the
+ * plain CLI, misses tiers like Sol Ultrafast, and overwrites the shared
+ * `models_cache.json`. Naming the originator at spawn closes that race.
+ * A value the user already set wins.
+ */
+export const codexAppServerEnvironment = (
+ environment: NodeJS.ProcessEnv | undefined,
+): NodeJS.ProcessEnv => ({
+ CODEX_INTERNAL_ORIGINATOR_OVERRIDE: CODEX_CLIENT_NAME,
+ ...environment,
+});
+
export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
return {
clientInfo: {
- name: "T3 Code",
- title: "T3 Code",
+ name: CODEX_CLIENT_NAME,
+ title: CODEX_CLIENT_NAME,
version: packageJson.version,
},
capabilities: {
@@ -382,7 +458,7 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu
: undefined;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const environment = {
- ...input.environment,
+ ...codexAppServerEnvironment(input.environment),
...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
};
const spawnCommand = yield* resolveSpawnCommand(
@@ -427,6 +503,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
readonly environment?: NodeJS.ProcessEnv;
readonly skipNativeUsage?: boolean;
}) {
+ const spawnedAtMs = DateTime.toEpochMillis(yield* DateTime.now);
const { client, initialize } = yield* withCodexAppServerClient(input);
// Extract the version string after the first '/' in userAgent, up to the next space or the end
@@ -448,7 +525,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
client.request("skills/list", {
cwds: [input.cwd],
}),
- requestAllCodexModels(client),
+ accountResponse.account?.type === "chatgpt"
+ ? requestCodexChatGptModels({ client, codexHome: initialize.codexHome, spawnedAtMs })
+ : requestAllCodexModels(client),
// Usage is an enrichment: a failure or a slow answer degrades to "no
// usage this probe" rather than costing the account and models.
input.skipNativeUsage
@@ -589,14 +668,14 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
}) => Effect.Effect<
CodexAppServerProviderSnapshot,
CodexErrors.CodexAppServerError,
- ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
+ ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | Scope.Scope
> = probeCodexAppServerProvider,
environment?: NodeJS.ProcessEnv,
managedAuth?: ServerProvider["auth"],
): Effect.fn.Return<
ServerProviderDraft,
ServerSettingsError,
- ChildProcessSpawner.ChildProcessSpawner
+ ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
> {
const resolvedEnvironment = environment ?? process.env;
const checkedAt = DateTime.formatIso(yield* DateTime.now);
diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts
index 8aa546d103e12..09d5413f39d59 100644
--- a/apps/server/src/provider/Drivers/CodexDriver.ts
+++ b/apps/server/src/provider/Drivers/CodexDriver.ts
@@ -235,6 +235,8 @@ export const CodexDriver: ProviderDriver>({
diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts
index 92c461390aea1..374f177fd9cae 100644
--- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts
+++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts
@@ -1,5 +1,7 @@
import { ProviderDriverKind, TextGenerationError, type CodexSettings } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
import * as Stream from "effect/Stream";
import * as Option from "effect/Option";
import { ChildProcessSpawner } from "effect/process";
@@ -31,6 +33,8 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu
const { instanceId, enabled, displayName, accentColor, config } = input;
const http = yield* HttpClient.HttpClient;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
const host = yield* ProviderHost.ProviderHost;
const runtime = yield* makeCodexManagedRuntime({
instanceId,
@@ -190,7 +194,11 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu
),
Effect.scoped,
);
- }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));
+ }).pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ Effect.provideService(FileSystem.FileSystem, fileSystem),
+ Effect.provideService(Path.Path, path),
+ );
const snapshot = yield* makeManagedServerProvider({
resolveMaintenance: () => Effect.succeed({ provider: DRIVER, packageName: null, update: null }),
getSettings: host.settings.get,
@@ -260,6 +268,8 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu
protect("generateBranchName", nativeGeneration.generateBranchName(value)),
generateThreadTitle: (value) =>
protect("generateThreadTitle", nativeGeneration.generateThreadTitle(value)),
+ generateReviewGuide: (value) =>
+ protect("generateReviewGuide", nativeGeneration.generateReviewGuide(value)),
};
return {
instanceId,
diff --git a/apps/server/src/provider/ProjectSettingsOverrideSweep.test.ts b/apps/server/src/provider/ProjectSettingsOverrideSweep.test.ts
new file mode 100644
index 0000000000000..01fd35c0c598d
--- /dev/null
+++ b/apps/server/src/provider/ProjectSettingsOverrideSweep.test.ts
@@ -0,0 +1,46 @@
+import { DEFAULT_SERVER_SETTINGS, ProjectId, type ServerSettings } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { findOrphanedProjectIds } from "./ProjectSettingsOverrideSweep.ts";
+
+const liveProjectId = ProjectId.make("project-live");
+const deletedProjectId = ProjectId.make("project-deleted");
+
+function settingsWith(overrides: Partial): ServerSettings {
+ return { ...DEFAULT_SERVER_SETTINGS, ...overrides };
+}
+
+describe("findOrphanedProjectIds", () => {
+ it("reports a project override whose project is no longer live", () => {
+ const settings = settingsWith({
+ projectSettingsOverrides: {
+ [liveProjectId]: { providerInstances: {} },
+ [deletedProjectId]: { providerInstances: {} },
+ },
+ });
+
+ const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId]));
+
+ expect(orphaned).toEqual([deletedProjectId]);
+ });
+
+ it("reports nothing when every override's project is still live", () => {
+ const settings = settingsWith({
+ projectSettingsOverrides: {
+ [liveProjectId]: { providerInstances: {} },
+ },
+ });
+
+ const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId]));
+
+ expect(orphaned).toEqual([]);
+ });
+
+ it("reports nothing when there are no overrides at all", () => {
+ const settings = settingsWith({ projectSettingsOverrides: {} });
+
+ const orphaned = findOrphanedProjectIds(settings, new Set([liveProjectId]));
+
+ expect(orphaned).toEqual([]);
+ });
+});
diff --git a/apps/server/src/provider/ProjectSettingsOverrideSweep.ts b/apps/server/src/provider/ProjectSettingsOverrideSweep.ts
new file mode 100644
index 0000000000000..05bad7f0a18f7
--- /dev/null
+++ b/apps/server/src/provider/ProjectSettingsOverrideSweep.ts
@@ -0,0 +1,61 @@
+/**
+ * Removes `projectSettingsOverrides` entries
+ * whose project no longer exists.
+ *
+ * `ProjectService.delete` clears a project's entry as part of deleting the
+ * project, but that only covers deletions a server new enough to run that
+ * code actually processes. A project deleted by an older server, or one
+ * whose cleanup write failed, leaves its override behind:
+ * `ProviderInstanceRegistryHydration` folds every project's override in
+ * unconditionally, so a machine-disabled provider instance the override once
+ * turned on stays probed and alive forever, with no UI left to turn it off.
+ *
+ * This sweep runs once at boot and removes overrides left over from projects
+ * that no longer exist. The settings watcher's normal reconcile picks up
+ * whatever it clears, so the registry does not need its own live-project
+ * check on every settings change.
+ *
+ * @module provider/ProjectSettingsOverrideSweep
+ */
+import { type ProjectId, type ServerSettings } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+
+import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
+import * as Settings from "../serverSettings.ts";
+
+/** Pure so the orphan rule can be tested without layers. */
+export const findOrphanedProjectIds = (
+ settings: Pick,
+ liveProjectIds: ReadonlySet,
+): ReadonlyArray