authorgravatar for patrik@votocek.czPatrik Votoček <patrik@votocek.cz> 2026-09-17 04:02:13+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 19:02:13-07:00
log901db896691da5ab7b7428ef8a5da52883f831dd
tree81c47e02d9ba7eaf40b25a82f3b9fe554e9a9a2b
parentd42a3bd2ae3e698141b26f6426aa28d8886e1754
signature Signed by PGP key B5690EEEBB952194

fix(server): keep VCS waits from blocking turn completion (#11970)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

4 files changed, 416 insertions(+), 63 deletions(-)

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+174-1
......@@ -29,6 +29,7 @@ import * as Clock from "effect/Clock";
2929import * as Deferred from "effect/Deferred";
3030import * as Effect from "effect/Effect";
3131import * as Exit from "effect/Exit";
32import * as Fiber from "effect/Fiber";
3233import * as Layer from "effect/Layer";
3334import * as ManagedRuntime from "effect/ManagedRuntime";
3435import * as Option from "effect/Option";
......@@ -42,6 +43,8 @@ import { afterEach, describe, expect, it } from "vite-plus/test";
4243import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
4344import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
4445import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
46import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
47import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
4548import {
4649 ProviderService,
4750 type ProviderServiceShape,
......@@ -267,6 +270,7 @@ describe("ProviderRuntimeIngestion", () => {
267270 serverSettings?: Partial<ServerSettings>;
268271 threadTitle?: string;
269272 workspaceSubdirectory?: string;
273 isGitRepository?: CheckpointStore.CheckpointStore["Service"]["isGitRepository"];
270274 }) {
271275 const repositoryRoot = makeTempDir("t3-provider-project-");
272276 NodeChildProcess.execFileSync("git", ["init", "--initial-branch=main"], {
......@@ -327,7 +331,15 @@ describe("ProviderRuntimeIngestion", () => {
327331 Layer.provideMerge(SqlitePersistenceMemory),
328332 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
329333 Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),
330 Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))),
334 Layer.provideMerge(
335 Layer.effect(
336 CheckpointStore.CheckpointStore,
337 Effect.map(CheckpointStore.CheckpointStore, (store) => ({
338 ...store,
339 isGitRepository: options?.isGitRepository ?? store.isGitRepository,
340 })),
341 ).pipe(Layer.provide(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer)))),
342 ),
331343 Layer.provideMerge(VcsProcess.layer),
332344 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
333345 Layer.provideMerge(NodeServices.layer),
......@@ -405,6 +417,12 @@ describe("ProviderRuntimeIngestion", () => {
405417 engine,
406418 dispatch,
407419 readModel: () => testRuntime.runPromise(snapshotQuery.getSnapshot()),
420 readTurn: (turnId: TurnId) =>
421 testRuntime.runPromise(
422 Effect.flatMap(ProjectionTurnRepository, (turns) =>
423 turns.getByTurnId({ threadId: asThreadId("thread-1"), turnId }),
424 ).pipe(Effect.map(Option.getOrUndefined), Effect.provide(ProjectionTurnRepositoryLive)),
425 ),
408426 readThreadShell: () =>
409427 testRuntime.runPromise(
410428 snapshotQuery
......@@ -4075,6 +4093,142 @@ describe("ProviderRuntimeIngestion", () => {
40754093 });
40764094 });
40774095
4096 effectIt.effect("settles the turn while repository detection for a diff is blocked", () =>
4097 Effect.gen(function* () {
4098 const detectionStarted = yield* Deferred.make<void>();
4099 const releaseDetection = yield* Deferred.make<boolean>();
4100 const harness = yield* Effect.promise(() =>
4101 createHarness({
4102 isGitRepository: () =>
4103 Deferred.succeed(detectionStarted, undefined).pipe(
4104 Effect.andThen(Deferred.await(releaseDetection)),
4105 ),
4106 }),
4107 );
4108 yield* Effect.addFinalizer(() => Deferred.succeed(releaseDetection, true));
4109 const base = {
4110 provider: ProviderDriverKind.make("codex"),
4111 threadId: asThreadId("thread-1"),
4112 turnId: asTurnId("blocked-diff-turn"),
4113 createdAt: "2026-01-01T00:00:00.000Z",
4114 };
4115 yield* Effect.promise(() =>
4116 harness.emitAndDrain([
4117 { ...base, type: "turn.started", eventId: asEventId("evt-blocked-turn-start") },
4118 ]),
4119 );
4120 harness.emit({
4121 ...base,
4122 type: "turn.diff.updated",
4123 eventId: asEventId("evt-blocked-diff"),
4124 payload: { unifiedDiff: "diff --git a/file.ts b/file.ts\n+new\n" },
4125 });
4126 yield* Deferred.await(detectionStarted);
4127
4128 const settled = yield* harness.engine.streamDomainEvents.pipe(
4129 Stream.filter(
4130 (event) =>
4131 event.type === "thread.session-set" &&
4132 event.payload.threadId === base.threadId &&
4133 event.payload.session.status === "error",
4134 ),
4135 Stream.runHead,
4136 Effect.forkScoped({ startImmediately: true }),
4137 );
4138 harness.emit({
4139 ...base,
4140 type: "item.completed",
4141 eventId: asEventId("evt-blocked-final-reply"),
4142 itemId: asItemId("blocked-final-reply"),
4143 payload: { itemType: "assistant_message", status: "completed", detail: "Work finished." },
4144 });
4145 harness.emit({
4146 ...base,
4147 type: "turn.completed",
4148 eventId: asEventId("evt-blocked-turn-completed"),
4149 payload: { state: "failed" },
4150 });
4151 // Resolves only if turn.completed is processed while detection is still blocked.
4152 yield* Fiber.join(settled);
4153 const blocked = yield* Effect.promise(harness.readModel);
4154 expect(blocked.threads[0]?.session).toMatchObject({ status: "error", activeTurnId: null });
4155 expect(blocked.threads[0]?.messages).toEqual(
4156 expect.arrayContaining([expect.objectContaining({ text: "Work finished." })]),
4157 );
4158 expect(blocked.threads[0]?.checkpoints).toEqual([]);
4159
4160 // A newer turn starts before detection returns. The late placeholder
4161 // must neither settle the failed turn as completed nor move the
4162 // latest-turn pointer back to it.
4163 const nextTurnId = asTurnId("next-turn");
4164 const nextTurnStarted = yield* harness.engine.streamDomainEvents.pipe(
4165 Stream.filter(
4166 (event) =>
4167 event.type === "thread.session-set" &&
4168 event.payload.session.activeTurnId === nextTurnId,
4169 ),
4170 Stream.runHead,
4171 Effect.forkScoped({ startImmediately: true }),
4172 );
4173 harness.emit({
4174 ...base,
4175 type: "turn.started",
4176 turnId: nextTurnId,
4177 eventId: asEventId("evt-next-turn-start"),
4178 });
4179 yield* Fiber.join(nextTurnStarted);
4180 yield* Deferred.succeed(releaseDetection, true);
4181 yield* Effect.promise(harness.drain);
4182 const released = yield* Effect.promise(harness.readModel);
4183 expect(released.threads[0]?.checkpoints).toEqual([]);
4184 expect(released.threads[0]?.latestTurn).toMatchObject({
4185 turnId: nextTurnId,
4186 state: "running",
4187 });
4188 expect(yield* Effect.promise(() => harness.readTurn(base.turnId))).toMatchObject({
4189 state: "error",
4190 checkpointRef: null,
4191 });
4192 }),
4193 );
4194
4195 effectIt.effect("ignores a diff for a missing turn without moving the latest turn", () =>
4196 Effect.gen(function* () {
4197 const harness = yield* Effect.promise(() => createHarness());
4198 const base = {
4199 provider: ProviderDriverKind.make("codex"),
4200 threadId: asThreadId("thread-1"),
4201 createdAt: "2026-01-01T00:00:00.000Z",
4202 };
4203 yield* Effect.promise(() =>
4204 harness.emitAndDrain([
4205 {
4206 ...base,
4207 type: "turn.started",
4208 eventId: asEventId("evt-existing-turn"),
4209 turnId: asTurnId("current-turn"),
4210 },
4211 {
4212 ...base,
4213 type: "turn.diff.updated",
4214 eventId: asEventId("evt-missing-turn-diff"),
4215 turnId: asTurnId("missing-turn"),
4216 payload: { unifiedDiff: "diff --git a/file.ts b/file.ts\n+late\n" },
4217 },
4218 ]),
4219 );
4220 const snapshot = yield* Effect.promise(harness.readModel);
4221 expect(snapshot.threads[0]?.checkpoints).toEqual([]);
4222 expect(snapshot.threads[0]?.latestTurn).toMatchObject({
4223 turnId: "current-turn",
4224 state: "running",
4225 });
4226 expect(
4227 yield* Effect.promise(() => harness.readTurn(asTurnId("missing-turn"))),
4228 ).toBeUndefined();
4229 }),
4230 );
4231
40784232 effectIt.effect("tracks provider diff updates from a nested Git workspace", () =>
40794233 Effect.gen(function* () {
40804234 const harness = yield* Effect.promise(() =>
......@@ -4082,6 +4236,14 @@ describe("ProviderRuntimeIngestion", () => {
40824236 );
40834237 yield* Effect.promise(() =>
40844238 harness.emitAndDrain([
4239 {
4240 type: "turn.started",
4241 eventId: asEventId("evt-nested-turn-started"),
4242 provider: ProviderDriverKind.make("codex"),
4243 createdAt: "2026-01-01T00:00:00.000Z",
4244 threadId: asThreadId("thread-1"),
4245 turnId: asTurnId("nested-turn"),
4246 },
40854247 {
40864248 type: "turn.diff.updated",
40874249 eventId: asEventId("evt-nested-diff"),
......@@ -4106,6 +4268,17 @@ describe("ProviderRuntimeIngestion", () => {
41064268 const harness = await createHarness();
41074269 const now = "2026-01-01T00:00:00.000Z";
41084270
4271 await harness.emitAndDrain([
4272 {
4273 type: "turn.started",
4274 eventId: asEventId("evt-p1-turn-started"),
4275 provider: ProviderDriverKind.make("codex"),
4276 createdAt: now,
4277 threadId: asThreadId("thread-1"),
4278 turnId: asTurnId("turn-p1"),
4279 },
4280 ]);
4281
41094282 harness.emit({
41104283 type: "thread.metadata.updated",
41114284 eventId: asEventId("evt-thread-metadata-updated"),
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+96-61
......@@ -126,6 +126,8 @@ type TurnStartRequestedDomainEvent = Extract<
126126 { type: "thread.turn-start-requested" }
127127>;
128128
129type ProviderDiffEvent = Extract<ProviderRuntimeEvent, { type: "turn.diff.updated" }>;
130
129131type RuntimeIngestionInput =
130132 | {
131133 source: "runtime";
......@@ -134,6 +136,11 @@ type RuntimeIngestionInput =
134136 | {
135137 source: "domain";
136138 event: TurnStartRequestedDomainEvent;
139 }
140 | {
141 /** A diff whose workspace the diff worker confirmed is a Git repository. */
142 source: "diff";
143 event: ProviderDiffEvent;
137144 };
138145
139146function toTurnId(value: TurnId | string | undefined): TurnId | undefined {
......@@ -2436,48 +2443,6 @@ const make = Effect.gen(function* () {
24362443 }
24372444 }
24382445
2439 if (event.type === "turn.diff.updated") {
2440 const turnId = toTurnId(event.turnId);
2441 const checkpointContext = turnId
2442 ? yield* projectionSnapshotQuery
2443 .getThreadCheckpointContext(thread.id)
2444 .pipe(Effect.map(Option.getOrUndefined))
2445 : undefined;
2446 const workspaceCwd =
2447 checkpointContext?.worktreePath ?? checkpointContext?.workspaceRoot ?? undefined;
2448 if (
2449 turnId &&
2450 checkpointContext &&
2451 workspaceCwd &&
2452 (yield* checkpointStore.isGitRepository(workspaceCwd))
2453 ) {
2454 // Skip if a checkpoint already exists for this turn. A real
2455 // (non-placeholder) capture from CheckpointReactor should not
2456 // be clobbered, and dispatching a duplicate placeholder for the
2457 // same turnId would produce an unstable checkpointTurnCount.
2458 if (hasCheckpointForTurn(checkpointContext.checkpoints, turnId)) {
2459 // Already tracked; no-op.
2460 } else {
2461 const assistantMessageId = MessageId.make(
2462 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
2463 );
2464 yield* orchestrationEngine.dispatch({
2465 type: "thread.turn.diff.complete",
2466 commandId: yield* providerCommandId(event, "thread-turn-diff-complete"),
2467 threadId: thread.id,
2468 turnId,
2469 completedAt: now,
2470 checkpointRef: CheckpointRef.make(`provider-diff:${event.eventId}`),
2471 status: "missing",
2472 files: [],
2473 assistantMessageId,
2474 checkpointTurnCount: maxCheckpointTurnCount(checkpointContext.checkpoints) + 1,
2475 createdAt: now,
2476 });
2477 }
2478 }
2479 }
2480
24812446 if (event.type === "task.started" || event.type === "task.progress") {
24822447 const description = event.payload.description?.trim();
24832448 if (description) {
......@@ -2626,31 +2591,100 @@ const make = Effect.gen(function* () {
26262591
26272592 const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void;
26282593
2629 const processInput = (input: RuntimeIngestionInput) =>
2630 input.source === "runtime" ? processRuntimeEvent(input.event) : processDomainEvent(input.event);
2594 // Records a mid-turn placeholder checkpoint for a provider diff. Runs on the
2595 // lifecycle worker, after repository detection, so the running-turn check
2596 // and the dispatch are ordered with the turn's terminal events: a diff that
2597 // resolved after turn.completed must not rewrite the settled turn's state or
2598 // move the latest-turn pointer back.
2599 const recordProviderDiff = Effect.fn("recordProviderDiff")(function* (event: ProviderDiffEvent) {
2600 const thread = yield* resolveThreadRuntimeContext(event.threadId);
2601 const turnId = toTurnId(event.turnId);
2602 if (!thread || !turnId) return;
2603 const turn = yield* projectionTurnRepository.getByTurnId({ threadId: thread.id, turnId });
2604 if (Option.isNone(turn) || turn.value.state !== "running") return;
2605 const checkpointContext = yield* projectionSnapshotQuery
2606 .getThreadCheckpointContext(thread.id)
2607 .pipe(Effect.map(Option.getOrUndefined));
2608 // Skip if a checkpoint already exists for this turn. A real
2609 // (non-placeholder) capture from CheckpointReactor should not
2610 // be clobbered, and dispatching a duplicate placeholder for the
2611 // same turnId would produce an unstable checkpointTurnCount.
2612 if (!checkpointContext || hasCheckpointForTurn(checkpointContext.checkpoints, turnId)) return;
2613 const now = event.createdAt;
2614 yield* orchestrationEngine.dispatch({
2615 type: "thread.turn.diff.complete",
2616 commandId: yield* providerCommandId(event, "thread-turn-diff-complete"),
2617 threadId: thread.id,
2618 turnId,
2619 completedAt: now,
2620 checkpointRef: CheckpointRef.make(`provider-diff:${event.eventId}`),
2621 status: "missing",
2622 files: [],
2623 assistantMessageId: MessageId.make(
2624 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
2625 ),
2626 checkpointTurnCount: maxCheckpointTurnCount(checkpointContext.checkpoints) + 1,
2627 createdAt: now,
2628 });
2629 });
2630
2631 const processInput = (input: RuntimeIngestionInput) => {
2632 switch (input.source) {
2633 case "runtime":
2634 return processRuntimeEvent(input.event);
2635 case "domain":
2636 return processDomainEvent(input.event);
2637 case "diff":
2638 return recordProviderDiff(input.event);
2639 }
2640 };
26312641
2632 const processInputSafely = (input: RuntimeIngestionInput) =>
2633 processInput(input).pipe(
2634 Effect.catchCause((cause) => {
2635 if (Cause.hasInterruptsOnly(cause)) {
2636 return Effect.failCause(cause);
2637 }
2638 return Effect.logWarning("provider runtime ingestion failed to process event", {
2639 source: input.source,
2640 eventId: input.event.eventId,
2641 eventType: input.event.type,
2642 cause: Cause.pretty(cause),
2643 });
2644 }),
2645 );
2642 const logIngestionFailure =
2643 (source: string, event: { readonly eventId: string; readonly type: string }) =>
2644 <E, R>(effect: Effect.Effect<void, E, R>) =>
2645 effect.pipe(
2646 Effect.catchCause((cause) => {
2647 if (Cause.hasInterruptsOnly(cause)) {
2648 return Effect.failCause(cause);
2649 }
2650 return Effect.logWarning("provider runtime ingestion failed to process event", {
2651 source,
2652 eventId: event.eventId,
2653 eventType: event.type,
2654 cause: Cause.pretty(cause),
2655 });
2656 }),
2657 );
2658
2659 const worker = yield* makeDrainableWorker((input: RuntimeIngestionInput) =>
2660 processInput(input).pipe(logIngestionFailure(input.source, input.event)),
2661 );
26462662
2647 const worker = yield* makeDrainableWorker(processInputSafely);
2663 // Repository detection for a diff goes through VCS subprocesses, which can
2664 // stall behind slow or hung git. It runs on its own worker so a stuck diff
2665 // never delays the lifecycle worker; confirmed diffs are handed back to it.
2666 const detectProviderDiffRepository = Effect.fn("detectProviderDiffRepository")(function* (
2667 event: ProviderDiffEvent,
2668 ) {
2669 if (!toTurnId(event.turnId)) return;
2670 const checkpointContext = yield* projectionSnapshotQuery
2671 .getThreadCheckpointContext(event.threadId)
2672 .pipe(Effect.map(Option.getOrUndefined));
2673 const workspaceCwd = checkpointContext?.worktreePath ?? checkpointContext?.workspaceRoot;
2674 if (!workspaceCwd || !(yield* checkpointStore.isGitRepository(workspaceCwd))) return;
2675 yield* worker.enqueue({ source: "diff", event });
2676 });
2677 const diffWorker = yield* makeDrainableWorker((event: ProviderDiffEvent) =>
2678 detectProviderDiffRepository(event).pipe(logIngestionFailure("diff", event)),
2679 );
26482680
26492681 const start: ProviderRuntimeIngestionShape["start"] = () =>
26502682 Effect.gen(function* () {
26512683 yield* forkParked(
26522684 Stream.runForEach(providerService.streamEvents, (event) =>
2653 worker.enqueue({ source: "runtime", event }),
2685 event.type === "turn.diff.updated"
2686 ? diffWorker.enqueue(event)
2687 : worker.enqueue({ source: "runtime", event }),
26542688 ),
26552689 );
26562690 yield* forkParked(
......@@ -2665,7 +2699,8 @@ const make = Effect.gen(function* () {
26652699
26662700 return {
26672701 start,
2668 drain: worker.drain,
2702 // The diff worker feeds the lifecycle worker, so drain it first.
2703 drain: diffWorker.drain.pipe(Effect.andThen(worker.drain)),
26692704 } satisfies ProviderRuntimeIngestionShape;
26702705});
26712706
apps/server/src/orchestration/decider.ts+19-1
......@@ -2075,11 +2075,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
20752075 }
20762076
20772077 case "thread.turn.diff.complete": {
2078 yield* requireThread({
2078 const thread = yield* requireThread({
20792079 readModel,
20802080 command,
20812081 threadId: command.threadId,
20822082 });
2083 // A placeholder (status "missing") must never replace a checkpoint that
2084 // was already captured with a real git ref. Provider diff ingestion
2085 // checks this before dispatching, but CheckpointReactor can commit the
2086 // real capture in between; the decider runs under the engine's command
2087 // lock, so rejecting here closes that window.
2088 const existingCheckpoint = thread.checkpoints.find(
2089 (checkpoint) => checkpoint.turnId === command.turnId,
2090 );
2091 if (
2092 command.status === "missing" &&
2093 existingCheckpoint !== undefined &&
2094 existingCheckpoint.status !== "missing"
2095 ) {
2096 return yield* new OrchestrationCommandInvariantError({
2097 commandType: command.type,
2098 detail: `turn ${command.turnId} already has a captured checkpoint`,
2099 });
2100 }
20832101 return {
20842102 ...(yield* withEventBase({
20852103 aggregateKind: "thread",
apps/server/src/orchestration/decider.turnDiffComplete.test.ts created+127
......@@ -0,0 +1,127 @@
1import {
2 CheckpointRef,
3 CommandId,
4 MessageId,
5 ProjectId,
6 ProviderInstanceId,
7 ThreadId,
8 TurnId,
9 type OrchestrationCheckpointSummary,
10 type OrchestrationReadModel,
11} from "@t3tools/contracts";
12import * as NodeServices from "@effect/platform-node/NodeServices";
13import { expect, it } from "@effect/vitest";
14import * as Effect from "effect/Effect";
15import * as Exit from "effect/Exit";
16
17import { decideOrchestrationCommand } from "./decider.ts";
18
19const NOW = "2026-01-01T00:00:00.000Z";
20const THREAD_ID = ThreadId.make("thread-1");
21const TURN_ID = TurnId.make("turn-1");
22
23function makeReadModel(checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>) {
24 return {
25 snapshotSequence: 0,
26 projects: [],
27 threads: [
28 {
29 id: THREAD_ID,
30 projectId: ProjectId.make("project-1"),
31 title: "Thread",
32 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
33 runtimeMode: "full-access",
34 interactionMode: "default",
35 branch: null,
36 worktreePath: null,
37 pullRequests: [],
38 latestTurn: null,
39 createdAt: NOW,
40 updatedAt: NOW,
41 archivedAt: null,
42 settledOverride: null,
43 settledAt: null,
44 snoozedUntil: null,
45 snoozedAt: null,
46 pinnedAt: null,
47 pinOrderKey: null,
48 deletedAt: null,
49 messages: [],
50 proposedPlans: [],
51 activities: [],
52 checkpoints,
53 session: null,
54 },
55 ],
56 updatedAt: NOW,
57 } satisfies OrchestrationReadModel;
58}
59
60function makeCheckpoint(status: OrchestrationCheckpointSummary["status"]) {
61 return {
62 turnId: TURN_ID,
63 checkpointTurnCount: 1,
64 checkpointRef: CheckpointRef.make(`existing:${status}`),
65 status,
66 files: [],
67 assistantMessageId: null,
68 completedAt: NOW,
69 } satisfies OrchestrationCheckpointSummary;
70}
71
72function placeholderCommand() {
73 return {
74 type: "thread.turn.diff.complete",
75 commandId: CommandId.make("cmd-diff-placeholder"),
76 threadId: THREAD_ID,
77 turnId: TURN_ID,
78 completedAt: NOW,
79 checkpointRef: CheckpointRef.make("provider-diff:event-1"),
80 status: "missing",
81 files: [],
82 assistantMessageId: MessageId.make("assistant:turn-1"),
83 checkpointTurnCount: 2,
84 createdAt: NOW,
85 } as const;
86}
87
88it.layer(NodeServices.layer)("turn diff complete decider", (it) => {
89 it.effect("rejects a placeholder when the turn already has a captured checkpoint", () =>
90 Effect.gen(function* () {
91 const exit = yield* Effect.exit(
92 decideOrchestrationCommand({
93 command: placeholderCommand(),
94 readModel: makeReadModel([makeCheckpoint("ready")]),
95 }),
96 );
97 expect(Exit.isFailure(exit)).toBe(true);
98 }),
99 );
100
101 it.effect("accepts a placeholder when the turn only has a placeholder", () =>
102 Effect.gen(function* () {
103 const event = yield* decideOrchestrationCommand({
104 command: placeholderCommand(),
105 readModel: makeReadModel([makeCheckpoint("missing")]),
106 });
107 const events = Array.isArray(event) ? event : [event];
108 expect(events).toHaveLength(1);
109 expect(events[0]?.type).toBe("thread.turn-diff-completed");
110 }),
111 );
112
113 it.effect("lets a captured checkpoint replace an earlier placeholder", () =>
114 Effect.gen(function* () {
115 const event = yield* decideOrchestrationCommand({
116 command: {
117 ...placeholderCommand(),
118 checkpointRef: CheckpointRef.make("refs/t3/checkpoints/turn-1"),
119 status: "ready",
120 },
121 readModel: makeReadModel([makeCheckpoint("missing")]),
122 });
123 const events = Array.isArray(event) ? event : [event];
124 expect(events[0]?.type).toBe("thread.turn-diff-completed");
125 }),
126 );
127});