| 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,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 | }); |
| 1784 | 1788 | ||
| 1785 | describe("ClaudeAdapterV2 native session identity", () => { | 1789 | describe("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 | }); |
| 1871 | 1884 | ||
| 1872 | const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | 1885 | const 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(); |
| 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 | 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( |
| 6969 | 6969 | ||
| 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 the | 6971 | // openedNativeThreads is per session instance and is lost when the |
| 6972 | // provider session is idle-released. A prior persisted provider turn | 6972 | // provider session is idle-released. A prior turn on this native id |
| 6973 | // proves the native session already exists, so the query must resume | 6973 | // requires resume; sessionId would fail with "already in use". |
| 6974 | // it; reopening with a fixed session id makes the CLI fail fast with | 6974 | // 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 another | 41 | // Codex turns leave commands running, then the thread moves to another |
| 42 | // provider thread (a provider switch). Stop on the newer, settled run must | 42 | // 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. |
| 44 | it.effect("Stop reaches background work an earlier provider thread still runs", () => | 44 | const 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 | }); |
| 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 | 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(); |
| 417 | 450 | ||
| ... | @@ -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 | |||
| 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,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 | unknown | 513 | 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 === undefined | 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,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 | } |
| 89 | 92 | ||
| 90 | function unimplemented(driver: ProviderDriverKind, detail: string) { | 93 | function 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 | ); |
| 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 | 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) => |