authorgravatar for 130890337+eimexdev@users.noreply.github.comeimexdev <130890337+eimexdev@users.noreply.github.com> 2026-10-04 14:49:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 18:49:56-03:00
logefecd3cf8bcec3d1891b5f5a27dc2f6d797c6448
treebcb58565e58df2dfc5979eeac2970479ae87e963
parent2188bdd8b5c0251f40daebdb66647dd0150cf076
signature Signed by PGP key B5690EEEBB952194

fix(server): Claude threads no longer get stuck after background commands (#15770)


9 files changed, 291 insertions(+), 16 deletions(-)

apps/server/src/claudeModelOptions.ts+1-1
...@@ -37,7 +37,7 @@ export function compileClaudeModelSelection(...@@ -37,7 +37,7 @@ export function compileClaudeModelSelection(
37 const resolvedEffort = resolveClaudeCatalogEffort(catalog, selection.model, rawEffort);37 const resolvedEffort = resolveClaudeCatalogEffort(catalog, selection.model, rawEffort);
38 const effort = normalizeClaudeCatalogEffort(catalog, resolvedEffort, selection.model);38 const effort = normalizeClaudeCatalogEffort(catalog, resolvedEffort, selection.model);
39 const fastMode = supportsBoolean("fastMode")39 const fastMode = supportsBoolean("fastMode")
40 ? getModelSelectionBooleanOptionValue(selection, "fastMode")40 ? (getModelSelectionBooleanOptionValue(selection, "fastMode") ?? false)
41 : undefined;41 : undefined;
42 const thinking = supportsBoolean("thinking")42 const thinking = supportsBoolean("thinking")
43 ? getModelSelectionBooleanOptionValue(selection, "thinking")43 ? getModelSelectionBooleanOptionValue(selection, "thinking")
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+76-1
...@@ -126,6 +126,7 @@ function makeClaudeTestTurnInput(input: {...@@ -126,6 +126,7 @@ function makeClaudeTestTurnInput(input: {
126 readonly text: string;126 readonly text: string;
127 readonly attachments: ProviderAdapterV2TurnInput["message"]["attachments"];127 readonly attachments: ProviderAdapterV2TurnInput["message"]["attachments"];
128 readonly providerTurnOrdinal?: number;128 readonly providerTurnOrdinal?: number;
129 readonly nativeThreadHasTurns?: boolean;
129 readonly messageCreatedBy?: ProviderAdapterV2TurnInput["message"]["createdBy"];130 readonly messageCreatedBy?: ProviderAdapterV2TurnInput["message"]["createdBy"];
130 readonly messageCreationSource?: ProviderAdapterV2TurnInput["message"]["creationSource"];131 readonly messageCreationSource?: ProviderAdapterV2TurnInput["message"]["creationSource"];
131 readonly modelSelection?: ModelSelection;132 readonly modelSelection?: ModelSelection;
...@@ -137,6 +138,9 @@ function makeClaudeTestTurnInput(input: {...@@ -137,6 +138,9 @@ function makeClaudeTestTurnInput(input: {
137 runId: RunId.make(`run-${input.attemptId}`),138 runId: RunId.make(`run-${input.attemptId}`),
138 runOrdinal: 1,139 runOrdinal: 1,
139 providerTurnOrdinal: input.providerTurnOrdinal ?? 1,140 providerTurnOrdinal: input.providerTurnOrdinal ?? 1,
141 ...(input.nativeThreadHasTurns === undefined
142 ? {}
143 : { nativeThreadHasTurns: input.nativeThreadHasTurns }),
140 attemptId: input.attemptId,144 attemptId: input.attemptId,
141 rootNodeId: NodeId.make(`node-${input.attemptId}`),145 rootNodeId: NodeId.make(`node-${input.attemptId}`),
142 providerThread: input.providerThread,146 providerThread: input.providerThread,
...@@ -1783,7 +1787,7 @@ describe("ClaudeAdapterV2 native fork", () => {...@@ -1783,7 +1787,7 @@ describe("ClaudeAdapterV2 native fork", () => {
1783});1787});
17841788
1785describe("ClaudeAdapterV2 native session identity", () => {1789describe("ClaudeAdapterV2 native session identity", () => {
1786 const openTurnWithOrdinal = (providerTurnOrdinal: number) =>1790 const openTurnWithOrdinal = (providerTurnOrdinal: number, nativeThreadHasTurns?: boolean) =>
1787 Effect.scoped(1791 Effect.scoped(
1788 Effect.gen(function* () {1792 Effect.gen(function* () {
1789 const fileSystem = yield* FileSystem.FileSystem;1793 const fileSystem = yield* FileSystem.FileSystem;
...@@ -1842,6 +1846,7 @@ describe("ClaudeAdapterV2 native session identity", () => {...@@ -1842,6 +1846,7 @@ describe("ClaudeAdapterV2 native session identity", () => {
1842 text: "Respond with identity ok",1846 text: "Respond with identity ok",
1843 attachments: [],1847 attachments: [],
1844 providerTurnOrdinal,1848 providerTurnOrdinal,
1849 ...(nativeThreadHasTurns === undefined ? {} : { nativeThreadHasTurns }),
1845 }),1850 }),
1846 );1851 );
1847 return openedQueries;1852 return openedQueries;
...@@ -1867,6 +1872,14 @@ describe("ClaudeAdapterV2 native session identity", () => {...@@ -1867,6 +1872,14 @@ describe("ClaudeAdapterV2 native session identity", () => {
1867 assert.equal(openedQueries[0]?.options.sessionId, undefined);1872 assert.equal(openedQueries[0]?.options.sessionId, undefined);
1868 }),1873 }),
1869 );1874 );
1875
1876 it.effect("creates a fresh native session despite earlier provider-thread turns", () =>
1877 Effect.gen(function* () {
1878 const openedQueries = yield* openTurnWithOrdinal(4, false);
1879 assert.equal(openedQueries[0]?.options.sessionId, "native-session-identity");
1880 assert.equal(openedQueries[0]?.options.resume, undefined);
1881 }),
1882 );
1870});1883});
18711884
1872const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));1885const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
...@@ -2182,6 +2195,68 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2182,6 +2195,68 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2182 });2195 });
2183 const makeWakeHarness = makeWakeHarnessWithOptions();2196 const makeWakeHarness = makeWakeHarnessWithOptions();
21842197
2198 it.effect(
2199 "reuses a background shell's query for omitted and explicit Normal, but blocks Fast",
2200 () =>
2201 Effect.scoped(
2202 Effect.gen(function* () {
2203 const harness = yield* makeWakeHarness;
2204 const now = yield* DateTime.now;
2205 const normal = {
2206 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
2207 model: "claude-opus-5-5",
2208 } satisfies ModelSelection;
2209 const turn = (ordinal: number, modelSelection: ModelSelection) =>
2210 makeClaudeTestTurnInput({
2211 threadId: harness.threadId,
2212 providerThread: harness.providerThread,
2213 now,
2214 attemptId: RunAttemptId.make(`attempt-normal-background:${ordinal}`),
2215 text: `Request ${ordinal}`,
2216 attachments: [],
2217 providerTurnOrdinal: ordinal,
2218 modelSelection,
2219 });
2220 yield* harness.runtime.startTurn(turn(1, normal));
2221 const originalOptions = harness.getOpenedOptions();
2222 yield* harness.offerAndWait(wakeTaskStarted);
2223 yield* harness.offerAndWait(turnOneResult);
2224 yield* Queue.take(harness.terminalReceipts);
2225 assert.isTrue(yield* harness.hasPendingBackgroundWork);
2226
2227 yield* harness.runtime.startTurn(
2228 turn(2, {
2229 ...normal,
2230 options: [{ id: "fastMode", value: false }],
2231 }),
2232 );
2233 assert.strictEqual(harness.getOpenedOptions(), originalOptions);
2234 assert.lengthOf(harness.offeredMessages, 2);
2235 yield* harness.offerAndWait(turnOneResult);
2236 yield* Queue.take(harness.terminalReceipts);
2237
2238 const refused = yield* harness.runtime
2239 .startTurn(
2240 turn(3, {
2241 ...normal,
2242 options: [{ id: "fastMode", value: true }],
2243 }),
2244 )
2245 .pipe(Effect.result);
2246 assert.equal(refused._tag, "Failure");
2247 if (refused._tag === "Failure") {
2248 assert.instanceOf(
2249 refused.failure.cause,
2250 ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError,
2251 );
2252 }
2253 assert.strictEqual(harness.getOpenedOptions(), originalOptions);
2254 assert.lengthOf(harness.offeredMessages, 2);
2255 assert.isTrue(yield* harness.hasPendingBackgroundWork);
2256 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
2257 ),
2258 );
2259
2185 it.effect.each(["completed", "interrupted"] as const)(2260 it.effect.each(["completed", "interrupted"] as const)(
2186 "projects Claude thinking blocks when %s",2261 "projects Claude thinking blocks when %s",
2187 (status) =>2262 (status) =>
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+6-5
...@@ -6969,11 +6969,12 @@ export function makeClaudeAdapterV2(...@@ -6969,11 +6969,12 @@ export function makeClaudeAdapterV2(
69696969
6970 const openedWithResume = (yield* Ref.get(openedNativeThreads)).has(nativeThreadId);6970 const openedWithResume = (yield* Ref.get(openedNativeThreads)).has(nativeThreadId);
6971 // openedNativeThreads is per session instance and is lost when the6971 // openedNativeThreads is per session instance and is lost when the
6972 // provider session is idle-released. A prior persisted provider turn6972 // provider session is idle-released. A prior turn on this native id
6973 // proves the native session already exists, so the query must resume6973 // requires resume; sessionId would fail with "already in use".
6974 // it; reopening with a fixed session id makes the CLI fail fast with6974 // A fresh-session fallback keeps provider-thread history but binds
6975 // "Session ID ... is already in use".6975 // a new native id, which must be created before it can be resumed.
6976 const hasPersistedProviderTurn = turnInput.providerTurnOrdinal > 1;6976 const hasPersistedProviderTurn =
6977 turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1;
6977 const shouldResume =6978 const shouldResume =
6978 resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn;6979 resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn;
6979 const queryOptions = makeClaudeQueryOptions({6980 const queryOptions = makeClaudeQueryOptions({
apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts+45-4
...@@ -41,7 +41,7 @@ const modelSelection = { instanceId, model: "test-model" };...@@ -41,7 +41,7 @@ const modelSelection = { instanceId, model: "test-model" };
41// Codex turns leave commands running, then the thread moves to another41// Codex turns leave commands running, then the thread moves to another
42// provider thread (a provider switch). Stop on the newer, settled run must42// provider thread (a provider switch). Stop on the newer, settled run must
43// reach both provider threads and end all of the Codex work.43// reach both provider threads and end all of the Codex work.
44it.effect("Stop reaches background work an earlier provider thread still runs", () =>44const stopEarlierBackgroundWork = (failedStart: boolean) =>
45 Effect.scoped(45 Effect.scoped(
46 Effect.gen(function* () {46 Effect.gen(function* () {
47 const cwd = yield* checkpointWorkspace("background-work-stop");47 const cwd = yield* checkpointWorkspace("background-work-stop");
...@@ -233,7 +233,10 @@ it.effect("Stop reaches background work an earlier provider thread still runs",...@@ -233,7 +233,10 @@ it.effect("Stop reaches background work an earlier provider thread still runs",
233 const settledRun = (input: {233 const settledRun = (input: {
234 readonly ordinal: number;234 readonly ordinal: number;
235 readonly providerThreadId: ProviderThreadId;235 readonly providerThreadId: ProviderThreadId;
236 readonly runningItem?: { readonly id: TurnItemId; readonly kind: "command" | "subagent" };236 readonly runningItem?: {
237 readonly id: TurnItemId;
238 readonly kind: "command" | "subagent";
239 };
237 }) => {240 }) => {
238 const runId = RunId.make(`run:${input.ordinal}`);241 const runId = RunId.make(`run:${input.ordinal}`);
239 const attemptId = RunAttemptId.make(`attempt:${input.ordinal}`);242 const attemptId = RunAttemptId.make(`attempt:${input.ordinal}`);
...@@ -407,11 +410,41 @@ it.effect("Stop reaches background work an earlier provider thread still runs",...@@ -407,11 +410,41 @@ it.effect("Stop reaches background work an earlier provider thread still runs",
407 ],410 ],
408 });411 });
409412
413 const failedRun = settledRun({ ordinal: 5, providerThreadId: otherProviderThreadId });
414 if (failedStart) {
415 yield* sink.write({
416 events: failedRun.events.flatMap((event): Array<OrchestrationV2DomainEvent> => {
417 switch (event.type) {
418 case "provider-turn.updated":
419 return [];
420 case "run.created":
421 return [{ ...event, payload: { ...event.payload, status: "failed" } }];
422 case "node.updated":
423 return [
424 {
425 ...event,
426 payload: { ...event.payload, status: "failed", providerTurnId: null },
427 },
428 ];
429 case "run-attempt.created":
430 return [
431 {
432 ...event,
433 payload: { ...event.payload, status: "failed", providerTurnId: null },
434 },
435 ];
436 default:
437 return [event];
438 }
439 }),
440 });
441 }
442
410 yield* orchestrator.dispatch({443 yield* orchestrator.dispatch({
411 type: "run.interrupt",444 type: "run.interrupt",
412 commandId: CommandId.make("stop-background-work"),445 commandId: CommandId.make("stop-background-work"),
413 threadId,446 threadId,
414 runId: latestRun.runId,447 runId: failedStart ? failedRun.runId : latestRun.runId,
415 });448 });
416 yield* worker.drain();449 yield* worker.drain();
417450
...@@ -442,5 +475,13 @@ it.effect("Stop reaches background work an earlier provider thread still runs",...@@ -442,5 +475,13 @@ it.effect("Stop reaches background work an earlier provider thread still runs",
442 ),475 ),
443 );476 );
444 }),477 }),
445 ),478 );
479
480it.effect("Stop reaches background work an earlier provider thread still runs", () =>
481 stopEarlierBackgroundWork(false),
482);
483
484it.effect(
485 "Stop reaches earlier background work after the newest run fails before provider start",
486 () => stopEarlierBackgroundWork(true),
446);487);
apps/server/src/orchestration-v2/Orchestrator.ts+13-5
...@@ -8040,11 +8040,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -8040,11 +8040,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
8040 activeProviderThreadId: projection.thread.activeProviderThreadId,8040 activeProviderThreadId: projection.thread.activeProviderThreadId,
8041 runs: projection.runs,8041 runs: projection.runs,
8042 }).length > 0;8042 }).length > 0;
8043 const providerTurn = projection.providerTurns.findLast(8043 // A failed start has no provider turn. Background work still belongs
8044 (candidate) =>8044 // to the provider thread, so Stop reaches its latest accepted turn.
8045 candidate.runAttemptId === run?.activeAttemptId &&8045 const providerTurn =
8046 (candidate.status === "running" || hasBackgroundWork),8046 projection.providerTurns.findLast(
8047 );8047 (candidate) =>
8048 candidate.runAttemptId === run?.activeAttemptId &&
8049 (candidate.status === "running" || hasBackgroundWork),
8050 ) ??
8051 (hasBackgroundWork
8052 ? projection.providerTurns.findLast(
8053 (candidate) => candidate.providerThreadId === run?.providerThreadId,
8054 )
8055 : undefined);
8048 if (run === undefined || rootNode === undefined || providerThread === undefined) {8056 if (run === undefined || rootNode === undefined || providerThread === undefined) {
8049 return yield* new OrchestratorDispatchError({8057 return yield* new OrchestratorDispatchError({
8050 commandId: command.commandId,8058 commandId: command.commandId,
apps/server/src/orchestration-v2/ProviderAdapter.ts+2
...@@ -398,6 +398,8 @@ export interface ProviderAdapterV2TurnInput {...@@ -398,6 +398,8 @@ export interface ProviderAdapterV2TurnInput {
398 readonly threadId: ThreadId;398 readonly threadId: ThreadId;
399 readonly runId: RunId;399 readonly runId: RunId;
400 readonly runOrdinal: number;400 readonly runOrdinal: number;
401 /** Whether the current native session has an accepted turn; omitted when unknown. */
402 readonly nativeThreadHasTurns?: boolean;
401 readonly providerTurnOrdinal: number;403 readonly providerTurnOrdinal: number;
402 readonly restartContinuationOfRunId?: RunId;404 readonly restartContinuationOfRunId?: RunId;
403 readonly attemptId: RunAttemptId;405 readonly attemptId: RunAttemptId;
apps/server/src/orchestration-v2/ProviderTurnStartService.ts+7
...@@ -1231,6 +1231,13 @@ export const layer: Layer.Layer<...@@ -1231,6 +1231,13 @@ export const layer: Layer.Layer<
1231 .filter((turn) => turn.providerThreadId === providerThread.id)1231 .filter((turn) => turn.providerThreadId === providerThread.id)
1232 .map((turn) => turn.ordinal),1232 .map((turn) => turn.ordinal),
1233 ) + 1,1233 ) + 1,
1234 // Legacy accepted attempts have no native id. They count only before
1235 // a replacement, while no accepted attempt records a native identity.
1236 nativeThreadHasTurns:
1237 nativeInputRunIds.size > 0 ||
1238 (legacyInputRunIds.size > 0 &&
1239 sameNativeThread &&
1240 !acceptedAttempts.some((source) => source.nativeThreadId !== undefined)),
1234 shouldStartProviderTurn: runControls.shouldStartProviderTurn,1241 shouldStartProviderTurn: runControls.shouldStartProviderTurn,
1235 shouldFinalizeRun: runControls.shouldFinalizeRun,1242 shouldFinalizeRun: runControls.shouldFinalizeRun,
1236 hasUnpairedRunInterruptRequest: runControls.hasUnpairedRunInterruptRequest,1243 hasUnpairedRunInterruptRequest: runControls.hasUnpairedRunInterruptRequest,
apps/server/src/orchestration-v2/RunExecutionService.ts+4
...@@ -507,6 +507,7 @@ export interface RunExecutionServiceV2StartRootRunInput {...@@ -507,6 +507,7 @@ export interface RunExecutionServiceV2StartRootRunInput {
507 readonly attempt: OrchestrationV2RunAttempt;507 readonly attempt: OrchestrationV2RunAttempt;
508 readonly attemptId: RunAttemptId;508 readonly attemptId: RunAttemptId;
509 readonly providerTurnOrdinal: number;509 readonly providerTurnOrdinal: number;
510 readonly nativeThreadHasTurns?: boolean;
510 readonly loadInheritedBackgroundTurnItems?: () => Effect.Effect<511 readonly loadInheritedBackgroundTurnItems?: () => Effect.Effect<
511 ReadonlyArray<InheritedBackgroundTurnItemRoute>,512 ReadonlyArray<InheritedBackgroundTurnItemRoute>,
512 unknown513 unknown
...@@ -1348,6 +1349,9 @@ export const layer: Layer.Layer<...@@ -1348,6 +1349,9 @@ export const layer: Layer.Layer<
1348 runId: input.run.id,1349 runId: input.run.id,
1349 runOrdinal: input.run.ordinal,1350 runOrdinal: input.run.ordinal,
1350 providerTurnOrdinal: input.providerTurnOrdinal,1351 providerTurnOrdinal: input.providerTurnOrdinal,
1352 ...(input.nativeThreadHasTurns === undefined
1353 ? {}
1354 : { nativeThreadHasTurns: input.nativeThreadHasTurns }),
1351 ...(input.run.restartContinuationOfRunId === undefined1355 ...(input.run.restartContinuationOfRunId === undefined
1352 ? {}1356 ? {}
1353 : {1357 : {
apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts+137
...@@ -85,6 +85,9 @@ interface CapturedTurn {...@@ -85,6 +85,9 @@ interface CapturedTurn {
85 readonly providerThreadId: ProviderThreadId;85 readonly providerThreadId: ProviderThreadId;
86 readonly text: string;86 readonly text: string;
87 readonly attachments: ReadonlyArray<ChatAttachment>;87 readonly attachments: ReadonlyArray<ChatAttachment>;
88 readonly nativeThreadId?: string | null;
89 readonly providerTurnOrdinal?: number;
90 readonly nativeThreadHasTurns?: boolean;
88}91}
8992
90function unimplemented(driver: ProviderDriverKind, detail: string) {93function unimplemented(driver: ProviderDriverKind, detail: string) {
...@@ -232,6 +235,11 @@ function makeTestAdapter(input: {...@@ -232,6 +235,11 @@ function makeTestAdapter(input: {
232 providerThreadId: turnInput.providerThread.id,235 providerThreadId: turnInput.providerThread.id,
233 text: turnInput.message.text,236 text: turnInput.message.text,
234 attachments: turnInput.message.attachments,237 attachments: turnInput.message.attachments,
238 nativeThreadId: turnInput.providerThread.nativeThreadRef?.nativeId ?? null,
239 providerTurnOrdinal: turnInput.providerTurnOrdinal,
240 ...(turnInput.nativeThreadHasTurns === undefined
241 ? {}
242 : { nativeThreadHasTurns: turnInput.nativeThreadHasTurns }),
235 },243 },
236 ]);244 ]);
237 if (245 if (
...@@ -1051,6 +1059,135 @@ describe("orchestration v2 provider switching", () => {...@@ -1051,6 +1059,135 @@ describe("orchestration v2 provider switching", () => {
1051 ),1059 ),
1052 );1060 );
10531061
1062 it.live.each(["native", "legacy"] as const)(
1063 "starts a replacement native thread as new after the resume fallback with %s attempts",
1064 (attempts) =>
1065 Effect.scoped(
1066 Effect.gen(function* () {
1067 const cwd = yield* checkpointWorkspace("resume-fallback-native-turns");
1068 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
1069 const failResumeOnce = yield* Ref.make(false);
1070 const generation = yield* Ref.make(0);
1071 const registry = ProviderAdapterRegistry.makeLayer([
1072 makeTestAdapter({
1073 instanceId: CLAUDE_MODEL_SELECTION.instanceId,
1074 driver: CLAUDE_DRIVER,
1075 capabilities: ClaudeProviderCapabilitiesV2,
1076 modelSelection: CLAUDE_MODEL_SELECTION,
1077 responseByRunOrdinal: {},
1078 capturedTurns,
1079 failResumeOnce,
1080 nativeThreadGeneration: generation,
1081 }),
1082 ]);
1083 yield* Effect.gen(function* () {
1084 const orchestrator = yield* Orchestrator.OrchestratorV2;
1085 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
1086 const runToCompletion = Effect.fn("runToCompletion")(function* (ordinal: number) {
1087 yield* orchestrator.dispatch({
1088 type: "message.dispatch",
1089 commandId: CommandId.make(`resume-fallback:${ordinal}`),
1090 threadId,
1091 messageId: MessageId.make(`resume-fallback:${ordinal}`),
1092 createdBy: "user",
1093 creationSource: "web",
1094 text: `Request ${ordinal}`,
1095 attachments: [],
1096 modelSelection: CLAUDE_MODEL_SELECTION,
1097 dispatchMode: { type: "start_immediately" },
1098 });
1099 yield* orchestrator.streamStoredEvents.pipe(
1100 Stream.filter(
1101 ({ event }) =>
1102 event.type === "run.updated" &&
1103 event.payload.ordinal === ordinal &&
1104 (event.payload.status === "completed" || event.payload.status === "failed"),
1105 ),
1106 Stream.runHead,
1107 );
1108 yield* worker.drain();
1109 assert.equal(
1110 (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status,
1111 "completed",
1112 );
1113 });
1114 yield* orchestrator.dispatch({
1115 type: "thread.create",
1116 commandId: CommandId.make("resume-fallback:create"),
1117 threadId,
1118 projectId,
1119 createdBy: "user",
1120 creationSource: "web",
1121 title: "Resume fallback",
1122 modelSelection: CLAUDE_MODEL_SELECTION,
1123 runtimeMode: "full-access",
1124 interactionMode: "default",
1125 branch: null,
1126 worktreePath: null,
1127 });
1128 yield* runToCompletion(1);
1129 // A detached session resumes on the next turn, as after a restart.
1130 const providerThread = (yield* orchestrator.getThreadProjection(
1131 threadId,
1132 )).providerThreads.at(0);
1133 if (providerThread === undefined || providerThread.providerSessionId === null) {
1134 return yield* Effect.die("Expected a provider session after the first turn.");
1135 }
1136 yield* orchestrator.dispatch({
1137 type: "provider-session.detach",
1138 commandId: CommandId.make("resume-fallback:detach"),
1139 threadId,
1140 providerSessionId: providerThread.providerSessionId,
1141 });
1142 yield* worker.drain();
1143 if (attempts === "legacy") {
1144 // Attempts persisted before native identity existed carry no native id.
1145 const existing = yield* orchestrator.getThreadProjection(threadId);
1146 yield* (yield* EventSink.EventSinkV2).write({
1147 events: existing.attempts.map(
1148 ({ nativeThreadId: _nativeThreadId, ...legacy }, index) => ({
1149 id: EventId.make(`resume-fallback-legacy-attempt:${index}`),
1150 type: "run-attempt.updated" as const,
1151 threadId,
1152 occurredAt: existing.thread.createdAt,
1153 payload: legacy,
1154 }),
1155 ),
1156 });
1157 }
1158 yield* Ref.set(failResumeOnce, true);
1159 yield* runToCompletion(2);
1160 yield* runToCompletion(3);
1161
1162 const turns = yield* Ref.get(capturedTurns);
1163 assert.equal(turns.length, 3);
1164 assert.equal(yield* Ref.get(generation), 2);
1165 // The fallback binds a new native thread to a provider thread that
1166 // already has a turn, so the provider must not resume it.
1167 assert.notEqual(turns[1]?.nativeThreadId, turns[0]?.nativeThreadId);
1168 assert.isAbove(turns[1]?.providerTurnOrdinal ?? 0, 1);
1169 assert.isFalse(turns[1]?.nativeThreadHasTurns);
1170 assert.equal(turns[2]?.nativeThreadId, turns[1]?.nativeThreadId);
1171 assert.isTrue(turns[2]?.nativeThreadHasTurns);
1172 }).pipe(
1173 Effect.provide(
1174 makeOrchestratorV2ReplayLayerWithRegistry(
1175 {
1176 name: "resume-fallback-native-turns",
1177 runtimePolicyOverride: {
1178 cwd,
1179 approvalPolicy: "never",
1180 sandboxPolicy: { type: "readOnly" },
1181 },
1182 },
1183 registry,
1184 ),
1185 ),
1186 );
1187 }),
1188 ),
1189 );
1190
1054 it.live.each(1191 it.live.each(
1055 (["failed", "interrupted"] as const).flatMap((status) =>1192 (["failed", "interrupted"] as const).flatMap((status) =>
1056 [false, true].flatMap((queued) =>1193 [false, true].flatMap((queued) =>