| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { |
| 3 | 3 | type OrchestrationCommand, |
| 4 | type OrchestrationSessionStatus, |
| 4 | 5 | ProviderDriverKind, |
| 5 | 6 | ProviderInstanceId, |
| 6 | 7 | type ProviderSendTurnInput, |
| ... | ... | @@ -10,15 +11,21 @@ import { |
| 10 | 11 | import { assert, it } from "@effect/vitest"; |
| 11 | 12 | import * as Deferred from "effect/Deferred"; |
| 12 | 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Layer from "effect/Layer"; |
| 13 | 15 | import * as Option from "effect/Option"; |
| 14 | 16 | import * as Stream from "effect/Stream"; |
| 15 | 17 | |
| 16 | 18 | import { OrchestrationCommandInvariantError } from "./orchestration/Errors.ts"; |
| 17 | 19 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; |
| 18 | 20 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 19 | | import { ProviderSessionDirectoryPersistenceError } from "./provider/Errors.ts"; |
| 21 | import { |
| 22 | ProviderSessionDirectoryPersistenceError, |
| 23 | ProviderSessionNotFoundError, |
| 24 | } from "./provider/Errors.ts"; |
| 20 | 25 | import * as ProviderService from "./provider/Services/ProviderService.ts"; |
| 21 | 26 | import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; |
| 27 | import { ServerActivation } from "./serverActivation.ts"; |
| 28 | import * as ServerSettings from "./serverSettings.ts"; |
| 22 | 29 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 23 | 30 | |
| 24 | 31 | const providerInstanceId = ProviderInstanceId.make("codex"); |
| ... | ... | @@ -26,7 +33,7 @@ const updatedAt = "2026-08-20T12:00:00.000Z"; |
| 26 | 33 | |
| 27 | 34 | const makeThread = ( |
| 28 | 35 | id: string, |
| 29 | | status: "starting" | "running" | "ready" | "stopped" | "error", |
| 36 | status: OrchestrationSessionStatus, |
| 30 | 37 | activeTurnId: TurnId | null = null, |
| 31 | 38 | archivedAt: string | null = null, |
| 32 | 39 | deletedAt: string | null = null, |
| ... | ... | @@ -73,6 +80,7 @@ const queryWithThreads = (threads: ReadonlyArray<ReturnType<typeof makeThread>>) |
| 73 | 80 | |
| 74 | 81 | const runReconciliation = (input: { |
| 75 | 82 | readonly threads: ReadonlyArray<ReturnType<typeof makeThread>>; |
| 83 | readonly continueAfterRestart?: boolean; |
| 76 | 84 | readonly liveThreadIds?: ReadonlyArray<ThreadId>; |
| 77 | 85 | readonly providerService?: ProviderService.ProviderService["Service"]; |
| 78 | 86 | readonly directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; |
| ... | ... | @@ -97,7 +105,14 @@ const runReconciliation = (input: { |
| 97 | 105 | subscribeDomainEvents: Effect.succeed(Stream.empty), |
| 98 | 106 | latestSequence: Effect.succeed(0), |
| 99 | 107 | }), |
| 100 | | Effect.provide(NodeServices.layer), |
| 108 | Effect.provide( |
| 109 | Layer.mergeAll( |
| 110 | ServerSettings.layerTest({ |
| 111 | continueThreadsAfterServerUpdate: input.continueAfterRestart ?? false, |
| 112 | }), |
| 113 | NodeServices.layer, |
| 114 | ), |
| 115 | ), |
| 101 | 116 | ); |
| 102 | 117 | |
| 103 | 118 | it.effect("marks active running sessions that have persisted resume state", () => { |
| ... | ... | @@ -138,7 +153,7 @@ it.effect("marks active running sessions that have persisted resume state", () = |
| 138 | 153 | upsert: (binding) => Effect.sync(() => upserts.push(binding)), |
| 139 | 154 | getProvider: () => Effect.die("unused"), |
| 140 | 155 | listThreadIds: () => Effect.die("unused"), |
| 141 | | listBindings: () => Effect.die("unused"), |
| 156 | listBindings: () => Effect.succeed([]), |
| 142 | 157 | }), |
| 143 | 158 | Effect.tap((marked) => |
| 144 | 159 | Effect.sync(() => { |
| ... | ... | @@ -147,169 +162,186 @@ it.effect("marks active running sessions that have persisted resume state", () = |
| 147 | 162 | assert.deepStrictEqual(upserts[0]?.runtimePayload, { |
| 148 | 163 | activeTurnId: "turn-mark-active", |
| 149 | 164 | continueAfterServerUpdate: active.session.activeTurnId, |
| 165 | continueAfterServerUpdatePrepared: null, |
| 150 | 166 | }); |
| 151 | 167 | }), |
| 152 | 168 | ), |
| 153 | 169 | ); |
| 154 | 170 | }); |
| 155 | 171 | |
| 156 | | it.effect("continues marked sessions after activation with provider-specific input", () => |
| 157 | | Effect.gen(function* () { |
| 158 | | const codex = makeThread( |
| 159 | | "thread-continue-codex", |
| 160 | | "running", |
| 161 | | TurnId.make("turn-continue-codex"), |
| 162 | | ); |
| 163 | | const fallback = makeThread("thread-continue-fallback", "starting"); |
| 164 | | const fallbackContinuationTurnId = TurnId.make("turn-continue-fallback"); |
| 165 | | const fallbackProviderInstanceId = ProviderInstanceId.make("claudeAgent"); |
| 166 | | const continuationSent = yield* Deferred.make<void>(); |
| 167 | | const continuationCleared = yield* Deferred.make<void>(); |
| 168 | | const sends: ProviderSendTurnInput[] = []; |
| 169 | | const dispatched: OrchestrationCommand[] = []; |
| 170 | | const upserts: ProviderSessionDirectory.ProviderRuntimeBinding[] = []; |
| 171 | | const bindings = new Map<ThreadId, ProviderSessionDirectory.ProviderRuntimeBinding>( |
| 172 | | [codex, fallback].map((thread) => [ |
| 173 | | thread.id, |
| 174 | | { |
| 175 | | threadId: thread.id, |
| 176 | | provider: |
| 177 | | thread.id === codex.id |
| 178 | | ? ProviderDriverKind.make("codex") |
| 179 | | : ProviderDriverKind.make("claudeAgent"), |
| 180 | | providerInstanceId: |
| 181 | | thread.id === codex.id ? providerInstanceId : fallbackProviderInstanceId, |
| 182 | | status: "running" as const, |
| 183 | | runtimePayload: { |
| 184 | | continueAfterServerUpdate: |
| 185 | | thread.id === codex.id ? codex.session.activeTurnId : fallbackContinuationTurnId, |
| 172 | it.effect.each(["marked update", "opt-in restart"] as const)( |
| 173 | "continues %s sessions after activation with provider-specific input", |
| 174 | (recovery) => |
| 175 | Effect.gen(function* () { |
| 176 | const codex = makeThread( |
| 177 | "thread-continue-codex", |
| 178 | "running", |
| 179 | TurnId.make("turn-continue-codex"), |
| 180 | ); |
| 181 | const fallbackContinuationTurnId = TurnId.make("turn-continue-fallback"); |
| 182 | const fallback = makeThread( |
| 183 | "thread-continue-fallback", |
| 184 | recovery === "marked update" ? "starting" : "running", |
| 185 | recovery === "marked update" ? null : fallbackContinuationTurnId, |
| 186 | ); |
| 187 | const fallbackProviderInstanceId = ProviderInstanceId.make("claudeAgent"); |
| 188 | const continuationSent = yield* Deferred.make<void>(); |
| 189 | const continuationCleared = yield* Deferred.make<void>(); |
| 190 | const sends: ProviderSendTurnInput[] = []; |
| 191 | const dispatched: OrchestrationCommand[] = []; |
| 192 | const upserts: ProviderSessionDirectory.ProviderRuntimeBinding[] = []; |
| 193 | const bindings = new Map<ThreadId, ProviderSessionDirectory.ProviderRuntimeBinding>( |
| 194 | [codex, fallback].map((thread) => [ |
| 195 | thread.id, |
| 196 | { |
| 197 | threadId: thread.id, |
| 198 | provider: |
| 199 | thread.id === codex.id |
| 200 | ? ProviderDriverKind.make("codex") |
| 201 | : ProviderDriverKind.make("claudeAgent"), |
| 202 | providerInstanceId: |
| 203 | thread.id === codex.id ? providerInstanceId : fallbackProviderInstanceId, |
| 204 | status: "running" as const, |
| 205 | resumeCursor: { threadId: thread.id }, |
| 206 | runtimePayload: |
| 207 | recovery === "marked update" |
| 208 | ? { |
| 209 | continueAfterServerUpdate: |
| 210 | thread.id === codex.id |
| 211 | ? codex.session.activeTurnId |
| 212 | : fallbackContinuationTurnId, |
| 213 | } |
| 214 | : { activeTurnId: thread.session.activeTurnId }, |
| 186 | 215 | }, |
| 187 | | }, |
| 188 | | ]), |
| 189 | | ); |
| 190 | | const providerService: ProviderService.ProviderService["Service"] = { |
| 191 | | ...makeProviderService(), |
| 192 | | getCapabilities: (instanceId) => |
| 193 | | Effect.succeed({ |
| 194 | | sessionModelSwitch: "in-session", |
| 195 | | ...(instanceId === providerInstanceId ? { promptlessTurnContinuation: true } : {}), |
| 196 | | }), |
| 197 | | sendTurn: (input) => |
| 198 | | Effect.gen(function* () { |
| 199 | | sends.push(input); |
| 200 | | if (sends.length === 2) { |
| 201 | | yield* Deferred.succeed(continuationSent, undefined); |
| 202 | | } |
| 203 | | return { |
| 204 | | threadId: input.threadId, |
| 205 | | turnId: TurnId.make(`continued-${String(input.threadId)}`), |
| 206 | | }; |
| 207 | | }), |
| 208 | | }; |
| 209 | | |
| 210 | | yield* runReconciliation({ |
| 211 | | threads: [codex, fallback], |
| 212 | | providerService, |
| 213 | | directory: { |
| 214 | | getBinding: (threadId) => |
| 215 | | Effect.sync(() => { |
| 216 | | const binding = bindings.get(threadId); |
| 217 | | return binding === undefined ? Option.none() : Option.some(binding); |
| 216 | ]), |
| 217 | ); |
| 218 | const providerService: ProviderService.ProviderService["Service"] = { |
| 219 | ...makeProviderService(), |
| 220 | getCapabilities: (instanceId) => |
| 221 | Effect.succeed({ |
| 222 | sessionModelSwitch: "in-session", |
| 223 | ...(instanceId === providerInstanceId ? { promptlessTurnContinuation: true } : {}), |
| 218 | 224 | }), |
| 219 | | upsert: (binding) => |
| 220 | | Effect.sync(() => { |
| 221 | | bindings.set(binding.threadId, binding); |
| 222 | | upserts.push(binding); |
| 223 | | const clearedCount = upserts.filter((candidate) => { |
| 224 | | const payload = candidate.runtimePayload; |
| 225 | | return ( |
| 226 | | payload !== null && |
| 227 | | typeof payload === "object" && |
| 228 | | !Array.isArray(payload) && |
| 229 | | "continueAfterServerUpdate" in payload && |
| 230 | | payload.continueAfterServerUpdate === null |
| 231 | | ); |
| 232 | | }).length; |
| 233 | | return clearedCount === 1; |
| 234 | | }).pipe( |
| 235 | | Effect.flatMap((firstMarkerCleared) => |
| 236 | | firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void, |
| 225 | sendTurn: (input) => |
| 226 | Effect.gen(function* () { |
| 227 | sends.push(input); |
| 228 | if (sends.length === 2) { |
| 229 | yield* Deferred.succeed(continuationSent, undefined); |
| 230 | } |
| 231 | return { |
| 232 | threadId: input.threadId, |
| 233 | turnId: TurnId.make(`continued-${String(input.threadId)}`), |
| 234 | }; |
| 235 | }), |
| 236 | }; |
| 237 | |
| 238 | yield* runReconciliation({ |
| 239 | threads: [codex, fallback], |
| 240 | continueAfterRestart: recovery === "opt-in restart", |
| 241 | providerService, |
| 242 | directory: { |
| 243 | getBinding: (threadId) => |
| 244 | Effect.sync(() => { |
| 245 | const binding = bindings.get(threadId); |
| 246 | return binding === undefined ? Option.none() : Option.some(binding); |
| 247 | }), |
| 248 | upsert: (binding) => |
| 249 | Effect.sync(() => { |
| 250 | bindings.set(binding.threadId, binding); |
| 251 | upserts.push(binding); |
| 252 | const clearedCount = upserts.filter((candidate) => { |
| 253 | const payload = candidate.runtimePayload; |
| 254 | return ( |
| 255 | payload !== null && |
| 256 | typeof payload === "object" && |
| 257 | !Array.isArray(payload) && |
| 258 | "continueAfterServerUpdate" in payload && |
| 259 | payload.continueAfterServerUpdate === null |
| 260 | ); |
| 261 | }).length; |
| 262 | return clearedCount === 1; |
| 263 | }).pipe( |
| 264 | Effect.flatMap((firstMarkerCleared) => |
| 265 | firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void, |
| 266 | ), |
| 237 | 267 | ), |
| 268 | getProvider: () => Effect.die("unused"), |
| 269 | listThreadIds: () => Effect.die("unused"), |
| 270 | listBindings: () => Effect.succeed([]), |
| 271 | }, |
| 272 | dispatch: (command) => |
| 273 | Effect.sync(() => dispatched.push(command)).pipe( |
| 274 | Effect.as({ sequence: dispatched.length }), |
| 238 | 275 | ), |
| 239 | | getProvider: () => Effect.die("unused"), |
| 240 | | listThreadIds: () => Effect.die("unused"), |
| 241 | | listBindings: () => Effect.die("unused"), |
| 242 | | }, |
| 243 | | dispatch: (command) => |
| 244 | | Effect.sync(() => dispatched.push(command)).pipe( |
| 245 | | Effect.as({ sequence: dispatched.length }), |
| 246 | | ), |
| 247 | | }); |
| 248 | | yield* Deferred.await(continuationSent); |
| 249 | | yield* Deferred.await(continuationCleared); |
| 276 | }); |
| 277 | yield* Deferred.await(continuationSent); |
| 278 | yield* Deferred.await(continuationCleared); |
| 250 | 279 | |
| 251 | | assert.deepStrictEqual( |
| 252 | | sends.toSorted((left, right) => String(left.threadId).localeCompare(String(right.threadId))), |
| 253 | | [ |
| 254 | | { threadId: codex.id, continuation: true, interactionMode: "default" }, |
| 255 | | { |
| 256 | | threadId: fallback.id, |
| 257 | | input: "Continue where you left off.", |
| 258 | | interactionMode: "default", |
| 259 | | }, |
| 260 | | ], |
| 261 | | ); |
| 262 | | assert.deepStrictEqual( |
| 263 | | dispatched.map((command) => |
| 264 | | command.type === "thread.session.set" |
| 265 | | ? { |
| 266 | | threadId: command.threadId, |
| 267 | | status: command.session.status, |
| 268 | | activeTurnId: command.session.activeTurnId, |
| 269 | | } |
| 270 | | : null, |
| 271 | | ), |
| 272 | | [ |
| 273 | | { |
| 274 | | threadId: codex.id, |
| 275 | | status: "starting", |
| 276 | | activeTurnId: null, |
| 277 | | }, |
| 278 | | { |
| 279 | | threadId: fallback.id, |
| 280 | | status: "starting", |
| 281 | | activeTurnId: fallback.session.activeTurnId, |
| 282 | | }, |
| 283 | | ], |
| 284 | | ); |
| 285 | | for (const [thread, continuationTurnId] of [ |
| 286 | | [codex, codex.session.activeTurnId], |
| 287 | | [fallback, fallbackContinuationTurnId], |
| 288 | | ] as const) { |
| 289 | 280 | assert.deepStrictEqual( |
| 290 | | upserts |
| 291 | | .filter((binding) => binding.threadId === thread.id) |
| 292 | | .map((binding) => binding.runtimePayload)[0], |
| 293 | | { |
| 294 | | continueAfterServerUpdate: continuationTurnId, |
| 295 | | activeTurnId: null, |
| 296 | | }, |
| 281 | sends.toSorted((left, right) => |
| 282 | String(left.threadId).localeCompare(String(right.threadId)), |
| 283 | ), |
| 284 | [ |
| 285 | { threadId: codex.id, continuation: true, interactionMode: "default" }, |
| 286 | { |
| 287 | threadId: fallback.id, |
| 288 | input: "Continue where you left off.", |
| 289 | interactionMode: "default", |
| 290 | }, |
| 291 | ], |
| 292 | ); |
| 293 | assert.deepStrictEqual( |
| 294 | dispatched.map((command) => |
| 295 | command.type === "thread.session.set" |
| 296 | ? { |
| 297 | threadId: command.threadId, |
| 298 | status: command.session.status, |
| 299 | activeTurnId: command.session.activeTurnId, |
| 300 | } |
| 301 | : null, |
| 302 | ), |
| 303 | [ |
| 304 | { |
| 305 | threadId: codex.id, |
| 306 | status: "starting", |
| 307 | activeTurnId: null, |
| 308 | }, |
| 309 | { |
| 310 | threadId: fallback.id, |
| 311 | status: "starting", |
| 312 | activeTurnId: null, |
| 313 | }, |
| 314 | ], |
| 297 | 315 | ); |
| 298 | | } |
| 299 | | assert.equal( |
| 300 | | upserts.some((binding) => { |
| 301 | | const payload = binding.runtimePayload; |
| 302 | | return ( |
| 303 | | payload !== null && |
| 304 | | typeof payload === "object" && |
| 305 | | !Array.isArray(payload) && |
| 306 | | "continueAfterServerUpdate" in payload && |
| 307 | | payload.continueAfterServerUpdate === null |
| 316 | for (const [thread, continuationTurnId] of [ |
| 317 | [codex, codex.session.activeTurnId], |
| 318 | [fallback, fallbackContinuationTurnId], |
| 319 | ] as const) { |
| 320 | assert.deepStrictEqual( |
| 321 | upserts |
| 322 | .filter((binding) => binding.threadId === thread.id) |
| 323 | .map((binding) => binding.runtimePayload)[0], |
| 324 | { |
| 325 | continueAfterServerUpdate: continuationTurnId, |
| 326 | continueAfterServerUpdatePrepared: true, |
| 327 | activeTurnId: null, |
| 328 | }, |
| 308 | 329 | ); |
| 309 | | }), |
| 310 | | true, |
| 311 | | ); |
| 312 | | }), |
| 330 | } |
| 331 | assert.equal( |
| 332 | upserts.some((binding) => { |
| 333 | const payload = binding.runtimePayload; |
| 334 | return ( |
| 335 | payload !== null && |
| 336 | typeof payload === "object" && |
| 337 | !Array.isArray(payload) && |
| 338 | "continueAfterServerUpdate" in payload && |
| 339 | payload.continueAfterServerUpdate === null |
| 340 | ); |
| 341 | }), |
| 342 | true, |
| 343 | ); |
| 344 | }), |
| 313 | 345 | ); |
| 314 | 346 | |
| 315 | 347 | it.effect("does not continue archived or deleted marked sessions", () => { |
| ... | ... | @@ -361,7 +393,7 @@ it.effect("does not continue archived or deleted marked sessions", () => { |
| 361 | 393 | upsert: () => Effect.void, |
| 362 | 394 | getProvider: () => Effect.die("unused"), |
| 363 | 395 | listThreadIds: () => Effect.die("unused"), |
| 364 | | listBindings: () => Effect.die("unused"), |
| 396 | listBindings: () => Effect.succeed([]), |
| 365 | 397 | }, |
| 366 | 398 | dispatch: (command) => |
| 367 | 399 | Effect.sync(() => dispatched.push(command)).pipe(Effect.as({ sequence: dispatched.length })), |
| ... | ... | @@ -416,7 +448,7 @@ it.effect("retries continuation preparation before settling a persistent failure |
| 416 | 448 | upsert: () => Effect.void, |
| 417 | 449 | getProvider: () => Effect.die("unused"), |
| 418 | 450 | listThreadIds: () => Effect.die("unused"), |
| 419 | | listBindings: () => Effect.die("unused"), |
| 451 | listBindings: () => Effect.succeed([]), |
| 420 | 452 | }, |
| 421 | 453 | dispatch: (command) => { |
| 422 | 454 | if (command.type !== "thread.session.set") { |
| ... | ... | @@ -487,7 +519,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio |
| 487 | 519 | upsert: (binding) => Effect.sync(() => upserts.push(binding)), |
| 488 | 520 | getProvider: () => Effect.die("unused"), |
| 489 | 521 | listThreadIds: () => Effect.die("unused"), |
| 490 | | listBindings: () => Effect.die("unused"), |
| 522 | listBindings: () => Effect.succeed([]), |
| 491 | 523 | }, |
| 492 | 524 | dispatch: (command) => |
| 493 | 525 | Effect.sync(() => dispatched.push(command)).pipe(Effect.as({ sequence: dispatched.length })), |
| ... | ... | @@ -521,6 +553,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio |
| 521 | 553 | activeTurnId: null, |
| 522 | 554 | unrelated: binding.threadId, |
| 523 | 555 | continueAfterServerUpdate: null, |
| 556 | continueAfterServerUpdatePrepared: null, |
| 524 | 557 | } |
| 525 | 558 | : { activeTurnId: null, unrelated: binding.threadId }, |
| 526 | 559 | ); |
| ... | ... | @@ -565,7 +598,7 @@ it.effect( |
| 565 | 598 | upsert: () => Effect.fail(writeFailure), |
| 566 | 599 | getProvider: () => Effect.die("unused"), |
| 567 | 600 | listThreadIds: () => Effect.die("unused"), |
| 568 | | listBindings: () => Effect.die("unused"), |
| 601 | listBindings: () => Effect.succeed([]), |
| 569 | 602 | }, |
| 570 | 603 | dispatch: (command) => |
| 571 | 604 | Effect.sync(() => dispatched.push(command)).pipe( |
| ... | ... | @@ -602,7 +635,7 @@ it.effect("retries failed projections and continues after a persistent failure", |
| 602 | 635 | upsert: () => Effect.void, |
| 603 | 636 | getProvider: () => Effect.die("unused"), |
| 604 | 637 | listThreadIds: () => Effect.die("unused"), |
| 605 | | listBindings: () => Effect.die("unused"), |
| 638 | listBindings: () => Effect.succeed([]), |
| 606 | 639 | }, |
| 607 | 640 | dispatch: (command) => { |
| 608 | 641 | if (command.type !== "thread.session.set") { |
| ... | ... | @@ -651,7 +684,7 @@ it.effect("does not fail startup when the live provider session inventory cannot |
| 651 | 684 | upsert: () => Effect.die("unused"), |
| 652 | 685 | getProvider: () => Effect.die("unused"), |
| 653 | 686 | listThreadIds: () => Effect.die("unused"), |
| 654 | | listBindings: () => Effect.die("unused"), |
| 687 | listBindings: () => Effect.succeed([]), |
| 655 | 688 | }), |
| 656 | 689 | Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { |
| 657 | 690 | readEvents: () => Stream.empty, |
| ... | ... | @@ -662,7 +695,283 @@ it.effect("does not fail startup when the live provider session inventory cannot |
| 662 | 695 | subscribeDomainEvents: Effect.succeed(Stream.empty), |
| 663 | 696 | latestSequence: Effect.succeed(0), |
| 664 | 697 | }), |
| 665 | | Effect.provide(NodeServices.layer), |
| 698 | Effect.provide(Layer.mergeAll(NodeServices.layer, ServerSettings.layerTest())), |
| 666 | 699 | Effect.tap(() => Effect.sync(() => assert.equal(queried, false))), |
| 667 | 700 | ); |
| 668 | 701 | }); |
| 702 | |
| 703 | for (const scenario of [ |
| 704 | "disabled", |
| 705 | "stopped projection", |
| 706 | "finished projection", |
| 707 | "stopped binding", |
| 708 | "finished binding", |
| 709 | "missing cursor", |
| 710 | "mismatched turn", |
| 711 | "marked without cursor", |
| 712 | "marked stopped projection", |
| 713 | "marked superseded turn", |
| 714 | ] as const) { |
| 715 | it.effect(`does not recover an interrupted session with ${scenario}`, () => { |
| 716 | const turnId = TurnId.make("turn-excluded-recovery"); |
| 717 | const thread = makeThread( |
| 718 | "thread-excluded-recovery", |
| 719 | scenario.includes("stopped projection") |
| 720 | ? "stopped" |
| 721 | : scenario === "finished projection" |
| 722 | ? "ready" |
| 723 | : scenario === "marked superseded turn" |
| 724 | ? "starting" |
| 725 | : "running", |
| 726 | scenario === "marked superseded turn" ? null : turnId, |
| 727 | ); |
| 728 | const dispatched: OrchestrationCommand[] = []; |
| 729 | const upserts: ProviderSessionDirectory.ProviderRuntimeBinding[] = []; |
| 730 | return runReconciliation({ |
| 731 | threads: [thread], |
| 732 | continueAfterRestart: scenario !== "disabled", |
| 733 | directory: { |
| 734 | getBinding: () => |
| 735 | Effect.succeed( |
| 736 | Option.some({ |
| 737 | threadId: thread.id, |
| 738 | provider: ProviderDriverKind.make("codex"), |
| 739 | providerInstanceId, |
| 740 | status: scenario === "stopped binding" ? "stopped" : "running", |
| 741 | ...(scenario.includes("cursor") ? {} : { resumeCursor: { threadId: thread.id } }), |
| 742 | runtimePayload: { |
| 743 | activeTurnId: |
| 744 | scenario === "finished binding" |
| 745 | ? null |
| 746 | : scenario === "mismatched turn" || scenario === "marked superseded turn" |
| 747 | ? "another-turn" |
| 748 | : turnId, |
| 749 | ...(scenario.startsWith("marked") ? { continueAfterServerUpdate: turnId } : {}), |
| 750 | }, |
| 751 | }), |
| 752 | ), |
| 753 | upsert: (binding) => |
| 754 | Effect.sync(() => { |
| 755 | upserts.push(binding); |
| 756 | }), |
| 757 | getProvider: () => Effect.die("unused"), |
| 758 | listThreadIds: () => Effect.die("unused"), |
| 759 | listBindings: () => Effect.succeed([]), |
| 760 | }, |
| 761 | dispatch: (command) => |
| 762 | Effect.sync(() => { |
| 763 | dispatched.push(command); |
| 764 | return { sequence: dispatched.length }; |
| 765 | }), |
| 766 | }).pipe( |
| 767 | Effect.tap(() => |
| 768 | Effect.sync(() => { |
| 769 | assert.deepStrictEqual( |
| 770 | dispatched.map( |
| 771 | (command) => command.type === "thread.session.set" && command.session.status, |
| 772 | ), |
| 773 | ["error"], |
| 774 | ); |
| 775 | assert.deepStrictEqual( |
| 776 | upserts.map((binding) => binding.status), |
| 777 | ["stopped"], |
| 778 | ); |
| 779 | }), |
| 780 | ), |
| 781 | ); |
| 782 | }); |
| 783 | } |
| 784 | |
| 785 | for (const preparedStatus of [ |
| 786 | "starting", |
| 787 | "ready", |
| 788 | "ready with failed scan", |
| 789 | "completed after update marking", |
| 790 | ] as const) { |
| 791 | it.effect(`recovers again if startup exits with a prepared ${preparedStatus} session`, () => |
| 792 | Effect.gen(function* () { |
| 793 | const turnId = TurnId.make("turn-interrupted-startup"); |
| 794 | const thread = makeThread("thread-interrupted-startup", "running", turnId); |
| 795 | const activation = yield* Deferred.make<void>(); |
| 796 | const cleared = yield* Deferred.make<void>(); |
| 797 | const sends: ProviderSendTurnInput[] = []; |
| 798 | let binding: ProviderSessionDirectory.ProviderRuntimeBinding = { |
| 799 | threadId: thread.id, |
| 800 | provider: ProviderDriverKind.make("codex"), |
| 801 | providerInstanceId, |
| 802 | status: "running", |
| 803 | resumeCursor: { threadId: thread.id }, |
| 804 | runtimePayload: { activeTurnId: turnId }, |
| 805 | }; |
| 806 | const input = { |
| 807 | threads: [thread], |
| 808 | continueAfterRestart: true, |
| 809 | providerService: { |
| 810 | ...makeProviderService(), |
| 811 | getCapabilities: () => |
| 812 | Effect.succeed({ |
| 813 | sessionModelSwitch: "in-session" as const, |
| 814 | promptlessTurnContinuation: true, |
| 815 | }), |
| 816 | sendTurn: (input: ProviderSendTurnInput) => |
| 817 | Effect.sync(() => { |
| 818 | sends.push(input); |
| 819 | return { threadId: input.threadId, turnId: TurnId.make("turn-recovered") }; |
| 820 | }), |
| 821 | }, |
| 822 | directory: { |
| 823 | getBinding: () => Effect.sync(() => Option.some(binding)), |
| 824 | upsert: (next: ProviderSessionDirectory.ProviderRuntimeBinding) => |
| 825 | Effect.gen(function* () { |
| 826 | binding = next; |
| 827 | if (binding.status !== "starting" || sends.length === 0) return; |
| 828 | yield* Deferred.succeed(cleared, undefined); |
| 829 | }), |
| 830 | getProvider: () => Effect.die("unused"), |
| 831 | listThreadIds: () => Effect.die("unused"), |
| 832 | listBindings: () => |
| 833 | preparedStatus === "ready with failed scan" |
| 834 | ? Effect.fail( |
| 835 | new ProviderSessionDirectoryPersistenceError({ |
| 836 | operation: "listBindings", |
| 837 | detail: "unreadable unrelated binding", |
| 838 | }), |
| 839 | ) |
| 840 | : Effect.sync(() => [{ ...binding, lastSeenAt: "2026-01-01T00:00:00.000Z" }]), |
| 841 | }, |
| 842 | dispatch: (command: OrchestrationCommand) => |
| 843 | Effect.sync(() => { |
| 844 | if (command.type === "thread.session.set") { |
| 845 | thread.session.status = command.session.status; |
| 846 | thread.session.activeTurnId = command.session.activeTurnId; |
| 847 | } |
| 848 | return { sequence: 1 }; |
| 849 | }), |
| 850 | }; |
| 851 | |
| 852 | yield* runReconciliation(input).pipe( |
| 853 | Effect.provideService(ServerActivation, Deferred.await(activation)), |
| 854 | Effect.scoped, |
| 855 | ); |
| 856 | assert.deepStrictEqual(sends, []); |
| 857 | assert.equal(thread.session.status, "starting"); |
| 858 | assert.equal(thread.session.activeTurnId, null); |
| 859 | assert.deepStrictEqual(binding.runtimePayload, { |
| 860 | activeTurnId: null, |
| 861 | continueAfterServerUpdate: turnId, |
| 862 | continueAfterServerUpdatePrepared: true, |
| 863 | }); |
| 864 | |
| 865 | if (preparedStatus === "completed after update marking") { |
| 866 | thread.session.status = "ready"; |
| 867 | binding = { |
| 868 | ...binding, |
| 869 | status: "stopped", |
| 870 | runtimePayload: { |
| 871 | activeTurnId: null, |
| 872 | continueAfterServerUpdate: turnId, |
| 873 | continueAfterServerUpdatePrepared: null, |
| 874 | }, |
| 875 | }; |
| 876 | yield* runReconciliation(input); |
| 877 | assert.deepStrictEqual(sends, []); |
| 878 | assert.equal(thread.session.status, "ready"); |
| 879 | return; |
| 880 | } |
| 881 | thread.session.status = |
| 882 | preparedStatus === "ready with failed scan" ? "ready" : preparedStatus; |
| 883 | yield* runReconciliation(input); |
| 884 | yield* Deferred.await(cleared); |
| 885 | assert.deepStrictEqual(sends, [ |
| 886 | { threadId: thread.id, continuation: true, interactionMode: "default" }, |
| 887 | ]); |
| 888 | assert.deepStrictEqual(binding.runtimePayload, { |
| 889 | activeTurnId: null, |
| 890 | continueAfterServerUpdate: null, |
| 891 | continueAfterServerUpdatePrepared: null, |
| 892 | }); |
| 893 | }), |
| 894 | ); |
| 895 | } |
| 896 | |
| 897 | it.effect("settles failed opt-in recovery without retrying the provider turn", () => |
| 898 | Effect.gen(function* () { |
| 899 | const turnId = TurnId.make("turn-failed-recovery"); |
| 900 | const thread = makeThread("thread-failed-recovery", "running", turnId); |
| 901 | const settled = yield* Deferred.make<void>(); |
| 902 | const sends: ProviderSendTurnInput[] = []; |
| 903 | const dispatched: OrchestrationCommand[] = []; |
| 904 | const preparedPayloads: unknown[] = []; |
| 905 | let binding: ProviderSessionDirectory.ProviderRuntimeBinding = { |
| 906 | threadId: thread.id, |
| 907 | provider: ProviderDriverKind.make("codex"), |
| 908 | providerInstanceId, |
| 909 | status: "running", |
| 910 | resumeCursor: { threadId: thread.id }, |
| 911 | runtimePayload: { activeTurnId: turnId }, |
| 912 | }; |
| 913 | yield* runReconciliation({ |
| 914 | threads: [thread], |
| 915 | continueAfterRestart: true, |
| 916 | providerService: { |
| 917 | ...makeProviderService(), |
| 918 | getCapabilities: () => |
| 919 | Effect.succeed({ sessionModelSwitch: "in-session", promptlessTurnContinuation: true }), |
| 920 | sendTurn: (input) => |
| 921 | Effect.gen(function* () { |
| 922 | sends.push(input); |
| 923 | preparedPayloads.push(binding.runtimePayload); |
| 924 | return yield* Effect.fail( |
| 925 | new ProviderSessionNotFoundError({ threadId: input.threadId }), |
| 926 | ); |
| 927 | }), |
| 928 | }, |
| 929 | directory: { |
| 930 | getBinding: () => Effect.sync(() => Option.some(binding)), |
| 931 | upsert: (next) => |
| 932 | Effect.sync(() => { |
| 933 | binding = next; |
| 934 | }), |
| 935 | getProvider: () => Effect.die("unused"), |
| 936 | listThreadIds: () => Effect.die("unused"), |
| 937 | listBindings: () => Effect.succeed([]), |
| 938 | }, |
| 939 | dispatch: (command) => |
| 940 | Effect.gen(function* () { |
| 941 | dispatched.push(command); |
| 942 | if (command.type === "thread.session.set" && command.session.status === "error") { |
| 943 | yield* Deferred.succeed(settled, undefined); |
| 944 | } |
| 945 | return { sequence: dispatched.length }; |
| 946 | }), |
| 947 | }); |
| 948 | yield* Deferred.await(settled); |
| 949 | assert.equal(sends.length, 1); |
| 950 | assert.deepStrictEqual(preparedPayloads, [ |
| 951 | { |
| 952 | activeTurnId: null, |
| 953 | continueAfterServerUpdate: turnId, |
| 954 | continueAfterServerUpdatePrepared: true, |
| 955 | }, |
| 956 | ]); |
| 957 | assert.deepStrictEqual( |
| 958 | dispatched.map( |
| 959 | (command) => |
| 960 | command.type === "thread.session.set" && { |
| 961 | status: command.session.status, |
| 962 | activeTurnId: command.session.activeTurnId, |
| 963 | }, |
| 964 | ), |
| 965 | [ |
| 966 | { status: "starting", activeTurnId: null }, |
| 967 | { status: "error", activeTurnId: null }, |
| 968 | ], |
| 969 | ); |
| 970 | assert.equal(binding.status, "stopped"); |
| 971 | assert.deepStrictEqual(binding.runtimePayload, { |
| 972 | activeTurnId: null, |
| 973 | continueAfterServerUpdate: null, |
| 974 | continueAfterServerUpdatePrepared: null, |
| 975 | }); |
| 976 | }), |
| 977 | ); |