| ... | ... | @@ -5,6 +5,8 @@ import * as NodePath from "node:path"; |
| 5 | 5 | import * as NodeChildProcess from "node:child_process"; |
| 6 | 6 | |
| 7 | 7 | import { |
| 8 | VcsProcessTimeoutError, |
| 9 | VcsProcessSpawnError, |
| 8 | 10 | ProviderDriverKind, |
| 9 | 11 | ProviderRuntimeEvent, |
| 10 | 12 | ProviderSession, |
| ... | ... | @@ -290,6 +292,9 @@ describe("CheckpointReactor", () => { |
| 290 | 292 | }); |
| 291 | 293 | |
| 292 | 294 | async function createHarness(options?: { |
| 295 | readonly checkpointLookupFailure?: ( |
| 296 | cwd: string, |
| 297 | ) => VcsProcessTimeoutError | VcsProcessSpawnError | undefined; |
| 293 | 298 | readonly hasSession?: boolean; |
| 294 | 299 | readonly seedFilesystemCheckpoints?: boolean; |
| 295 | 300 | readonly initializeGit?: boolean; |
| ... | ... | @@ -370,7 +375,20 @@ describe("CheckpointReactor", () => { |
| 370 | 375 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 371 | 376 | Layer.provideMerge(Layer.mock(PullRequestService)({ refreshAfterTurn })), |
| 372 | 377 | Layer.provideMerge(vcsStatusBroadcasterLayer), |
| 373 | | Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))), |
| 378 | Layer.provideMerge( |
| 379 | Layer.effect( |
| 380 | CheckpointStore.CheckpointStore, |
| 381 | CheckpointStore.make.pipe( |
| 382 | Effect.map((store) => ({ |
| 383 | ...store, |
| 384 | hasCheckpointRef: (input) => { |
| 385 | const failure = options?.checkpointLookupFailure?.(input.cwd); |
| 386 | return failure ? Effect.fail(failure) : store.hasCheckpointRef(input); |
| 387 | }, |
| 388 | })), |
| 389 | ), |
| 390 | ).pipe(Layer.provide(VcsDriverRegistry.layer)), |
| 391 | ), |
| 374 | 392 | Layer.provideMerge( |
| 375 | 393 | WorkspaceEntries.layer.pipe( |
| 376 | 394 | Layer.provide(WorkspacePaths.layer), |
| ... | ... | @@ -575,6 +593,77 @@ describe("CheckpointReactor", () => { |
| 575 | 593 | }), |
| 576 | 594 | ); |
| 577 | 595 | |
| 596 | effectIt.effect.each(["timeout", "spawn"] as const)( |
| 597 | "captures and finalizes a turn when previous checkpoint lookup fails (%s)", |
| 598 | (failureKind) => |
| 599 | Effect.gen(function* () { |
| 600 | let failLookup = false; |
| 601 | const harness = yield* Effect.promise(() => |
| 602 | createHarness({ |
| 603 | seedFilesystemCheckpoints: false, |
| 604 | checkpointLookupFailure: (cwd) => |
| 605 | !failLookup |
| 606 | ? undefined |
| 607 | : failureKind === "timeout" |
| 608 | ? new VcsProcessTimeoutError({ |
| 609 | operation: "test.refLookup", |
| 610 | command: "git", |
| 611 | cwd, |
| 612 | timeoutMs: 30000, |
| 613 | }) |
| 614 | : new VcsProcessSpawnError({ |
| 615 | operation: "test.refLookup", |
| 616 | command: "git", |
| 617 | cwd, |
| 618 | cause: new Error("transient lookup spawn failure"), |
| 619 | }), |
| 620 | }), |
| 621 | ); |
| 622 | const threadId = ThreadId.make("thread-1"); |
| 623 | const turnId = asTurnId("turn-ref-timeout"); |
| 624 | harness.provider.emit({ |
| 625 | type: "turn.started", |
| 626 | eventId: EventId.make("evt-ref-start"), |
| 627 | provider: ProviderDriverKind.make("codex"), |
| 628 | createdAt: "2026-01-01T00:00:00.000Z", |
| 629 | threadId, |
| 630 | turnId, |
| 631 | }); |
| 632 | expect(yield* harness.nextReceipt).toMatchObject({ type: "checkpoint.baseline.captured" }); |
| 633 | NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "new snapshot\n"); |
| 634 | failLookup = true; |
| 635 | harness.provider.emit({ |
| 636 | type: "turn.completed", |
| 637 | eventId: EventId.make("evt-ref-complete"), |
| 638 | provider: ProviderDriverKind.make("codex"), |
| 639 | createdAt: "2026-01-01T00:00:01.000Z", |
| 640 | threadId, |
| 641 | turnId, |
| 642 | payload: { state: "completed" }, |
| 643 | }); |
| 644 | yield* Effect.promise(harness.drain); |
| 645 | const ref = checkpointRefForThreadTurn(threadId, 1); |
| 646 | expect(gitShowFileAtRef(harness.cwd, ref, "README.md")).toBe("new snapshot\n"); |
| 647 | expect(yield* harness.nextReceipt).toMatchObject({ |
| 648 | type: "checkpoint.diff.finalized", |
| 649 | turnId, |
| 650 | }); |
| 651 | expect(yield* harness.nextReceipt).toMatchObject({ |
| 652 | type: "turn.processing.quiesced", |
| 653 | turnId, |
| 654 | }); |
| 655 | const model = yield* Effect.promise(harness.readModel); |
| 656 | expect(model.threads[0]?.checkpoints[0]).toMatchObject({ |
| 657 | checkpointRef: ref, |
| 658 | status: "ready", |
| 659 | files: [], |
| 660 | }); |
| 661 | expect( |
| 662 | model.threads[0]?.activities.some((a) => a.kind === "checkpoint.capture.failed"), |
| 663 | ).toBe(false); |
| 664 | }), |
| 665 | ); |
| 666 | |
| 578 | 667 | effectIt.effect("captures baseline and large turn summaries before completion receipts", () => |
| 579 | 668 | Effect.gen(function* () { |
| 580 | 669 | const harness = yield* Effect.promise(() => |