| author | |
| committer | |
| log | efecd3cf8bcec3d1891b5f5a27dc2f6d797c6448 |
| tree | bcb58565e58df2dfc5979eeac2970479ae87e963 |
| parent | 2188bdd8b5c0251f40daebdb66647dd0150cf076 |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 291 insertions(+), 16 deletions(-)
apps/server/src/claudeModelOptions.ts+1-1| ... | ... | @@ -37,7 +37,7 @@ export function compileClaudeModelSelection( |
| 37 | 37 | const resolvedEffort = resolveClaudeCatalogEffort(catalog, selection.model, rawEffort); |
| 38 | 38 | const effort = normalizeClaudeCatalogEffort(catalog, resolvedEffort, selection.model); |
| 39 | 39 | const fastMode = supportsBoolean("fastMode") |
| 40 | ? getModelSelectionBooleanOptionValue(selection, "fastMode") | |
| 40 | ? (getModelSelectionBooleanOptionValue(selection, "fastMode") ?? false) | |
| 41 | 41 | : undefined; |
| 42 | 42 | const thinking = supportsBoolean("thinking") |
| 43 | 43 | ? getModelSelectionBooleanOptionValue(selection, "thinking") |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+76-1| ... | ... | @@ -126,6 +126,7 @@ function makeClaudeTestTurnInput(input: { |
| 126 | 126 | readonly text: string; |
| 127 | 127 | readonly attachments: ProviderAdapterV2TurnInput["message"]["attachments"]; |
| 128 | 128 | readonly providerTurnOrdinal?: number; |
| 129 | readonly nativeThreadHasTurns?: boolean; | |
| 129 | 130 | readonly messageCreatedBy?: ProviderAdapterV2TurnInput["message"]["createdBy"]; |
| 130 | 131 | readonly messageCreationSource?: ProviderAdapterV2TurnInput["message"]["creationSource"]; |
| 131 | 132 | readonly modelSelection?: ModelSelection; |
| ... | ... | @@ -137,6 +138,9 @@ function makeClaudeTestTurnInput(input: { |
| 137 | 138 | runId: RunId.make(`run-${input.attemptId}`), |
| 138 | 139 | runOrdinal: 1, |
| 139 | 140 | providerTurnOrdinal: input.providerTurnOrdinal ?? 1, |
| 141 | ...(input.nativeThreadHasTurns === undefined | |
| 142 | ? {} | |
| 143 | : { nativeThreadHasTurns: input.nativeThreadHasTurns }), | |
| 140 | 144 | attemptId: input.attemptId, |
| 141 | 145 | rootNodeId: NodeId.make(`node-${input.attemptId}`), |
| 142 | 146 | providerThread: input.providerThread, |
| ... | ... | @@ -1783,7 +1787,7 @@ describe("ClaudeAdapterV2 native fork", () => { |
| 1783 | 1787 | }); |
| 1784 | 1788 | |
| 1785 | 1789 | describe("ClaudeAdapterV2 native session identity", () => { |
| 1786 | const openTurnWithOrdinal = (providerTurnOrdinal: number) => | |
| 1790 | const openTurnWithOrdinal = (providerTurnOrdinal: number, nativeThreadHasTurns?: boolean) => | |
| 1787 | 1791 | Effect.scoped( |
| 1788 | 1792 | Effect.gen(function* () { |
| 1789 | 1793 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -1842,6 +1846,7 @@ describe("ClaudeAdapterV2 native session identity", () => { |
| 1842 | 1846 | text: "Respond with identity ok", |
| 1843 | 1847 | attachments: [], |
| 1844 | 1848 | providerTurnOrdinal, |
| 1849 | ...(nativeThreadHasTurns === undefined ? {} : { nativeThreadHasTurns }), | |
| 1845 | 1850 | }), |
| 1846 | 1851 | ); |
| 1847 | 1852 | return openedQueries; |
| ... | ... | @@ -1867,6 +1872,14 @@ describe("ClaudeAdapterV2 native session identity", () => { |
| 1867 | 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 | }); |
| 1871 | 1884 | |
| 1872 | 1885 | const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| ... | ... | @@ -2182,6 +2195,68 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2182 | 2195 | }); |
| 2183 | 2196 | const makeWakeHarness = makeWakeHarnessWithOptions(); |
| 2184 | 2197 | |
| 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 | 2260 | it.effect.each(["completed", "interrupted"] as const)( |
| 2186 | 2261 | "projects Claude thinking blocks when %s", |
| 2187 | 2262 | (status) => |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+6-5| ... | ... | @@ -6969,11 +6969,12 @@ export function makeClaudeAdapterV2( |
| 6969 | 6969 | |
| 6970 | 6970 | const openedWithResume = (yield* Ref.get(openedNativeThreads)).has(nativeThreadId); |
| 6971 | 6971 | // openedNativeThreads is per session instance and is lost when the |
| 6972 | // provider session is idle-released. A prior persisted provider turn | |
| 6973 | // proves the native session already exists, so the query must resume | |
| 6974 | // it; reopening with a fixed session id makes the CLI fail fast with | |
| 6975 | // "Session ID ... is already in use". | |
| 6976 | const hasPersistedProviderTurn = turnInput.providerTurnOrdinal > 1; | |
| 6972 | // provider session is idle-released. A prior turn on this native id | |
| 6973 | // requires resume; sessionId would fail with "already in use". | |
| 6974 | // A fresh-session fallback keeps provider-thread history but binds | |
| 6975 | // a new native id, which must be created before it can be resumed. | |
| 6976 | const hasPersistedProviderTurn = | |
| 6977 | turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1; | |
| 6977 | 6978 | const shouldResume = |
| 6978 | 6979 | resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; |
| 6979 | 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 | 41 | // Codex turns leave commands running, then the thread moves to another |
| 42 | 42 | // provider thread (a provider switch). Stop on the newer, settled run must |
| 43 | 43 | // reach both provider threads and end all of the Codex work. |
| 44 | it.effect("Stop reaches background work an earlier provider thread still runs", () => | |
| 44 | const stopEarlierBackgroundWork = (failedStart: boolean) => | |
| 45 | 45 | Effect.scoped( |
| 46 | 46 | Effect.gen(function* () { |
| 47 | 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 | 233 | const settledRun = (input: { |
| 234 | 234 | readonly ordinal: number; |
| 235 | 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 | 241 | const runId = RunId.make(`run:${input.ordinal}`); |
| 239 | 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 | 410 | ], |
| 408 | 411 | }); |
| 409 | 412 | |
| 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 | 443 | yield* orchestrator.dispatch({ |
| 411 | 444 | type: "run.interrupt", |
| 412 | 445 | commandId: CommandId.make("stop-background-work"), |
| 413 | 446 | threadId, |
| 414 | runId: latestRun.runId, | |
| 447 | runId: failedStart ? failedRun.runId : latestRun.runId, | |
| 415 | 448 | }); |
| 416 | 449 | yield* worker.drain(); |
| 417 | 450 | |
| ... | ... | @@ -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 | ||
| 480 | it.effect("Stop reaches background work an earlier provider thread still runs", () => | |
| 481 | stopEarlierBackgroundWork(false), | |
| 482 | ); | |
| 483 | ||
| 484 | it.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 | 8040 | activeProviderThreadId: projection.thread.activeProviderThreadId, |
| 8041 | 8041 | runs: projection.runs, |
| 8042 | 8042 | }).length > 0; |
| 8043 | const providerTurn = projection.providerTurns.findLast( | |
| 8044 | (candidate) => | |
| 8045 | candidate.runAttemptId === run?.activeAttemptId && | |
| 8046 | (candidate.status === "running" || hasBackgroundWork), | |
| 8047 | ); | |
| 8043 | // A failed start has no provider turn. Background work still belongs | |
| 8044 | // to the provider thread, so Stop reaches its latest accepted turn. | |
| 8045 | const providerTurn = | |
| 8046 | projection.providerTurns.findLast( | |
| 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 | 8056 | if (run === undefined || rootNode === undefined || providerThread === undefined) { |
| 8049 | 8057 | return yield* new OrchestratorDispatchError({ |
| 8050 | 8058 | commandId: command.commandId, |
apps/server/src/orchestration-v2/ProviderAdapter.ts+2| ... | ... | @@ -398,6 +398,8 @@ export interface ProviderAdapterV2TurnInput { |
| 398 | 398 | readonly threadId: ThreadId; |
| 399 | 399 | readonly runId: RunId; |
| 400 | 400 | readonly runOrdinal: number; |
| 401 | /** Whether the current native session has an accepted turn; omitted when unknown. */ | |
| 402 | readonly nativeThreadHasTurns?: boolean; | |
| 401 | 403 | readonly providerTurnOrdinal: number; |
| 402 | 404 | readonly restartContinuationOfRunId?: RunId; |
| 403 | 405 | readonly attemptId: RunAttemptId; |
apps/server/src/orchestration-v2/ProviderTurnStartService.ts+7| ... | ... | @@ -1231,6 +1231,13 @@ export const layer: Layer.Layer< |
| 1231 | 1231 | .filter((turn) => turn.providerThreadId === providerThread.id) |
| 1232 | 1232 | .map((turn) => turn.ordinal), |
| 1233 | 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 | 1241 | shouldStartProviderTurn: runControls.shouldStartProviderTurn, |
| 1235 | 1242 | shouldFinalizeRun: runControls.shouldFinalizeRun, |
| 1236 | 1243 | hasUnpairedRunInterruptRequest: runControls.hasUnpairedRunInterruptRequest, |
apps/server/src/orchestration-v2/RunExecutionService.ts+4| ... | ... | @@ -507,6 +507,7 @@ export interface RunExecutionServiceV2StartRootRunInput { |
| 507 | 507 | readonly attempt: OrchestrationV2RunAttempt; |
| 508 | 508 | readonly attemptId: RunAttemptId; |
| 509 | 509 | readonly providerTurnOrdinal: number; |
| 510 | readonly nativeThreadHasTurns?: boolean; | |
| 510 | 511 | readonly loadInheritedBackgroundTurnItems?: () => Effect.Effect< |
| 511 | 512 | ReadonlyArray<InheritedBackgroundTurnItemRoute>, |
| 512 | 513 | unknown |
| ... | ... | @@ -1348,6 +1349,9 @@ export const layer: Layer.Layer< |
| 1348 | 1349 | runId: input.run.id, |
| 1349 | 1350 | runOrdinal: input.run.ordinal, |
| 1350 | 1351 | providerTurnOrdinal: input.providerTurnOrdinal, |
| 1352 | ...(input.nativeThreadHasTurns === undefined | |
| 1353 | ? {} | |
| 1354 | : { nativeThreadHasTurns: input.nativeThreadHasTurns }), | |
| 1351 | 1355 | ...(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 | 85 | readonly providerThreadId: ProviderThreadId; |
| 86 | 86 | readonly text: string; |
| 87 | 87 | readonly attachments: ReadonlyArray<ChatAttachment>; |
| 88 | readonly nativeThreadId?: string | null; | |
| 89 | readonly providerTurnOrdinal?: number; | |
| 90 | readonly nativeThreadHasTurns?: boolean; | |
| 88 | 91 | } |
| 89 | 92 | |
| 90 | 93 | function unimplemented(driver: ProviderDriverKind, detail: string) { |
| ... | ... | @@ -232,6 +235,11 @@ function makeTestAdapter(input: { |
| 232 | 235 | providerThreadId: turnInput.providerThread.id, |
| 233 | 236 | text: turnInput.message.text, |
| 234 | 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 | 245 | if ( |
| ... | ... | @@ -1051,6 +1059,135 @@ describe("orchestration v2 provider switching", () => { |
| 1051 | 1059 | ), |
| 1052 | 1060 | ); |
| 1053 | 1061 | |
| 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 | 1191 | it.live.each( |
| 1055 | 1192 | (["failed", "interrupted"] as const).flatMap((status) => |
| 1056 | 1193 | [false, true].flatMap((queued) => |