| author | |
| committer | |
| log | 7a089b2b2449c5c146e1a7d554a31e6d55924d3f |
| tree | 8298be14595bc34194e37a84dd7c618e15085fc0 |
| parent | f47a3fe90354a3dfa14276e3699541d552426276 |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 453 insertions(+), 212 deletions(-)
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+162| ... | @@ -12,6 +12,7 @@ import { | ... | @@ -12,6 +12,7 @@ import { |
| 12 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import { | 13 | import { |
| 14 | CommandId, | 14 | CommandId, |
| 15 | CheckpointRef, | ||
| 15 | DEFAULT_PROVIDER_INTERACTION_MODE, | 16 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 16 | EventId, | 17 | EventId, |
| 17 | MessageId, | 18 | MessageId, |
| ... | @@ -667,6 +668,167 @@ describe("CheckpointReactor", () => { | ... | @@ -667,6 +668,167 @@ describe("CheckpointReactor", () => { |
| 667 | }), | 668 | }), |
| 668 | ); | 669 | ); |
| 669 | 670 | ||
| 671 | effectIt.effect.each(["turn.completed", "turn.aborted"] as const)( | ||
| 672 | "captures every edit after a mid-turn diff update on %s", | ||
| 673 | (terminalEventType) => | ||
| 674 | Effect.gen(function* () { | ||
| 675 | const harness = yield* Effect.promise(() => | ||
| 676 | createHarness({ seedFilesystemCheckpoints: false }), | ||
| 677 | ); | ||
| 678 | const threadId = ThreadId.make("thread-1"); | ||
| 679 | const turnId = asTurnId("turn-1"); | ||
| 680 | const assistantMessageId = MessageId.make("assistant:mid-turn"); | ||
| 681 | const createdAt = "2026-01-01T00:00:00.000Z"; | ||
| 682 | yield* harness.engine.dispatch({ | ||
| 683 | type: "thread.session.set", | ||
| 684 | commandId: CommandId.make("cmd-mid-turn-running"), | ||
| 685 | threadId, | ||
| 686 | session: { | ||
| 687 | threadId, | ||
| 688 | status: "running", | ||
| 689 | providerName: "codex", | ||
| 690 | runtimeMode: "approval-required", | ||
| 691 | activeTurnId: turnId, | ||
| 692 | lastError: null, | ||
| 693 | updatedAt: createdAt, | ||
| 694 | }, | ||
| 695 | createdAt, | ||
| 696 | }); | ||
| 697 | harness.provider.emit({ | ||
| 698 | type: "turn.started", | ||
| 699 | eventId: EventId.make("evt-mid-turn-start"), | ||
| 700 | provider: ProviderDriverKind.make("codex"), | ||
| 701 | createdAt, | ||
| 702 | threadId, | ||
| 703 | turnId, | ||
| 704 | }); | ||
| 705 | expect(yield* harness.nextReceipt).toMatchObject({ | ||
| 706 | type: "checkpoint.baseline.captured", | ||
| 707 | }); | ||
| 708 | |||
| 709 | NodeFS.writeFileSync(NodePath.join(harness.cwd, "early.ts"), "export const early = 1;\n"); | ||
| 710 | yield* harness.engine.dispatch({ | ||
| 711 | type: "thread.turn.diff.complete", | ||
| 712 | commandId: CommandId.make("cmd-mid-turn-diff"), | ||
| 713 | threadId, | ||
| 714 | turnId, | ||
| 715 | completedAt: createdAt, | ||
| 716 | checkpointRef: CheckpointRef.make("provider-diff:mid-turn"), | ||
| 717 | assistantMessageId, | ||
| 718 | status: "missing", | ||
| 719 | files: [], | ||
| 720 | checkpointTurnCount: 1, | ||
| 721 | createdAt, | ||
| 722 | }); | ||
| 723 | yield* Effect.promise(harness.drain); | ||
| 724 | |||
| 725 | NodeFS.writeFileSync(NodePath.join(harness.cwd, "late.ts"), "export const late = 2;\n"); | ||
| 726 | yield* harness.engine.dispatch({ | ||
| 727 | type: "thread.session.set", | ||
| 728 | commandId: CommandId.make("cmd-mid-turn-settled"), | ||
| 729 | threadId, | ||
| 730 | session: { | ||
| 731 | threadId, | ||
| 732 | status: terminalEventType === "turn.aborted" ? "interrupted" : "ready", | ||
| 733 | providerName: "codex", | ||
| 734 | runtimeMode: "approval-required", | ||
| 735 | activeTurnId: null, | ||
| 736 | lastError: null, | ||
| 737 | updatedAt: createdAt, | ||
| 738 | }, | ||
| 739 | createdAt, | ||
| 740 | }); | ||
| 741 | harness.provider.emit({ | ||
| 742 | eventId: EventId.make("evt-mid-turn-complete"), | ||
| 743 | provider: ProviderDriverKind.make("codex"), | ||
| 744 | createdAt, | ||
| 745 | threadId, | ||
| 746 | turnId, | ||
| 747 | ...(terminalEventType === "turn.completed" | ||
| 748 | ? { type: "turn.completed", payload: { state: "completed" } } | ||
| 749 | : { type: "turn.aborted", payload: { reason: "Interrupted by user." } }), | ||
| 750 | }); | ||
| 751 | yield* Effect.promise(harness.drain); | ||
| 752 | expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 1))).toBe(true); | ||
| 753 | expect(yield* harness.nextReceipt).toMatchObject({ | ||
| 754 | type: "checkpoint.diff.finalized", | ||
| 755 | turnId, | ||
| 756 | checkpointTurnCount: 1, | ||
| 757 | }); | ||
| 758 | expect(yield* harness.nextReceipt).toMatchObject({ | ||
| 759 | type: "turn.processing.quiesced", | ||
| 760 | turnId, | ||
| 761 | }); | ||
| 762 | yield* Effect.promise(harness.drain); | ||
| 763 | const thread = (yield* Effect.promise(harness.readModel)).threads.find( | ||
| 764 | (entry) => entry.id === threadId, | ||
| 765 | ); | ||
| 766 | expect(thread?.checkpoints).toHaveLength(1); | ||
| 767 | expect(thread?.checkpoints[0]?.status).toBe("ready"); | ||
| 768 | expect(thread?.latestTurn?.state).toBe( | ||
| 769 | terminalEventType === "turn.aborted" ? "interrupted" : "completed", | ||
| 770 | ); | ||
| 771 | expect(thread?.checkpoints[0]?.assistantMessageId).toBe(assistantMessageId); | ||
| 772 | expect(thread?.checkpoints[0]?.files.map((file) => file.path)).toEqual([ | ||
| 773 | "early.ts", | ||
| 774 | "late.ts", | ||
| 775 | ]); | ||
| 776 | expect( | ||
| 777 | gitShowFileAtRef(harness.cwd, checkpointRefForThreadTurn(threadId, 1), "late.ts"), | ||
| 778 | ).toBe("export const late = 2;\n"); | ||
| 779 | |||
| 780 | const followUpTurnId = asTurnId("turn-2"); | ||
| 781 | harness.provider.emit({ | ||
| 782 | type: "turn.started", | ||
| 783 | eventId: EventId.make("evt-follow-up-start"), | ||
| 784 | provider: ProviderDriverKind.make("codex"), | ||
| 785 | createdAt, | ||
| 786 | threadId, | ||
| 787 | turnId: followUpTurnId, | ||
| 788 | }); | ||
| 789 | harness.provider.emit({ | ||
| 790 | type: "turn.completed", | ||
| 791 | eventId: EventId.make("evt-follow-up-complete"), | ||
| 792 | provider: ProviderDriverKind.make("codex"), | ||
| 793 | createdAt, | ||
| 794 | threadId, | ||
| 795 | turnId: followUpTurnId, | ||
| 796 | payload: { state: "completed" }, | ||
| 797 | }); | ||
| 798 | expect(yield* harness.nextReceipt).toMatchObject({ | ||
| 799 | type: "checkpoint.diff.finalized", | ||
| 800 | turnId: followUpTurnId, | ||
| 801 | checkpointTurnCount: 2, | ||
| 802 | }); | ||
| 803 | const followUp = (yield* Effect.promise(harness.readModel)).threads.find( | ||
| 804 | (entry) => entry.id === threadId, | ||
| 805 | ); | ||
| 806 | expect( | ||
| 807 | followUp?.checkpoints.find((checkpoint) => checkpoint.turnId === followUpTurnId), | ||
| 808 | ).toMatchObject({ checkpointTurnCount: 2, files: [] }); | ||
| 809 | }), | ||
| 810 | ); | ||
| 811 | |||
| 812 | it("does not capture an aborted turn without a matching start or active session", async () => { | ||
| 813 | const harness = await createHarness({ seedFilesystemCheckpoints: false }); | ||
| 814 | harness.provider.emit({ | ||
| 815 | type: "turn.aborted", | ||
| 816 | eventId: EventId.make("evt-untracked-abort"), | ||
| 817 | provider: ProviderDriverKind.make("codex"), | ||
| 818 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 819 | threadId: ThreadId.make("thread-1"), | ||
| 820 | turnId: asTurnId("turn-untracked"), | ||
| 821 | payload: { reason: "Interrupted before the turn started." }, | ||
| 822 | }); | ||
| 823 | await harness.drain(); | ||
| 824 | |||
| 825 | const thread = (await harness.readModel()).threads.find((entry) => entry.id === "thread-1"); | ||
| 826 | expect(thread?.checkpoints).toEqual([]); | ||
| 827 | expect( | ||
| 828 | gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), | ||
| 829 | ).toBe(false); | ||
| 830 | }); | ||
| 831 | |||
| 670 | it("refreshes local git status state on turn completion using the session cwd", async () => { | 832 | it("refreshes local git status state on turn completion using the session cwd", async () => { |
| 671 | const gitStatusRefreshCalls: string[] = []; | 833 | const gitStatusRefreshCalls: string[] = []; |
| 672 | const harness = await createHarness({ | 834 | const harness = await createHarness({ |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+16-91| ... | @@ -216,9 +216,7 @@ const make = Effect.gen(function* () { | ... | @@ -216,9 +216,7 @@ const make = Effect.gen(function* () { |
| 216 | return cwd; | 216 | return cwd; |
| 217 | }); | 217 | }); |
| 218 | 218 | ||
| 219 | // Shared tail for both capture paths: creates the git checkpoint ref, diffs | 219 | // Capture the completed turn's files, then publish its summary and receipts. |
| 220 | // it against the previous turn, then dispatches the domain events to update | ||
| 221 | // the orchestration read model. | ||
| 222 | const captureAndDispatchCheckpoint = Effect.fn("captureAndDispatchCheckpoint")(function* (input: { | 220 | const captureAndDispatchCheckpoint = Effect.fn("captureAndDispatchCheckpoint")(function* (input: { |
| 223 | readonly threadId: ThreadId; | 221 | readonly threadId: ThreadId; |
| 224 | readonly turnId: TurnId; | 222 | readonly turnId: TurnId; |
| ... | @@ -353,9 +351,9 @@ const make = Effect.gen(function* () { | ... | @@ -353,9 +351,9 @@ const make = Effect.gen(function* () { |
| 353 | }); | 351 | }); |
| 354 | }); | 352 | }); |
| 355 | 353 | ||
| 356 | // Captures a real git checkpoint when a turn completes via a runtime event. | 354 | // Capture the files left by a completed or interrupted turn. |
| 357 | const captureCheckpointFromTurnCompletion = Effect.fn("captureCheckpointFromTurnCompletion")( | 355 | const captureCheckpointFromTurnCompletion = Effect.fn("captureCheckpointFromTurnCompletion")( |
| 358 | function* (event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>) { | 356 | function* (event: Extract<ProviderRuntimeEvent, { type: "turn.completed" | "turn.aborted" }>) { |
| 359 | const turnId = toTurnId(event.turnId); | 357 | const turnId = toTurnId(event.turnId); |
| 360 | if (!turnId) { | 358 | if (!turnId) { |
| 361 | return; | 359 | return; |
| ... | @@ -412,75 +410,16 @@ const make = Effect.gen(function* () { | ... | @@ -412,75 +410,16 @@ const make = Effect.gen(function* () { |
| 412 | thread, | 410 | thread, |
| 413 | cwd: checkpointCwd, | 411 | cwd: checkpointCwd, |
| 414 | turnCount: nextTurnCount, | 412 | turnCount: nextTurnCount, |
| 415 | status: checkpointStatusFromRuntime(event.payload.state), | 413 | status: |
| 416 | assistantMessageId: undefined, | 414 | event.type === "turn.aborted" |
| 415 | ? "ready" | ||
| 416 | : checkpointStatusFromRuntime(event.payload.state), | ||
| 417 | assistantMessageId: existingPlaceholder?.assistantMessageId ?? undefined, | ||
| 417 | createdAt: event.createdAt, | 418 | createdAt: event.createdAt, |
| 418 | }); | 419 | }); |
| 419 | }, | 420 | }, |
| 420 | ); | 421 | ); |
| 421 | 422 | ||
| 422 | // Captures a real git checkpoint when a placeholder checkpoint (status "missing") | ||
| 423 | // is detected via a domain event. This replaces the placeholder with a real | ||
| 424 | // git-ref-based checkpoint. | ||
| 425 | // | ||
| 426 | // ProviderRuntimeIngestion creates placeholder checkpoints on turn.diff.updated | ||
| 427 | // events from the Codex runtime. This handler fires when the corresponding | ||
| 428 | // domain event arrives, allowing the reactor to capture the actual filesystem | ||
| 429 | // state into a git ref and dispatch a replacement checkpoint. | ||
| 430 | const captureCheckpointFromPlaceholder = Effect.fn("captureCheckpointFromPlaceholder")(function* ( | ||
| 431 | event: Extract<OrchestrationEvent, { type: "thread.turn-diff-completed" }>, | ||
| 432 | ) { | ||
| 433 | const { threadId, turnId, checkpointTurnCount, status } = event.payload; | ||
| 434 | |||
| 435 | // Only replace placeholders; skip events from our own real captures. | ||
| 436 | if (status !== "missing") { | ||
| 437 | return; | ||
| 438 | } | ||
| 439 | |||
| 440 | const thread = yield* resolveThreadDetail(threadId); | ||
| 441 | if (!thread) { | ||
| 442 | yield* Effect.logWarning("checkpoint capture from placeholder skipped: thread not found", { | ||
| 443 | threadId, | ||
| 444 | }); | ||
| 445 | return; | ||
| 446 | } | ||
| 447 | |||
| 448 | // If a real checkpoint already exists for this turn, skip. | ||
| 449 | if ( | ||
| 450 | thread.checkpoints.some( | ||
| 451 | (checkpoint) => checkpoint.turnId === turnId && checkpoint.status !== "missing", | ||
| 452 | ) | ||
| 453 | ) { | ||
| 454 | yield* Effect.logDebug( | ||
| 455 | "checkpoint capture from placeholder skipped: real checkpoint already exists", | ||
| 456 | { threadId, turnId }, | ||
| 457 | ); | ||
| 458 | return; | ||
| 459 | } | ||
| 460 | |||
| 461 | const projects = yield* resolveThreadProjects(thread.projectId); | ||
| 462 | const checkpointCwd = yield* resolveCheckpointCwd({ | ||
| 463 | threadId, | ||
| 464 | thread, | ||
| 465 | projects, | ||
| 466 | preferSessionRuntime: true, | ||
| 467 | }); | ||
| 468 | if (!checkpointCwd) { | ||
| 469 | return; | ||
| 470 | } | ||
| 471 | |||
| 472 | yield* captureAndDispatchCheckpoint({ | ||
| 473 | threadId, | ||
| 474 | turnId, | ||
| 475 | thread, | ||
| 476 | cwd: checkpointCwd, | ||
| 477 | turnCount: checkpointTurnCount, | ||
| 478 | status: "ready", | ||
| 479 | assistantMessageId: event.payload.assistantMessageId ?? undefined, | ||
| 480 | createdAt: event.payload.completedAt, | ||
| 481 | }); | ||
| 482 | }); | ||
| 483 | |||
| 484 | const ensurePreTurnBaselineFromTurnStart = Effect.fn("ensurePreTurnBaselineFromTurnStart")( | 423 | const ensurePreTurnBaselineFromTurnStart = Effect.fn("ensurePreTurnBaselineFromTurnStart")( |
| 485 | function* (event: Extract<ProviderRuntimeEvent, { type: "turn.started" }>) { | 424 | function* (event: Extract<ProviderRuntimeEvent, { type: "turn.started" }>) { |
| 486 | const turnId = toTurnId(event.turnId); | 425 | const turnId = toTurnId(event.turnId); |
| ... | @@ -876,25 +815,6 @@ const make = Effect.gen(function* () { | ... | @@ -876,25 +815,6 @@ const make = Effect.gen(function* () { |
| 876 | ); | 815 | ); |
| 877 | return; | 816 | return; |
| 878 | } | 817 | } |
| 879 | |||
| 880 | // When ProviderRuntimeIngestion creates a placeholder checkpoint (status "missing") | ||
| 881 | // from a turn.diff.updated runtime event, capture the real git checkpoint to | ||
| 882 | // replace it. ProviderService broadcasts runtime events to each subscriber. | ||
| 883 | // This domain-event path also captures checkpoints from turn diff updates. | ||
| 884 | if (event.type === "thread.turn-diff-completed") { | ||
| 885 | yield* captureCheckpointFromPlaceholder(event).pipe( | ||
| 886 | Effect.catch((error) => | ||
| 887 | Effect.flatMap(nowIso, (createdAt) => | ||
| 888 | appendCaptureFailureActivity({ | ||
| 889 | threadId: event.payload.threadId, | ||
| 890 | turnId: event.payload.turnId, | ||
| 891 | detail: error.message, | ||
| 892 | createdAt, | ||
| 893 | }).pipe(Effect.catch(() => Effect.void)), | ||
| 894 | ), | ||
| 895 | ), | ||
| 896 | ); | ||
| 897 | } | ||
| 898 | }); | 818 | }); |
| 899 | 819 | ||
| 900 | const processRuntimeEvent = Effect.fn("processRuntimeEvent")(function* ( | 820 | const processRuntimeEvent = Effect.fn("processRuntimeEvent")(function* ( |
| ... | @@ -939,7 +859,13 @@ const make = Effect.gen(function* () { | ... | @@ -939,7 +859,13 @@ const make = Effect.gen(function* () { |
| 939 | pending.delete(event.threadId); | 859 | pending.delete(event.threadId); |
| 940 | yield* pullRequests.refreshAfterTurn; | 860 | yield* pullRequests.refreshAfterTurn; |
| 941 | } | 861 | } |
| 942 | if (event.type === "turn.aborted") return; | 862 | if ( |
| 863 | event.type === "turn.aborted" && | ||
| 864 | !isTrackedTurn && | ||
| 865 | !sameId(thread?.session?.activeTurnId, turnId) | ||
| 866 | ) { | ||
| 867 | return; | ||
| 868 | } | ||
| 943 | yield* captureCheckpointFromTurnCompletion(event).pipe( | 869 | yield* captureCheckpointFromTurnCompletion(event).pipe( |
| 944 | Effect.catch((error) => | 870 | Effect.catch((error) => |
| 945 | Effect.flatMap(nowIso, (createdAt) => | 871 | Effect.flatMap(nowIso, (createdAt) => |
| ... | @@ -987,8 +913,7 @@ const make = Effect.gen(function* () { | ... | @@ -987,8 +913,7 @@ const make = Effect.gen(function* () { |
| 987 | if ( | 913 | if ( |
| 988 | event.type !== "thread.turn-start-requested" && | 914 | event.type !== "thread.turn-start-requested" && |
| 989 | event.type !== "thread.message-sent" && | 915 | event.type !== "thread.message-sent" && |
| 990 | event.type !== "thread.checkpoint-revert-requested" && | 916 | event.type !== "thread.checkpoint-revert-requested" |
| 991 | event.type !== "thread.turn-diff-completed" | ||
| 992 | ) { | 917 | ) { |
| 993 | return Effect.void; | 918 | return Effect.void; |
| 994 | } | 919 | } |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+4-1| ... | @@ -1573,7 +1573,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti | ... | @@ -1573,7 +1573,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti |
| 1573 | yield* projectionTurnRepository.upsertByTurnId({ | 1573 | yield* projectionTurnRepository.upsertByTurnId({ |
| 1574 | ...existingTurn.value, | 1574 | ...existingTurn.value, |
| 1575 | assistantMessageId: event.payload.assistantMessageId, | 1575 | assistantMessageId: event.payload.assistantMessageId, |
| 1576 | state: turnStillRunning ? existingTurn.value.state : nextState, | 1576 | state: |
| 1577 | turnStillRunning || existingTurn.value.state === "interrupted" | ||
| 1578 | ? existingTurn.value.state | ||
| 1579 | : nextState, | ||
| 1577 | checkpointTurnCount: event.payload.checkpointTurnCount, | 1580 | checkpointTurnCount: event.payload.checkpointTurnCount, |
| 1578 | checkpointRef: event.payload.checkpointRef, | 1581 | checkpointRef: event.payload.checkpointRef, |
| 1579 | checkpointStatus: event.payload.status, | 1582 | checkpointStatus: event.payload.status, |
apps/server/src/orchestration/projector.test.ts+215-90| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | type OrchestrationEvent, | 7 | type OrchestrationEvent, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 10 | import { it as effectIt } from "@effect/vitest"; | ||
| 10 | import { describe, expect, it } from "vite-plus/test"; | 11 | import { describe, expect, it } from "vite-plus/test"; |
| 11 | 12 | ||
| 12 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; | 13 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; |
| ... | @@ -239,110 +240,234 @@ describe("orchestration projector", () => { | ... | @@ -239,110 +240,234 @@ describe("orchestration projector", () => { |
| 239 | expect(next.threads).toEqual([]); | 240 | expect(next.threads).toEqual([]); |
| 240 | }); | 241 | }); |
| 241 | 242 | ||
| 242 | it("tracks latest turn id from session lifecycle events", async () => { | 243 | effectIt.effect.each([ |
| 243 | const createdAt = "2026-02-23T08:00:00.000Z"; | 244 | ["ready", "completed"], |
| 244 | const startedAt = "2026-02-23T08:00:05.000Z"; | 245 | ["interrupted", "interrupted"], |
| 245 | const model = createEmptyReadModel(createdAt); | 246 | ] as const)( |
| 246 | 247 | "preserves the turn state after a %s session captures its checkpoint", | |
| 247 | const afterCreate = await Effect.runPromise( | 248 | ([status, state]) => |
| 248 | projectEvent( | 249 | Effect.gen(function* () { |
| 249 | model, | 250 | const createdAt = "2026-02-23T08:00:00.000Z"; |
| 250 | makeEvent({ | 251 | const startedAt = "2026-02-23T08:00:05.000Z"; |
| 251 | sequence: 1, | 252 | const model = createEmptyReadModel(createdAt); |
| 252 | type: "thread.created", | ||
| 253 | aggregateKind: "thread", | ||
| 254 | aggregateId: "thread-1", | ||
| 255 | occurredAt: createdAt, | ||
| 256 | commandId: "cmd-create", | ||
| 257 | payload: { | ||
| 258 | threadId: "thread-1", | ||
| 259 | projectId: "project-1", | ||
| 260 | title: "demo", | ||
| 261 | modelSelection: { | ||
| 262 | provider: ProviderDriverKind.make("codex"), | ||
| 263 | model: "gpt-5.3-codex", | ||
| 264 | }, | ||
| 265 | runtimeMode: "full-access", | ||
| 266 | branch: null, | ||
| 267 | worktreePath: null, | ||
| 268 | createdAt, | ||
| 269 | updatedAt: createdAt, | ||
| 270 | }, | ||
| 271 | }), | ||
| 272 | ), | ||
| 273 | ); | ||
| 274 | 253 | ||
| 275 | const settledAt = "2026-02-23T08:01:00.000Z"; | 254 | const afterCreate = yield* projectEvent( |
| 276 | const [afterRunning, afterReady] = await Effect.runPromise( | 255 | model, |
| 277 | Effect.flatMap( | ||
| 278 | projectEvent( | ||
| 279 | afterCreate, | ||
| 280 | makeEvent({ | 256 | makeEvent({ |
| 281 | sequence: 2, | 257 | sequence: 1, |
| 282 | type: "thread.session-set", | 258 | type: "thread.created", |
| 283 | aggregateKind: "thread", | 259 | aggregateKind: "thread", |
| 284 | aggregateId: "thread-1", | 260 | aggregateId: "thread-1", |
| 285 | occurredAt: startedAt, | 261 | occurredAt: createdAt, |
| 286 | commandId: "cmd-running", | 262 | commandId: "cmd-create", |
| 287 | payload: { | 263 | payload: { |
| 288 | threadId: "thread-1", | 264 | threadId: "thread-1", |
| 289 | session: { | 265 | projectId: "project-1", |
| 290 | threadId: "thread-1", | 266 | title: "demo", |
| 291 | status: "running", | 267 | modelSelection: { |
| 292 | providerName: "codex", | 268 | provider: ProviderDriverKind.make("codex"), |
| 293 | providerSessionId: "session-1", | 269 | model: "gpt-5.3-codex", |
| 294 | providerThreadId: "provider-thread-1", | ||
| 295 | runtimeMode: "approval-required", | ||
| 296 | activeTurnId: "turn-1", | ||
| 297 | lastError: null, | ||
| 298 | updatedAt: startedAt, | ||
| 299 | }, | 270 | }, |
| 271 | runtimeMode: "full-access", | ||
| 272 | branch: null, | ||
| 273 | worktreePath: null, | ||
| 274 | createdAt, | ||
| 275 | updatedAt: createdAt, | ||
| 300 | }, | 276 | }, |
| 301 | }), | 277 | }), |
| 302 | ), | 278 | ); |
| 303 | (running) => | 279 | |
| 304 | Effect.map( | 280 | const settledAt = "2026-02-23T08:01:00.000Z"; |
| 305 | projectEvent( | 281 | const [afterRunning, afterReady] = yield* Effect.flatMap( |
| 306 | running, | 282 | projectEvent( |
| 307 | makeEvent({ | 283 | afterCreate, |
| 308 | sequence: 3, | 284 | makeEvent({ |
| 309 | type: "thread.session-set", | 285 | sequence: 2, |
| 310 | aggregateKind: "thread", | 286 | type: "thread.session-set", |
| 311 | aggregateId: "thread-1", | 287 | aggregateKind: "thread", |
| 312 | occurredAt: settledAt, | 288 | aggregateId: "thread-1", |
| 313 | commandId: "cmd-ready", | 289 | occurredAt: startedAt, |
| 314 | payload: { | 290 | commandId: "cmd-running", |
| 291 | payload: { | ||
| 292 | threadId: "thread-1", | ||
| 293 | session: { | ||
| 315 | threadId: "thread-1", | 294 | threadId: "thread-1", |
| 316 | session: { | 295 | status: "running", |
| 296 | providerName: "codex", | ||
| 297 | providerSessionId: "session-1", | ||
| 298 | providerThreadId: "provider-thread-1", | ||
| 299 | runtimeMode: "approval-required", | ||
| 300 | activeTurnId: "turn-1", | ||
| 301 | lastError: null, | ||
| 302 | updatedAt: startedAt, | ||
| 303 | }, | ||
| 304 | }, | ||
| 305 | }), | ||
| 306 | ), | ||
| 307 | (running) => | ||
| 308 | Effect.map( | ||
| 309 | projectEvent( | ||
| 310 | running, | ||
| 311 | makeEvent({ | ||
| 312 | sequence: 3, | ||
| 313 | type: "thread.session-set", | ||
| 314 | aggregateKind: "thread", | ||
| 315 | aggregateId: "thread-1", | ||
| 316 | occurredAt: settledAt, | ||
| 317 | commandId: "cmd-ready", | ||
| 318 | payload: { | ||
| 317 | threadId: "thread-1", | 319 | threadId: "thread-1", |
| 318 | status: "ready", | 320 | session: { |
| 319 | providerName: "codex", | 321 | threadId: "thread-1", |
| 320 | providerSessionId: "session-1", | 322 | status, |
| 321 | providerThreadId: "provider-thread-1", | 323 | providerName: "codex", |
| 322 | runtimeMode: "approval-required", | 324 | providerSessionId: "session-1", |
| 323 | activeTurnId: null, | 325 | providerThreadId: "provider-thread-1", |
| 324 | lastError: null, | 326 | runtimeMode: "approval-required", |
| 325 | updatedAt: settledAt, | 327 | activeTurnId: null, |
| 328 | lastError: null, | ||
| 329 | updatedAt: settledAt, | ||
| 330 | }, | ||
| 326 | }, | 331 | }, |
| 327 | }, | 332 | }), |
| 328 | }), | 333 | ), |
| 334 | (ready) => [running, ready] as const, | ||
| 329 | ), | 335 | ), |
| 330 | (ready) => [running, ready] as const, | 336 | ); |
| 331 | ), | ||
| 332 | ), | ||
| 333 | ); | ||
| 334 | 337 | ||
| 335 | const thread = afterRunning.threads[0]; | 338 | const thread = afterRunning.threads[0]; |
| 336 | expect(thread?.latestTurn?.turnId).toBe("turn-1"); | 339 | expect(thread?.latestTurn?.turnId).toBe("turn-1"); |
| 337 | expect(thread?.session?.status).toBe("running"); | 340 | expect(thread?.session?.status).toBe("running"); |
| 338 | 341 | ||
| 339 | // Leaving the "running" session status settles the running turn with the | 342 | // Leaving the "running" session status settles the running turn with the |
| 340 | // session timestamp as the turn end. | 343 | // session timestamp as the turn end. |
| 341 | const settledThread = afterReady.threads[0]; | 344 | const settledThread = afterReady.threads[0]; |
| 342 | expect(settledThread?.latestTurn?.turnId).toBe("turn-1"); | 345 | expect(settledThread?.latestTurn?.turnId).toBe("turn-1"); |
| 343 | expect(settledThread?.latestTurn?.state).toBe("completed"); | 346 | expect(settledThread?.latestTurn?.state).toBe(state); |
| 344 | expect(settledThread?.latestTurn?.completedAt).toBe(settledAt); | 347 | expect(settledThread?.latestTurn?.completedAt).toBe(settledAt); |
| 345 | }); | 348 | |
| 349 | const captured = yield* projectEvent( | ||
| 350 | afterReady, | ||
| 351 | makeEvent({ | ||
| 352 | sequence: 4, | ||
| 353 | type: "thread.turn-diff-completed", | ||
| 354 | aggregateKind: "thread", | ||
| 355 | aggregateId: "thread-1", | ||
| 356 | occurredAt: settledAt, | ||
| 357 | commandId: "cmd-final-checkpoint", | ||
| 358 | payload: { | ||
| 359 | threadId: "thread-1", | ||
| 360 | turnId: "turn-1", | ||
| 361 | checkpointTurnCount: 1, | ||
| 362 | checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", | ||
| 363 | status: "ready", | ||
| 364 | files: [], | ||
| 365 | assistantMessageId: "assistant:turn-1", | ||
| 366 | completedAt: settledAt, | ||
| 367 | }, | ||
| 368 | }), | ||
| 369 | ); | ||
| 370 | expect(captured.threads[0]?.latestTurn?.state).toBe(state); | ||
| 371 | expect(captured.threads[0]?.checkpoints[0]?.status).toBe("ready"); | ||
| 372 | }), | ||
| 373 | ); | ||
| 374 | |||
| 375 | effectIt.effect.each([null, "ready", "interrupted", "stopped"] as const)( | ||
| 376 | "replaces a missing checkpoint without inventing interruption for a %s session", | ||
| 377 | (sessionStatus) => | ||
| 378 | Effect.gen(function* () { | ||
| 379 | const now = "2026-09-04T23:00:00.000Z"; | ||
| 380 | const threadId = "thread-placeholder"; | ||
| 381 | const event = (sequence: number, type: OrchestrationEvent["type"], payload: unknown) => | ||
| 382 | makeEvent({ | ||
| 383 | sequence, | ||
| 384 | type, | ||
| 385 | payload, | ||
| 386 | aggregateKind: "thread", | ||
| 387 | aggregateId: threadId, | ||
| 388 | occurredAt: now, | ||
| 389 | commandId: `placeholder-${sequence}`, | ||
| 390 | }); | ||
| 391 | let model = yield* projectEvent( | ||
| 392 | createEmptyReadModel(now), | ||
| 393 | event(1, "thread.created", { | ||
| 394 | threadId, | ||
| 395 | projectId: "project-1", | ||
| 396 | title: "Placeholder", | ||
| 397 | modelSelection: { instanceId: "codex", model: "test" }, | ||
| 398 | runtimeMode: "full-access", | ||
| 399 | interactionMode: "default", | ||
| 400 | branch: null, | ||
| 401 | worktreePath: null, | ||
| 402 | createdAt: now, | ||
| 403 | updatedAt: now, | ||
| 404 | }), | ||
| 405 | ); | ||
| 406 | const checkpoint = { | ||
| 407 | threadId, | ||
| 408 | turnId: "turn-placeholder", | ||
| 409 | checkpointTurnCount: 1, | ||
| 410 | checkpointRef: "provider-diff:placeholder", | ||
| 411 | files: [], | ||
| 412 | assistantMessageId: "assistant:placeholder", | ||
| 413 | completedAt: now, | ||
| 414 | }; | ||
| 415 | if (sessionStatus === "interrupted" || sessionStatus === "stopped") { | ||
| 416 | model = yield* projectEvent( | ||
| 417 | model, | ||
| 418 | event(2, "thread.session-set", { | ||
| 419 | threadId, | ||
| 420 | session: { | ||
| 421 | threadId, | ||
| 422 | status: "running", | ||
| 423 | providerName: "codex", | ||
| 424 | runtimeMode: "full-access", | ||
| 425 | activeTurnId: "turn-placeholder", | ||
| 426 | lastError: null, | ||
| 427 | updatedAt: now, | ||
| 428 | }, | ||
| 429 | }), | ||
| 430 | ); | ||
| 431 | } | ||
| 432 | model = yield* projectEvent( | ||
| 433 | model, | ||
| 434 | event(3, "thread.turn-diff-completed", { | ||
| 435 | ...checkpoint, | ||
| 436 | status: "missing", | ||
| 437 | }), | ||
| 438 | ); | ||
| 439 | if (sessionStatus !== null) { | ||
| 440 | model = yield* projectEvent( | ||
| 441 | model, | ||
| 442 | event(4, "thread.session-set", { | ||
| 443 | threadId, | ||
| 444 | session: { | ||
| 445 | threadId, | ||
| 446 | status: sessionStatus, | ||
| 447 | providerName: "codex", | ||
| 448 | runtimeMode: "full-access", | ||
| 449 | activeTurnId: null, | ||
| 450 | lastError: null, | ||
| 451 | updatedAt: now, | ||
| 452 | }, | ||
| 453 | }), | ||
| 454 | ); | ||
| 455 | } | ||
| 456 | model = yield* projectEvent( | ||
| 457 | model, | ||
| 458 | event(5, "thread.turn-diff-completed", { | ||
| 459 | ...checkpoint, | ||
| 460 | status: "ready", | ||
| 461 | checkpointRef: "refs/t3/checkpoints/thread-placeholder/turn/1", | ||
| 462 | }), | ||
| 463 | ); | ||
| 464 | expect(model.threads[0]?.latestTurn?.state).toBe( | ||
| 465 | sessionStatus === "interrupted" || sessionStatus === "stopped" | ||
| 466 | ? "interrupted" | ||
| 467 | : "completed", | ||
| 468 | ); | ||
| 469 | }), | ||
| 470 | ); | ||
| 346 | 471 | ||
| 347 | it("updates canonical thread runtime mode from thread.runtime-mode-set", async () => { | 472 | it("updates canonical thread runtime mode from thread.runtime-mode-set", async () => { |
| 348 | const createdAt = "2026-02-23T08:00:00.000Z"; | 473 | const createdAt = "2026-02-23T08:00:00.000Z"; |
apps/server/src/orchestration/projector.ts+6-2| ... | @@ -64,7 +64,7 @@ function retainThreadActivities(activities: OrchestrationThread["activities"]) { | ... | @@ -64,7 +64,7 @@ function retainThreadActivities(activities: OrchestrationThread["activities"]) { |
| 64 | 64 | ||
| 65 | function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { | 65 | function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { |
| 66 | if (status === "error") return "error" as const; | 66 | if (status === "error") return "error" as const; |
| 67 | if (status === "missing") return "interrupted" as const; | 67 | // Match SQL and client projections: a missing git ref is not an interruption. |
| 68 | return "completed" as const; | 68 | return "completed" as const; |
| 69 | } | 69 | } |
| 70 | 70 | ||
| ... | @@ -746,7 +746,11 @@ export function projectEvent( | ... | @@ -746,7 +746,11 @@ export function projectEvent( |
| 746 | ? thread.latestTurn | 746 | ? thread.latestTurn |
| 747 | : { | 747 | : { |
| 748 | turnId: payload.turnId, | 748 | turnId: payload.turnId, |
| 749 | state: checkpointStatusToLatestTurnState(payload.status), | 749 | state: |
| 750 | thread.latestTurn?.turnId === payload.turnId && | ||
| 751 | thread.latestTurn.state === "interrupted" | ||
| 752 | ? "interrupted" | ||
| 753 | : checkpointStatusToLatestTurnState(payload.status), | ||
| 750 | requestedAt: | 754 | requestedAt: |
| 751 | thread.latestTurn?.turnId === payload.turnId | 755 | thread.latestTurn?.turnId === payload.turnId |
| 752 | ? thread.latestTurn.requestedAt | 756 | ? thread.latestTurn.requestedAt |
packages/client-runtime/src/state/threadReducer.test.ts+46-27| ... | @@ -1129,33 +1129,52 @@ describe("applyThreadDetailEvent", () => { | ... | @@ -1129,33 +1129,52 @@ describe("applyThreadDetailEvent", () => { |
| 1129 | }); | 1129 | }); |
| 1130 | 1130 | ||
| 1131 | describe("thread.turn-diff-completed", () => { | 1131 | describe("thread.turn-diff-completed", () => { |
| 1132 | it("adds a checkpoint and updates latestTurn", () => { | 1132 | it.each([null, "interrupted"] as const)( |
| 1133 | const result = applyThreadDetailEvent(baseThread, { | 1133 | "adds a checkpoint without replacing a %s turn outcome", |
| 1134 | ...baseEventFields, | 1134 | (previousState) => { |
| 1135 | sequence: 13, | 1135 | const result = applyThreadDetailEvent( |
| 1136 | occurredAt: "2026-04-01T12:00:00.000Z", | 1136 | { |
| 1137 | aggregateKind: "thread", | 1137 | ...baseThread, |
| 1138 | aggregateId: ThreadId.make("thread-1"), | 1138 | latestTurn: |
| 1139 | type: "thread.turn-diff-completed", | 1139 | previousState === null |
| 1140 | payload: { | 1140 | ? null |
| 1141 | threadId: ThreadId.make("thread-1"), | 1141 | : { |
| 1142 | turnId: TurnId.make("turn-1"), | 1142 | turnId: TurnId.make("turn-1"), |
| 1143 | checkpointTurnCount: 1, | 1143 | state: previousState, |
| 1144 | checkpointRef: CheckpointRef.make("ref-1"), | 1144 | requestedAt: "2026-04-01T11:00:00.000Z", |
| 1145 | status: "ready", | 1145 | startedAt: "2026-04-01T11:00:00.000Z", |
| 1146 | files: [], | 1146 | completedAt: "2026-04-01T12:00:00.000Z", |
| 1147 | assistantMessageId: MessageId.make("msg-3"), | 1147 | assistantMessageId: null, |
| 1148 | completedAt: "2026-04-01T12:00:00.000Z", | 1148 | }, |
| 1149 | }, | 1149 | }, |
| 1150 | }); | 1150 | { |
| 1151 | 1151 | ...baseEventFields, | |
| 1152 | expect(result.kind).toBe("updated"); | 1152 | sequence: 13, |
| 1153 | if (result.kind === "updated") { | 1153 | occurredAt: "2026-04-01T12:00:00.000Z", |
| 1154 | expect(result.thread.checkpoints).toHaveLength(1); | 1154 | aggregateKind: "thread", |
| 1155 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | 1155 | aggregateId: ThreadId.make("thread-1"), |
| 1156 | expect(result.thread.latestTurn?.state).toBe("completed"); | 1156 | type: "thread.turn-diff-completed", |
| 1157 | } | 1157 | payload: { |
| 1158 | }); | 1158 | threadId: ThreadId.make("thread-1"), |
| 1159 | turnId: TurnId.make("turn-1"), | ||
| 1160 | checkpointTurnCount: 1, | ||
| 1161 | checkpointRef: CheckpointRef.make("ref-1"), | ||
| 1162 | status: "ready", | ||
| 1163 | files: [], | ||
| 1164 | assistantMessageId: MessageId.make("msg-3"), | ||
| 1165 | completedAt: "2026-04-01T12:00:00.000Z", | ||
| 1166 | }, | ||
| 1167 | }, | ||
| 1168 | ); | ||
| 1169 | |||
| 1170 | expect(result.kind).toBe("updated"); | ||
| 1171 | if (result.kind === "updated") { | ||
| 1172 | expect(result.thread.checkpoints).toHaveLength(1); | ||
| 1173 | expect(result.thread.latestTurn?.turnId).toBe("turn-1"); | ||
| 1174 | expect(result.thread.latestTurn?.state).toBe(previousState ?? "completed"); | ||
| 1175 | } | ||
| 1176 | }, | ||
| 1177 | ); | ||
| 1159 | }); | 1178 | }); |
| 1160 | 1179 | ||
| 1161 | describe("thread.reverted", () => { | 1180 | describe("thread.reverted", () => { |
packages/client-runtime/src/state/threadReducer.ts+4-1| ... | @@ -521,7 +521,10 @@ export function applyThreadDetailEvent( | ... | @@ -521,7 +521,10 @@ export function applyThreadDetailEvent( |
| 521 | (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) | 521 | (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) |
| 522 | ? { | 522 | ? { |
| 523 | turnId: event.payload.turnId, | 523 | turnId: event.payload.turnId, |
| 524 | state: checkpointStatusToTurnState(event.payload.status), | 524 | state: |
| 525 | thread.latestTurn?.state === "interrupted" | ||
| 526 | ? "interrupted" | ||
| 527 | : checkpointStatusToTurnState(event.payload.status), | ||
| 525 | requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt, | 528 | requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt, |
| 526 | startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, | 529 | startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, |
| 527 | completedAt: event.payload.completedAt, | 530 | completedAt: event.payload.completedAt, |