| author | |
| committer | |
| log | 1abc717f0dbd08027b5be4ec45ae6c5bccf96418 |
| tree | cf44d63e9e682e393f40f490e074b2ef66cad6b3 |
| parent | a12589dc0bec60b498e72bdd2190871ee6b5c80d |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 304 insertions(+), 49 deletions(-)
apps/server/integration/orphanedProviderSessionStartup.integration.test.ts+123| ... | ... | @@ -7,10 +7,13 @@ import { |
| 7 | 7 | ProjectId, |
| 8 | 8 | ProviderDriverKind, |
| 9 | 9 | ProviderInstanceId, |
| 10 | type ProviderSendTurnInput, | |
| 10 | 11 | ThreadId, |
| 12 | TurnId, | |
| 11 | 13 | } from "@t3tools/contracts"; |
| 12 | 14 | import { assert, it } from "@effect/vitest"; |
| 13 | 15 | import * as DateTime from "effect/DateTime"; |
| 16 | import * as Deferred from "effect/Deferred"; | |
| 14 | 17 | import * as Effect from "effect/Effect"; |
| 15 | 18 | import * as Exit from "effect/Exit"; |
| 16 | 19 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -361,3 +364,123 @@ it.effect( |
| 361 | 364 | ), |
| 362 | 365 | ), |
| 363 | 366 | ); |
| 367 | ||
| 368 | it.effect.each(["opt-in desktop restart", "marked remote update"] as const)( | |
| 369 | "continues a newer persisted turn after %s", | |
| 370 | (restart) => | |
| 371 | Effect.gen(function* () { | |
| 372 | const config = yield* ServerConfig.ServerConfig; | |
| 373 | const createdAt = DateTime.formatIso(yield* DateTime.now); | |
| 374 | const activeTurnId = TurnId.make("turn-started-after-original-send"); | |
| 375 | const originalTurnId = TurnId.make("turn-from-original-send"); | |
| 376 | const sent = yield* Deferred.make<ProviderSendTurnInput>(); | |
| 377 | ||
| 378 | yield* Effect.gen(function* () { | |
| 379 | const engine = yield* OrchestrationEngine.OrchestrationEngineService; | |
| 380 | const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; | |
| 381 | yield* engine.dispatch({ | |
| 382 | type: "project.create", | |
| 383 | commandId: CommandId.make("create-restart-project"), | |
| 384 | projectId, | |
| 385 | title: "Restart continuation", | |
| 386 | workspaceRoot: "/tmp/startup-orphan-project", | |
| 387 | defaultModelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, | |
| 388 | createdAt, | |
| 389 | }); | |
| 390 | yield* engine.dispatch({ | |
| 391 | type: "thread.create", | |
| 392 | commandId: CommandId.make("create-restart-thread"), | |
| 393 | threadId, | |
| 394 | projectId, | |
| 395 | title: "Newer running turn", | |
| 396 | modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, | |
| 397 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 398 | runtimeMode: "full-access", | |
| 399 | branch: null, | |
| 400 | worktreePath: null, | |
| 401 | createdAt, | |
| 402 | }); | |
| 403 | yield* engine.dispatch({ | |
| 404 | type: "thread.session.set", | |
| 405 | commandId: CommandId.make("persist-newer-running-turn"), | |
| 406 | threadId, | |
| 407 | session: { | |
| 408 | threadId, | |
| 409 | status: "running", | |
| 410 | providerName: "codex", | |
| 411 | providerInstanceId, | |
| 412 | runtimeMode: "full-access", | |
| 413 | activeTurnId, | |
| 414 | lastError: null, | |
| 415 | updatedAt: createdAt, | |
| 416 | }, | |
| 417 | createdAt, | |
| 418 | }); | |
| 419 | yield* directory.upsert({ | |
| 420 | threadId, | |
| 421 | provider: ProviderDriverKind.make("codex"), | |
| 422 | providerInstanceId, | |
| 423 | status: "running", | |
| 424 | resumeCursor, | |
| 425 | runtimePayload: { activeTurnId: originalTurnId }, | |
| 426 | }); | |
| 427 | if (restart === "marked remote update") { | |
| 428 | assert.deepStrictEqual( | |
| 429 | yield* ServerRuntimeStartup.markRunningProviderSessionsForContinuation, | |
| 430 | [threadId], | |
| 431 | ); | |
| 432 | } | |
| 433 | }).pipe(Effect.provide(makePersistedRuntimeLayer(config.dbPath))); | |
| 434 | ||
| 435 | yield* Effect.gen(function* () { | |
| 436 | const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | |
| 437 | const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; | |
| 438 | const provider = yield* ProviderService.ProviderService; | |
| 439 | const before = Option.getOrThrow(yield* query.getThreadDetailById(threadId)); | |
| 440 | assert.equal(before.session?.activeTurnId, activeTurnId); | |
| 441 | assert.propertyVal( | |
| 442 | Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload, | |
| 443 | "activeTurnId", | |
| 444 | originalTurnId, | |
| 445 | ); | |
| 446 | yield* ServerRuntimeStartup.reconcileProviderSessions.pipe( | |
| 447 | Effect.provideService(ProviderService.ProviderService, { | |
| 448 | ...provider, | |
| 449 | getCapabilities: () => | |
| 450 | Effect.succeed({ | |
| 451 | sessionModelSwitch: "in-session", | |
| 452 | promptlessTurnContinuation: true, | |
| 453 | }), | |
| 454 | sendTurn: (input) => | |
| 455 | Deferred.succeed(sent, input).pipe( | |
| 456 | Effect.as({ threadId, turnId: TurnId.make("continued-turn") }), | |
| 457 | ), | |
| 458 | }), | |
| 459 | Effect.provide( | |
| 460 | ServerSettings.layerTest({ | |
| 461 | continueThreadsAfterServerUpdate: restart === "opt-in desktop restart", | |
| 462 | }), | |
| 463 | ), | |
| 464 | ); | |
| 465 | const after = Option.getOrThrow(yield* query.getThreadDetailById(threadId)); | |
| 466 | assert.equal(after.session?.status, "starting"); | |
| 467 | assert.equal(after.session?.activeTurnId, null); | |
| 468 | assert.equal(after.session?.lastError, null); | |
| 469 | assert.deepStrictEqual(yield* Deferred.await(sent), { | |
| 470 | threadId, | |
| 471 | continuation: true, | |
| 472 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| 473 | }); | |
| 474 | }).pipe( | |
| 475 | Effect.provide( | |
| 476 | Layer.mergeAll(makePersistedRuntimeLayer(config.dbPath), startupDependencies), | |
| 477 | ), | |
| 478 | ); | |
| 479 | }).pipe( | |
| 480 | Effect.provide( | |
| 481 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-restart-newer-turn-" }).pipe( | |
| 482 | Layer.provideMerge(NodeServices.layer), | |
| 483 | ), | |
| 484 | ), | |
| 485 | ), | |
| 486 | ); |
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+111-19| ... | ... | @@ -781,25 +781,123 @@ describe("isRecoverableThreadResumeError", () => { |
| 781 | 781 | }); |
| 782 | 782 | |
| 783 | 783 | describe("openCodexThread", () => { |
| 784 | it.effect("resumes metadata when historical turns contain unknown error values", () => | |
| 785 | Effect.gen(function* () { | |
| 786 | const response = makeThreadOpenResponse("saved-thread"); | |
| 787 | const calls: unknown[] = []; | |
| 788 | const opened = yield* openCodexThread({ | |
| 789 | client: { | |
| 790 | request: () => Effect.die("A valid resumed thread must not start fresh"), | |
| 791 | raw: { | |
| 792 | request: (method, payload) => { | |
| 793 | calls.push({ method, payload }); | |
| 794 | return Effect.succeed({ | |
| 795 | ...response, | |
| 796 | thread: { | |
| 797 | ...response.thread, | |
| 798 | turns: [ | |
| 799 | { | |
| 800 | id: "old-turn", | |
| 801 | status: "failed", | |
| 802 | items: [], | |
| 803 | error: { | |
| 804 | message: "Historical provider error", | |
| 805 | codexErrorInfo: "misalignment_policy_violation", | |
| 806 | }, | |
| 807 | }, | |
| 808 | ], | |
| 809 | }, | |
| 810 | }); | |
| 811 | }, | |
| 812 | }, | |
| 813 | }, | |
| 814 | threadId: ThreadId.make("thread-1"), | |
| 815 | runtimeMode: "auto", | |
| 816 | cwd: "/tmp/project", | |
| 817 | requestedModel: "gpt-5.3-codex", | |
| 818 | serviceTier: "fast", | |
| 819 | resumeThreadId: "saved-thread", | |
| 820 | }); | |
| 821 | ||
| 822 | NodeAssert.deepStrictEqual(opened, { | |
| 823 | cwd: response.cwd, | |
| 824 | model: response.model, | |
| 825 | thread: { id: "saved-thread" }, | |
| 826 | }); | |
| 827 | NodeAssert.deepStrictEqual(calls, [ | |
| 828 | { | |
| 829 | method: "thread/resume", | |
| 830 | payload: { | |
| 831 | threadId: "saved-thread", | |
| 832 | cwd: "/tmp/project", | |
| 833 | model: "gpt-5.3-codex", | |
| 834 | serviceTier: "fast", | |
| 835 | approvalPolicy: "on-request", | |
| 836 | sandbox: "workspace-write", | |
| 837 | approvalsReviewer: "auto_review", | |
| 838 | excludeTurns: true, | |
| 839 | }, | |
| 840 | }, | |
| 841 | ]); | |
| 842 | }), | |
| 843 | ); | |
| 844 | ||
| 845 | it.effect("rejects malformed required resume metadata without starting a fresh thread", () => | |
| 846 | Effect.gen(function* () { | |
| 847 | for (const invalidMetadata of [ | |
| 848 | { cwd: null }, | |
| 849 | { model: 42 }, | |
| 850 | { thread: { id: null } }, | |
| 851 | { thread: {} }, | |
| 852 | ]) { | |
| 853 | const error = yield* openCodexThread({ | |
| 854 | client: { | |
| 855 | request: () => Effect.die("Invalid resume metadata must not start a fresh thread"), | |
| 856 | raw: { | |
| 857 | request: () => | |
| 858 | Effect.succeed({ ...makeThreadOpenResponse("saved-thread"), ...invalidMetadata }), | |
| 859 | }, | |
| 860 | }, | |
| 861 | threadId: ThreadId.make("thread-1"), | |
| 862 | runtimeMode: "full-access", | |
| 863 | cwd: "/tmp/project", | |
| 864 | requestedModel: "gpt-5.3-codex", | |
| 865 | serviceTier: undefined, | |
| 866 | resumeThreadId: "saved-thread", | |
| 867 | }).pipe(Effect.flip); | |
| 868 | ||
| 869 | NodeAssert.ok(isCodexAppServerRequestError(error)); | |
| 870 | NodeAssert.equal(error.operation, "decode-payload"); | |
| 871 | NodeAssert.equal(error.method, "thread/resume"); | |
| 872 | } | |
| 873 | }), | |
| 874 | ); | |
| 875 | ||
| 784 | 876 | it.effect("falls back to thread/start when resume fails recoverably", () => |
| 785 | 877 | Effect.gen(function* () { |
| 786 | 878 | const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; |
| 787 | 879 | const started = makeThreadOpenResponse("fresh-thread"); |
| 788 | 880 | const client = { |
| 789 | request: <M extends "thread/start" | "thread/resume">( | |
| 790 | method: M, | |
| 791 | payload: CodexRpc.ClientRequestParamsByMethod[M], | |
| 792 | ) => { | |
| 793 | calls.push({ method, payload }); | |
| 794 | if (method === "thread/resume") { | |
| 881 | raw: { | |
| 882 | request: ( | |
| 883 | method: "thread/resume", | |
| 884 | payload: CodexRpc.ClientRequestParamsByMethod["thread/resume"], | |
| 885 | ) => { | |
| 886 | calls.push({ method, payload }); | |
| 795 | 887 | return Effect.fail( |
| 796 | 888 | new CodexErrors.CodexAppServerRequestError({ |
| 797 | 889 | code: -32603, |
| 798 | 890 | errorMessage: "thread not found", |
| 799 | 891 | }), |
| 800 | 892 | ); |
| 801 | } | |
| 802 | return Effect.succeed(started as CodexRpc.ClientRequestResponsesByMethod[M]); | |
| 893 | }, | |
| 894 | }, | |
| 895 | request: ( | |
| 896 | method: "thread/start", | |
| 897 | payload: CodexRpc.ClientRequestParamsByMethod["thread/start"], | |
| 898 | ) => { | |
| 899 | calls.push({ method, payload }); | |
| 900 | return Effect.succeed(started); | |
| 803 | 901 | }, |
| 804 | 902 | }; |
| 805 | 903 | |
| ... | ... | @@ -824,21 +922,15 @@ describe("openCodexThread", () => { |
| 824 | 922 | it.effect("propagates non-recoverable resume failures", () => |
| 825 | 923 | Effect.gen(function* () { |
| 826 | 924 | const client = { |
| 827 | request: <M extends "thread/start" | "thread/resume">( | |
| 828 | method: M, | |
| 829 | _payload: CodexRpc.ClientRequestParamsByMethod[M], | |
| 830 | ) => { | |
| 831 | if (method === "thread/resume") { | |
| 832 | return Effect.fail( | |
| 925 | request: () => Effect.die("Non-recoverable resume failures must not start a fresh thread"), | |
| 926 | raw: { | |
| 927 | request: () => | |
| 928 | Effect.fail( | |
| 833 | 929 | new CodexErrors.CodexAppServerRequestError({ |
| 834 | 930 | code: -32603, |
| 835 | 931 | errorMessage: "timed out waiting for server", |
| 836 | 932 | }), |
| 837 | ); | |
| 838 | } | |
| 839 | return Effect.succeed( | |
| 840 | makeThreadOpenResponse("fresh-thread") as CodexRpc.ClientRequestResponsesByMethod[M], | |
| 841 | ); | |
| 933 | ), | |
| 842 | 934 | }, |
| 843 | 935 | }; |
| 844 | 936 |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+38-11| ... | ... | @@ -676,17 +676,29 @@ export function isRecoverableThreadResumeError(error: unknown): boolean { |
| 676 | 676 | return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet)); |
| 677 | 677 | } |
| 678 | 678 | |
| 679 | type CodexThreadOpenResponse = | |
| 680 | | CodexRpc.ClientRequestResponsesByMethod["thread/start"] | |
| 681 | | CodexRpc.ClientRequestResponsesByMethod["thread/resume"]; | |
| 682 | ||
| 683 | type CodexThreadOpenMethod = "thread/start" | "thread/resume"; | |
| 679 | const CodexThreadResumeMetadata = Schema.Struct({ | |
| 680 | cwd: Schema.String, | |
| 681 | model: Schema.String, | |
| 682 | thread: Schema.Struct({ id: Schema.String }), | |
| 683 | }); | |
| 684 | const decodeCodexThreadResumeMetadata = Schema.decodeUnknownEffect(CodexThreadResumeMetadata); | |
| 684 | 685 | |
| 685 | 686 | interface CodexThreadOpenClient { |
| 686 | readonly request: <M extends CodexThreadOpenMethod>( | |
| 687 | method: M, | |
| 688 | payload: CodexRpc.ClientRequestParamsByMethod[M], | |
| 689 | ) => Effect.Effect<CodexRpc.ClientRequestResponsesByMethod[M], CodexErrors.CodexAppServerError>; | |
| 687 | readonly raw: { | |
| 688 | readonly request: ( | |
| 689 | method: "thread/resume", | |
| 690 | payload: CodexRpc.ClientRequestParamsByMethod["thread/resume"] & { | |
| 691 | readonly excludeTurns?: boolean; | |
| 692 | }, | |
| 693 | ) => Effect.Effect<unknown, CodexErrors.CodexAppServerError>; | |
| 694 | }; | |
| 695 | readonly request: ( | |
| 696 | method: "thread/start", | |
| 697 | payload: CodexRpc.ClientRequestParamsByMethod["thread/start"], | |
| 698 | ) => Effect.Effect< | |
| 699 | CodexRpc.ClientRequestResponsesByMethod["thread/start"], | |
| 700 | CodexErrors.CodexAppServerError | |
| 701 | >; | |
| 690 | 702 | } |
| 691 | 703 | |
| 692 | 704 | export const openCodexThread = (input: { |
| ... | ... | @@ -697,7 +709,7 @@ export const openCodexThread = (input: { |
| 697 | 709 | readonly requestedModel: string | undefined; |
| 698 | 710 | readonly serviceTier: CodexServiceTier | undefined; |
| 699 | 711 | readonly resumeThreadId: string | undefined; |
| 700 | }): Effect.Effect<CodexThreadOpenResponse, CodexErrors.CodexAppServerError> => { | |
| 712 | }): Effect.Effect<typeof CodexThreadResumeMetadata.Type, CodexErrors.CodexAppServerError> => { | |
| 701 | 713 | const resumeThreadId = input.resumeThreadId; |
| 702 | 714 | const startParams = buildThreadStartParams({ |
| 703 | 715 | cwd: input.cwd, |
| ... | ... | @@ -710,12 +722,27 @@ export const openCodexThread = (input: { |
| 710 | 722 | return input.client.request("thread/start", startParams); |
| 711 | 723 | } |
| 712 | 724 | |
| 713 | return input.client | |
| 725 | // Older providers may still return history despite excludeTurns. Only the | |
| 726 | // session metadata is needed here, so unrelated historical items cannot | |
| 727 | // prevent resuming a valid provider thread. | |
| 728 | return input.client.raw | |
| 714 | 729 | .request("thread/resume", { |
| 715 | 730 | threadId: resumeThreadId, |
| 716 | 731 | ...startParams, |
| 732 | excludeTurns: true, | |
| 717 | 733 | }) |
| 718 | 734 | .pipe( |
| 735 | Effect.flatMap((response) => | |
| 736 | decodeCodexThreadResumeMetadata(response).pipe( | |
| 737 | Effect.mapError((error) => | |
| 738 | CodexErrors.CodexAppServerRequestError.invalidPayload( | |
| 739 | "thread/resume", | |
| 740 | "decode-payload", | |
| 741 | error, | |
| 742 | ), | |
| 743 | ), | |
| 744 | ), | |
| 745 | ), | |
| 719 | 746 | Effect.catchIf(isRecoverableThreadResumeError, (error) => |
| 720 | 747 | Effect.logWarning("codex app-server thread resume fell back to fresh start", { |
| 721 | 748 | threadId: input.threadId, |
apps/server/src/serverRuntimeStartup.reconcile.test.ts+27-14| ... | ... | @@ -170,9 +170,16 @@ it.effect("marks active running sessions that have persisted resume state", () = |
| 170 | 170 | ); |
| 171 | 171 | }); |
| 172 | 172 | |
| 173 | it.effect.each(["marked update", "opt-in restart"] as const)( | |
| 174 | "continues %s sessions after activation with provider-specific input", | |
| 175 | (recovery) => | |
| 173 | it.effect.each( | |
| 174 | (["marked update", "opt-in restart"] as const).flatMap((recovery) => | |
| 175 | (["current", "previous", "missing"] as const).map((persistedTurn) => ({ | |
| 176 | recovery, | |
| 177 | persistedTurn, | |
| 178 | })), | |
| 179 | ), | |
| 180 | )( | |
| 181 | "continues $recovery sessions with a $persistedTurn directory turn", | |
| 182 | ({ recovery, persistedTurn }) => | |
| 176 | 183 | Effect.gen(function* () { |
| 177 | 184 | const codex = makeThread( |
| 178 | 185 | "thread-continue-codex", |
| ... | ... | @@ -204,15 +211,22 @@ it.effect.each(["marked update", "opt-in restart"] as const)( |
| 204 | 211 | thread.id === codex.id ? providerInstanceId : fallbackProviderInstanceId, |
| 205 | 212 | status: "running" as const, |
| 206 | 213 | resumeCursor: { threadId: thread.id }, |
| 207 | runtimePayload: | |
| 208 | recovery === "marked update" | |
| 214 | runtimePayload: { | |
| 215 | activeTurnId: | |
| 216 | thread.id === codex.id && persistedTurn !== "current" | |
| 217 | ? persistedTurn === "previous" | |
| 218 | ? "previous-provider-turn" | |
| 219 | : null | |
| 220 | : thread.session.activeTurnId, | |
| 221 | ...(recovery === "marked update" | |
| 209 | 222 | ? { |
| 210 | 223 | continueAfterServerUpdate: |
| 211 | 224 | thread.id === codex.id |
| 212 | 225 | ? codex.session.activeTurnId |
| 213 | 226 | : fallbackContinuationTurnId, |
| 214 | 227 | } |
| 215 | : { activeTurnId: thread.session.activeTurnId }, | |
| 228 | : {}), | |
| 229 | }, | |
| 216 | 230 | }, |
| 217 | 231 | ]), |
| 218 | 232 | ); |
| ... | ... | @@ -276,6 +290,12 @@ it.effect.each(["marked update", "opt-in restart"] as const)( |
| 276 | 290 | Effect.as({ sequence: dispatched.length }), |
| 277 | 291 | ), |
| 278 | 292 | }); |
| 293 | assert.isTrue( | |
| 294 | dispatched.every( | |
| 295 | (command) => | |
| 296 | command.type === "thread.session.set" && command.session.status === "starting", | |
| 297 | ), | |
| 298 | ); | |
| 279 | 299 | yield* Deferred.await(continuationSent); |
| 280 | 300 | yield* Deferred.await(continuationCleared); |
| 281 | 301 | |
| ... | ... | @@ -713,9 +733,7 @@ for (const scenario of [ |
| 713 | 733 | "stopped projection", |
| 714 | 734 | "finished projection", |
| 715 | 735 | "stopped binding", |
| 716 | "finished binding", | |
| 717 | 736 | "missing cursor", |
| 718 | "mismatched turn", | |
| 719 | 737 | "marked without cursor", |
| 720 | 738 | "marked stopped projection", |
| 721 | 739 | "marked superseded turn", |
| ... | ... | @@ -748,12 +766,7 @@ for (const scenario of [ |
| 748 | 766 | status: scenario === "stopped binding" ? "stopped" : "running", |
| 749 | 767 | ...(scenario.includes("cursor") ? {} : { resumeCursor: { threadId: thread.id } }), |
| 750 | 768 | runtimePayload: { |
| 751 | activeTurnId: | |
| 752 | scenario === "finished binding" | |
| 753 | ? null | |
| 754 | : scenario === "mismatched turn" || scenario === "marked superseded turn" | |
| 755 | ? "another-turn" | |
| 756 | : turnId, | |
| 769 | activeTurnId: scenario === "marked superseded turn" ? "another-turn" : turnId, | |
| 757 | 770 | ...(scenario.startsWith("marked") ? { continueAfterServerUpdate: turnId } : {}), |
| 758 | 771 | }, |
| 759 | 772 | }), |
apps/server/src/serverRuntimeStartup.ts+5-5| ... | ... | @@ -555,7 +555,8 @@ export const reconcileProviderSessions = Effect.gen(function* () { |
| 555 | 555 | continuationTurnId !== null && |
| 556 | 556 | (session.activeTurnId === null || continuationTurnId === session.activeTurnId) && |
| 557 | 557 | Option.isSome(binding) && |
| 558 | (readRuntimePayload(binding.value.runtimePayload).activeTurnId == null || | |
| 558 | (session.activeTurnId !== null || | |
| 559 | readRuntimePayload(binding.value.runtimePayload).activeTurnId == null || | |
| 559 | 560 | readRuntimePayload(binding.value.runtimePayload).activeTurnId === continuationTurnId); |
| 560 | 561 | const preparedWhileReady = |
| 561 | 562 | session.status === "ready" && |
| ... | ... | @@ -564,16 +565,15 @@ export const reconcileProviderSessions = Effect.gen(function* () { |
| 564 | 565 | Option.isSome(binding) && |
| 565 | 566 | readRuntimePayload(binding.value.runtimePayload).activeTurnId === null && |
| 566 | 567 | readRuntimePayload(binding.value.runtimePayload).continueAfterServerUpdatePrepared === true; |
| 567 | // Abrupt shutdowns cannot write an update marker. Require both durable | |
| 568 | // records to agree on an unfinished turn before recovering one implicitly. | |
| 568 | // Runtime events advance the projection's turn, but not the directory's | |
| 569 | // last admitted turn. Use the projection to identify interrupted work. | |
| 569 | 570 | const interruptedByRestart = |
| 570 | 571 | continueAfterRestart && |
| 571 | 572 | session.status === "running" && |
| 572 | 573 | session.activeTurnId !== null && |
| 573 | 574 | Option.isSome(binding) && |
| 574 | 575 | binding.value.status === "running" && |
| 575 | binding.value.resumeCursor != null && | |
| 576 | readRuntimePayload(binding.value.runtimePayload).activeTurnId === session.activeTurnId; | |
| 576 | binding.value.resumeCursor != null; | |
| 577 | 577 | const settleAsError = (lastError: string) => |
| 578 | 578 | Effect.gen(function* () { |
| 579 | 579 | yield* Effect.gen(function* () { |