History

C015 & C016

Julius Marminge committed 8 months ago

Commit 76208f71 parent 9e2324c9 files · +678 −35Browse files

Unified · Split

9 files
  1. .plans/16c-pr89-remediation-checklist.md +12 −14
  2. apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +327 −1
  3. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +29 −18
  4. apps/server/src/orchestration/projector.test.ts +149 −0
  5. apps/server/src/orchestration/projector.ts +12 −2
  6. apps/server/src/persistence/Layers/ProjectionPendingTurnStarts.ts +92 −0
  7. apps/server/src/persistence/Migrations.ts +2 −0
  8. apps/server/src/persistence/Migrations/006_ProjectionPendingTurnStarts.ts +14 −0
  9. apps/server/src/persistence/Services/ProjectionPendingTurnStarts.ts +41 −0
.plans/16c-pr89-remediation-checklist.md+12−14
@@ -11,7 +11,7 @@ Status values: · 10 unmodified lines
1111 - `DONE`: Implemented and verified
1212 - `CLOSED_INVALID`: Stale/invalid review finding
1313
14−Counts: active `52` (`valid=33`, `partially-valid=19`), closed-invalid `5`
14+Counts: active `51` (`valid=33`, `partially-valid=18`), closed-invalid `6`
1515
1616 ## Active Checklist
1717
@@ -172,17 +172,8 @@ Counts: active `52` (`valid=33`, `partially-valid=19`), closed-invalid `5` · 154 unmodified lines
172172 - Threads: PRRT_kwDORLtfbc5whtrM
173173 - Audit note: Persisted event can outpace in-memory projection on mid-flight failure.
174174
175−- [ ] `C014` Engine error handler catches all errors including non-invariant ones
176− - Status: `TODO`
177− - Verdict: `partially-valid`
178− - Severity: `Medium`
179− - Area: `Event ordering and state consistency`
180− - File: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts:144`
181− - Threads: PRRT_kwDORLtfbc5wkPaJ
182− - Audit note: Broad catch may be intentional for liveness; correctness concern is contextual.
183−
184−- [ ] `C015` The gap-filling fallback logic can retain messages from turns that are about to be deleted, causing foreign key violations. Consider removing the fallback logic entirely, or filtering `fallbackUserMessages` and `fallbackAssistantMessages` to only include messages whose `turnId` is in `retainedTurnIds`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary>
185− - Status: `TODO`
175+- [x] `C015` The gap-filling fallback logic can retain messages from turns that are about to be deleted, causing foreign key violations. Consider removing the fallback logic entirely, or filtering `fallbackUserMessages` and `fallbackAssistantMessages` to only include messages whose `turnId` is in `retainedTurnIds`. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary>
176+ - Status: `DONE`
186177 - Verdict: `partially-valid`
187178 - Severity: `Medium`
188179 - Area: `Event ordering and state consistency`
@@ -190,8 +181,8 @@ Counts: active `52` (`valid=33`, `partially-valid=19`), closed-invalid `5` · 1 unmodified line
190181 - Threads: PRRT_kwDORLtfbc5whxJO
191182 - Audit note: Message fallback retention issue is real, but prior FK-violation claim is overstated.
192183
193−- [ ] `C016` The in-memory `pendingTurnStartByThreadId` map isn't restored during bootstrap. If the service restarts after processing `thread.turn-start-requested` but before `thread.session-set`, the `userMessageId` and `startedAt` will be lost since bootstrap resumes *after* the committed sequence. Consider persisting this pending state or processing these two events atomically. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary>
194− - Status: `TODO`
184+- [x] `C016` The in-memory `pendingTurnStartByThreadId` map isn't restored during bootstrap. If the service restarts after processing `thread.turn-start-requested` but before `thread.session-set`, the `userMessageId` and `startedAt` will be lost since bootstrap resumes *after* the committed sequence. Consider persisting this pending state or processing these two events atomically. <details> <summary>🚀 Reply "<strong>fix it for me</strong>" or copy this <strong>AI Prompt</strong> for your agent:</summary>
185+ - Status: `DONE`
195186 - Verdict: `valid`
196187 - Severity: `Medium`
197188 - Area: `Event ordering and state consistency`
@@ -506,6 +497,13 @@ Counts: active `52` (`valid=33`, `partially-valid=19`), closed-invalid `5` · 308 unmodified lines
506497
507498 ## Closed Invalid Items
508499
500+- [x] `C014` Engine error handler catches all errors including non-invariant ones
501+ - Status: `CLOSED_INVALID`
502+ - Severity: `Medium`
503+ - File: `apps/server/src/orchestration/Layers/OrchestrationEngine.ts:144`
504+ - Threads: PRRT_kwDORLtfbc5wkPaJ
505+ - Rationale: Broad catch is intentional for worker liveness; transactional dispatch path prevents the claimed non-invariant idempotency break in current design.
506+
509507 - [x] `C021` Shared mutable default metadata object causes stale eventId
510508 - Status: `CLOSED_INVALID`
511509 - Severity: `Medium`
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+327−1
@@ -1,18 +1,29 @@
11 import {
2+ CheckpointRef,
23 CommandId,
34 CorrelationId,
45 EventId,
56 MessageId,
7+ ProviderSessionId,
8+ ProviderThreadId,
69 ProjectId,
710 ThreadId,
11+ TurnId,
812 } from "@t3tools/contracts";
13+import { NodeServices } from "@effect/platform-node";
914 import { assert, it } from "@effect/vitest";
1015 import { Effect, Layer } from "effect";
16+import fs from "node:fs";
17+import os from "node:os";
18+import path from "node:path";
1119 import * as SqlClient from "effect/unstable/sql/SqlClient";
1220
1321 import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
1422 import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
15−import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
23+import {
24+ makeSqlitePersistenceLive,
25+ SqlitePersistenceMemory,
26+} from "../../persistence/Layers/Sqlite.ts";
1627 import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
1728 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
1829 import {
@@ -400,8 +411,323 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { · 381 unmodified lines
400411 assert.isFalse(Boolean(messageRows[0]?.isStreaming));
401412 }),
402413 );
414+
415+ it.effect("does not fallback-retain messages whose turnId is removed by revert", () =>
416+ Effect.gen(function* () {
417+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
418+ const eventStore = yield* OrchestrationEventStore;
419+ const sql = yield* SqlClient.SqlClient;
420+ const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
421+ eventStore
422+ .append(event)
423+ .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
424+
425+ yield* appendAndProject({
426+ type: "project.created",
427+ eventId: EventId.makeUnsafe("evt-revert-1"),
428+ aggregateKind: "project",
429+ aggregateId: ProjectId.makeUnsafe("project-revert"),
430+ occurredAt: "2026-02-26T12:00:00.000Z",
431+ commandId: CommandId.makeUnsafe("cmd-revert-1"),
432+ causationEventId: null,
433+ correlationId: CorrelationId.makeUnsafe("cmd-revert-1"),
434+ metadata: {},
435+ payload: {
436+ projectId: ProjectId.makeUnsafe("project-revert"),
437+ title: "Project Revert",
438+ workspaceRoot: "/tmp/project-revert",
439+ defaultModel: null,
440+ scripts: [],
441+ createdAt: "2026-02-26T12:00:00.000Z",
442+ updatedAt: "2026-02-26T12:00:00.000Z",
443+ },
444+ });
445+
446+ yield* appendAndProject({
447+ type: "thread.created",
448+ eventId: EventId.makeUnsafe("evt-revert-2"),
449+ aggregateKind: "thread",
450+ aggregateId: ThreadId.makeUnsafe("thread-revert"),
451+ occurredAt: "2026-02-26T12:00:01.000Z",
452+ commandId: CommandId.makeUnsafe("cmd-revert-2"),
453+ causationEventId: null,
454+ correlationId: CorrelationId.makeUnsafe("cmd-revert-2"),
455+ metadata: {},
456+ payload: {
457+ threadId: ThreadId.makeUnsafe("thread-revert"),
458+ projectId: ProjectId.makeUnsafe("project-revert"),
459+ title: "Thread Revert",
460+ model: "gpt-5-codex",
461+ branch: null,
462+ worktreePath: null,
463+ createdAt: "2026-02-26T12:00:01.000Z",
464+ updatedAt: "2026-02-26T12:00:01.000Z",
465+ },
466+ });
467+
468+ yield* appendAndProject({
469+ type: "thread.turn-diff-completed",
470+ eventId: EventId.makeUnsafe("evt-revert-3"),
471+ aggregateKind: "thread",
472+ aggregateId: ThreadId.makeUnsafe("thread-revert"),
473+ occurredAt: "2026-02-26T12:00:02.000Z",
474+ commandId: CommandId.makeUnsafe("cmd-revert-3"),
475+ causationEventId: null,
476+ correlationId: CorrelationId.makeUnsafe("cmd-revert-3"),
477+ metadata: {},
478+ payload: {
479+ threadId: ThreadId.makeUnsafe("thread-revert"),
480+ turnId: TurnId.makeUnsafe("turn-1"),
481+ checkpointTurnCount: 1,
482+ checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert/turn/1"),
483+ status: "ready",
484+ files: [],
485+ assistantMessageId: MessageId.makeUnsafe("assistant-keep"),
486+ completedAt: "2026-02-26T12:00:02.000Z",
487+ },
488+ });
489+
490+ yield* appendAndProject({
491+ type: "thread.message-sent",
492+ eventId: EventId.makeUnsafe("evt-revert-4"),
493+ aggregateKind: "thread",
494+ aggregateId: ThreadId.makeUnsafe("thread-revert"),
495+ occurredAt: "2026-02-26T12:00:02.100Z",
496+ commandId: CommandId.makeUnsafe("cmd-revert-4"),
497+ causationEventId: null,
498+ correlationId: CorrelationId.makeUnsafe("cmd-revert-4"),
499+ metadata: {},
500+ payload: {
501+ threadId: ThreadId.makeUnsafe("thread-revert"),
502+ messageId: MessageId.makeUnsafe("assistant-keep"),
503+ role: "assistant",
504+ text: "kept",
505+ turnId: TurnId.makeUnsafe("turn-1"),
506+ streaming: false,
507+ createdAt: "2026-02-26T12:00:02.100Z",
508+ updatedAt: "2026-02-26T12:00:02.100Z",
509+ },
510+ });
511+
512+ yield* appendAndProject({
513+ type: "thread.turn-diff-completed",
514+ eventId: EventId.makeUnsafe("evt-revert-5"),
515+ aggregateKind: "thread",
516+ aggregateId: ThreadId.makeUnsafe("thread-revert"),
517+ occurredAt: "2026-02-26T12:00:03.000Z",
518+ commandId: CommandId.makeUnsafe("cmd-revert-5"),
519+ causationEventId: null,
520+ correlationId: CorrelationId.makeUnsafe("cmd-revert-5"),
521+ metadata: {},
522+ payload: {
523+ threadId: ThreadId.makeUnsafe("thread-revert"),
524+ turnId: TurnId.makeUnsafe("turn-2"),
525+ checkpointTurnCount: 2,
526+ checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert/turn/2"),
527+ status: "ready",
528+ files: [],
529+ assistantMessageId: MessageId.makeUnsafe("assistant-remove"),
530+ completedAt: "2026-02-26T12:00:03.000Z",
531+ },
532+ });
533+
534+ yield* appendAndProject({
535+ type: "thread.message-sent",
536+ eventId: EventId.makeUnsafe("evt-revert-6"),
537+ aggregateKind: "thread",
538+ aggregateId: ThreadId.makeUnsafe("thread-revert"),
539+ occurredAt: "2026-02-26T12:00:03.050Z",
540+ commandId: CommandId.makeUnsafe("cmd-revert-6"),
541+ causationEventId: null,
542+ correlationId: CorrelationId.makeUnsafe("cmd-revert-6"),
543+ metadata: {},
544+ payload: {
545+ threadId: ThreadId.makeUnsafe("thread-revert"),
546+ messageId: MessageId.makeUnsafe("user-remove"),
547+ role: "user",
548+ text: "removed",
549+ turnId: TurnId.makeUnsafe("turn-2"),
550+ streaming: false,
551+ createdAt: "2026-02-26T12:00:03.050Z",
552+ updatedAt: "2026-02-26T12:00:03.050Z",
553+ },
554+ });
555+
556+ yield* appendAndProject({
557+ type: "thread.message-sent",
558+ eventId: EventId.makeUnsafe("evt-revert-7"),
559+ aggregateKind: "thread",
560+ aggregateId: ThreadId.makeUnsafe("thread-revert"),
561+ occurredAt: "2026-02-26T12:00:03.100Z",
562+ commandId: CommandId.makeUnsafe("cmd-revert-7"),
563+ causationEventId: null,
564+ correlationId: CorrelationId.makeUnsafe("cmd-revert-7"),
565+ metadata: {},
566+ payload: {
567+ threadId: ThreadId.makeUnsafe("thread-revert"),
568+ messageId: MessageId.makeUnsafe("assistant-remove"),
569+ role: "assistant",
570+ text: "removed",
571+ turnId: TurnId.makeUnsafe("turn-2"),
572+ streaming: false,
573+ createdAt: "2026-02-26T12:00:03.100Z",
574+ updatedAt: "2026-02-26T12:00:03.100Z",
575+ },
576+ });
577+
578+ yield* appendAndProject({
579+ type: "thread.reverted",
580+ eventId: EventId.makeUnsafe("evt-revert-8"),
581+ aggregateKind: "thread",
582+ aggregateId: ThreadId.makeUnsafe("thread-revert"),
583+ occurredAt: "2026-02-26T12:00:04.000Z",
584+ commandId: CommandId.makeUnsafe("cmd-revert-8"),
585+ causationEventId: null,
586+ correlationId: CorrelationId.makeUnsafe("cmd-revert-8"),
587+ metadata: {},
588+ payload: {
589+ threadId: ThreadId.makeUnsafe("thread-revert"),
590+ turnCount: 1,
591+ },
592+ });
593+
594+ const messageRows = yield* sql<{
595+ readonly messageId: string;
596+ readonly turnId: string | null;
597+ readonly role: string;
598+ }>`
599+ SELECT
600+ message_id AS "messageId",
601+ turn_id AS "turnId",
602+ role
603+ FROM projection_thread_messages
604+ WHERE thread_id = 'thread-revert'
605+ ORDER BY created_at ASC, message_id ASC
606+ `;
607+ assert.deepEqual(messageRows, [
608+ {
609+ messageId: "assistant-keep",
610+ turnId: "turn-1",
611+ role: "assistant",
612+ },
613+ ]);
614+ }),
615+ );
616+
403617 });
404618
619+it.effect("restores pending turn-start metadata across projection pipeline restart", () =>
620+ Effect.gen(function* () {
621+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-pipeline-restart-"));
622+ const dbPath = path.join(tempDir, "orchestration.sqlite");
623+ const persistenceLayer = makeSqlitePersistenceLive(dbPath);
624+ const firstProjectionLayer = OrchestrationProjectionPipelineLive.pipe(
625+ Layer.provideMerge(OrchestrationEventStoreLive),
626+ Layer.provideMerge(persistenceLayer),
627+ );
628+ const secondProjectionLayer = OrchestrationProjectionPipelineLive.pipe(
629+ Layer.provideMerge(OrchestrationEventStoreLive),
630+ Layer.provideMerge(persistenceLayer),
631+ );
632+
633+ const threadId = ThreadId.makeUnsafe("thread-restart");
634+ const turnId = TurnId.makeUnsafe("turn-restart");
635+ const messageId = MessageId.makeUnsafe("message-restart");
636+ const turnStartedAt = "2026-02-26T14:00:00.000Z";
637+ const sessionSetAt = "2026-02-26T14:00:05.000Z";
638+
639+ yield* Effect.gen(function* () {
640+ const eventStore = yield* OrchestrationEventStore;
641+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
642+
643+ yield* eventStore.append({
644+ type: "thread.turn-start-requested",
645+ eventId: EventId.makeUnsafe("evt-restart-1"),
646+ aggregateKind: "thread",
647+ aggregateId: threadId,
648+ occurredAt: turnStartedAt,
649+ commandId: CommandId.makeUnsafe("cmd-restart-1"),
650+ causationEventId: null,
651+ correlationId: CorrelationId.makeUnsafe("cmd-restart-1"),
652+ metadata: {},
653+ payload: {
654+ threadId,
655+ messageId,
656+ createdAt: turnStartedAt,
657+ },
658+ });
659+
660+ yield* projectionPipeline.bootstrap;
661+ }).pipe(Effect.provide(firstProjectionLayer));
662+
663+ const turnRows = yield* Effect.gen(function* () {
664+ const eventStore = yield* OrchestrationEventStore;
665+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
666+ const sql = yield* SqlClient.SqlClient;
667+
668+ yield* eventStore.append({
669+ type: "thread.session-set",
670+ eventId: EventId.makeUnsafe("evt-restart-2"),
671+ aggregateKind: "thread",
672+ aggregateId: threadId,
673+ occurredAt: sessionSetAt,
674+ commandId: CommandId.makeUnsafe("cmd-restart-2"),
675+ causationEventId: null,
676+ correlationId: CorrelationId.makeUnsafe("cmd-restart-2"),
677+ metadata: {
678+ providerSessionId: ProviderSessionId.makeUnsafe("provider-session-restart"),
679+ providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-restart"),
680+ },
681+ payload: {
682+ threadId,
683+ session: {
684+ threadId,
685+ status: "running",
686+ providerName: "codex",
687+ providerSessionId: ProviderSessionId.makeUnsafe("provider-session-restart"),
688+ providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-restart"),
689+ activeTurnId: turnId,
690+ lastError: null,
691+ updatedAt: sessionSetAt,
692+ },
693+ },
694+ });
695+
696+ yield* projectionPipeline.bootstrap;
697+
698+ const pendingRows = yield* sql<{ readonly threadId: string }>`
699+ SELECT thread_id AS "threadId"
700+ FROM projection_pending_turn_starts
701+ WHERE thread_id = ${threadId}
702+ `;
703+ assert.deepEqual(pendingRows, []);
704+
705+ return yield* sql<{
706+ readonly turnId: string;
707+ readonly userMessageId: string | null;
708+ readonly startedAt: string;
709+ }>`
710+ SELECT
711+ turn_id AS "turnId",
712+ user_message_id AS "userMessageId",
713+ started_at AS "startedAt"
714+ FROM projection_thread_turns
715+ WHERE turn_id = ${turnId}
716+ `;
717+ }).pipe(Effect.provide(secondProjectionLayer));
718+
719+ assert.deepEqual(turnRows, [
720+ {
721+ turnId: "turn-restart",
722+ userMessageId: "message-restart",
723+ startedAt: turnStartedAt,
724+ },
725+ ]);
726+
727+ fs.rmSync(tempDir, { recursive: true, force: true });
728+ }).pipe(Effect.provide(NodeServices.layer)),
729+);
730+
405731 const engineLayer = it.layer(
406732 OrchestrationEngineLive.pipe(
407733 Layer.provide(OrchestrationProjectionPipelineLive),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+29−18
@@ -6,6 +6,7 @@ import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../per · 5 unmodified lines
66 import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
77 import { ProjectionCheckpointRepository } from "../../persistence/Services/ProjectionCheckpoints.ts";
88 import { ProjectionPendingApprovalRepository } from "../../persistence/Services/ProjectionPendingApprovals.ts";
9+import { ProjectionPendingTurnStartRepository } from "../../persistence/Services/ProjectionPendingTurnStarts.ts";
910 import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts";
1011 import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts";
1112 import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts";
@@ -20,6 +21,7 @@ import { ProjectionThreadTurnRepository } from "../../persistence/Services/Proje · 8 unmodified lines
2021 import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts";
2122 import { ProjectionCheckpointRepositoryLive } from "../../persistence/Layers/ProjectionCheckpoints.ts";
2223 import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts";
24+import { ProjectionPendingTurnStartRepositoryLive } from "../../persistence/Layers/ProjectionPendingTurnStarts.ts";
2325 import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts";
2426 import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts";
2527 import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts";
@@ -97,7 +99,12 @@ function retainProjectionMessagesAfterRevert( · 71 unmodified lines
9799 const missingUserCount = Math.max(0, turnCount - retainedUserCount);
98100 if (missingUserCount > 0) {
99101 const fallbackUserMessages = messages
100− .filter((message) => message.role === "user" && !retainedMessageIds.has(message.messageId))
102+ .filter(
103+ (message) =>
104+ message.role === "user" &&
105+ !retainedMessageIds.has(message.messageId) &&
106+ (message.turnId === null || retainedTurnIds.has(message.turnId)),
107+ )
101108 .toSorted(
102109 (left, right) =>
103110 left.createdAt.localeCompare(right.createdAt) ||
@@ -116,7 +123,10 @@ function retainProjectionMessagesAfterRevert( · 12 unmodified lines
116123 if (missingAssistantCount > 0) {
117124 const fallbackAssistantMessages = messages
118125 .filter(
119− (message) => message.role === "assistant" && !retainedMessageIds.has(message.messageId),
126+ (message) =>
127+ message.role === "assistant" &&
128+ !retainedMessageIds.has(message.messageId) &&
129+ (message.turnId === null || retainedTurnIds.has(message.turnId)),
120130 )
121131 .toSorted(
122132 (left, right) =>
@@ -157,13 +167,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { · 34 unmodified lines
157167 const projectionThreadTurnRepository = yield* ProjectionThreadTurnRepository;
158168 const projectionCheckpointRepository = yield* ProjectionCheckpointRepository;
159169 const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository;
160− const pendingTurnStartByThreadId = new Map<
161− string,
162− {
163− readonly messageId: NonNullable<ProjectionThreadTurn["userMessageId"]>;
164− readonly createdAt: string;
165− }
166− >();
170+ const projectionPendingTurnStartRepository = yield* ProjectionPendingTurnStartRepository;
167171
168172 const applyProjectsProjection: ProjectorDefinition["apply"] = (event) =>
169173 Effect.gen(function* () {
@@ -473,7 +477,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { · 303 unmodified lines
473477 Effect.gen(function* () {
474478 switch (event.type) {
475479 case "thread.turn-start-requested": {
476− pendingTurnStartByThreadId.set(event.payload.threadId, {
480+ yield* projectionPendingTurnStartRepository.upsert({
481+ threadId: event.payload.threadId,
477482 messageId: event.payload.messageId,
478483 createdAt: event.payload.createdAt,
479484 });
@@ -487,13 +492,16 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { · 7 unmodified lines
487492 }
488493
489494 const existingTurn = yield* projectionThreadTurnRepository.getByTurnId({ turnId });
490− const pendingTurnStart = pendingTurnStartByThreadId.get(event.payload.threadId);
495+ const pendingTurnStart = yield* projectionPendingTurnStartRepository.getByThreadId({
496+ threadId: event.payload.threadId,
497+ });
491498 if (Option.isSome(existingTurn)) {
492499 yield* projectionThreadTurnRepository.upsert({
493500 ...existingTurn.value,
494501 status: existingTurn.value.status === "completed" ? "completed" : "running",
495502 userMessageId:
496− existingTurn.value.userMessageId ?? pendingTurnStart?.messageId ?? null,
503+ existingTurn.value.userMessageId ??
504+ (Option.isSome(pendingTurnStart) ? pendingTurnStart.value.messageId : null),
497505 startedAt: existingTurn.value.startedAt,
498506 });
499507 } else {
@@ -505,14 +513,18 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { · 5 unmodified lines
505513 threadId: event.payload.threadId,
506514 turnCount: nextTurnCount(existingThreadTurns),
507515 status: "running",
508− userMessageId: pendingTurnStart?.messageId ?? null,
516+ userMessageId: Option.isSome(pendingTurnStart) ? pendingTurnStart.value.messageId : null,
509517 assistantMessageId: null,
510− startedAt: pendingTurnStart?.createdAt ?? event.occurredAt,
518+ startedAt: Option.isSome(pendingTurnStart)
519+ ? pendingTurnStart.value.createdAt
520+ : event.occurredAt,
511521 completedAt: null,
512522 });
513523 }
514524
515− pendingTurnStartByThreadId.delete(event.payload.threadId);
525+ yield* projectionPendingTurnStartRepository.deleteByThreadId({
526+ threadId: event.payload.threadId,
527+ });
516528 return;
517529 }
518530
@@ -837,9 +849,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { · 318 unmodified lines
837849 const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach(
838850 projectors,
839851 bootstrapProjector,
840− {
841− concurrency: 1,
842− },
852+ { concurrency: 1 },
843853 ).pipe(
844854 Effect.asVoid,
845855 Effect.tap(() =>
@@ -870,5 +880,6 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( · 24 unmodified lines
870880 Layer.provideMerge(ProjectionThreadTurnRepositoryLive),
871881 Layer.provideMerge(ProjectionCheckpointRepositoryLive),
872882 Layer.provideMerge(ProjectionPendingApprovalRepositoryLive),
883+ Layer.provideMerge(ProjectionPendingTurnStartRepositoryLive),
873884 Layer.provideMerge(ProjectionStateRepositoryLive),
874885 );
apps/server/src/orchestration/projector.test.ts+149−0
@@ -502,4 +502,153 @@ describe("orchestration projector", () => { · 501 unmodified lines
502502 expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]);
503503 expect(thread?.latestTurnId).toBe("turn-1");
504504 });
505+
506+ it("does not fallback-retain messages tied to removed turn IDs", async () => {
507+ const createdAt = "2026-02-26T12:00:00.000Z";
508+ const model = createEmptyReadModel(createdAt);
509+
510+ const afterCreate = await Effect.runPromise(
511+ projectEvent(
512+ model,
513+ makeEvent({
514+ sequence: 1,
515+ type: "thread.created",
516+ aggregateKind: "thread",
517+ aggregateId: "thread-revert",
518+ occurredAt: createdAt,
519+ commandId: "cmd-create-revert",
520+ payload: {
521+ threadId: "thread-revert",
522+ projectId: "project-1",
523+ title: "demo",
524+ model: "gpt-5.3-codex",
525+ branch: null,
526+ worktreePath: null,
527+ createdAt,
528+ updatedAt: createdAt,
529+ },
530+ }),
531+ ),
532+ );
533+
534+ const events: ReadonlyArray<OrchestrationEvent> = [
535+ makeEvent({
536+ sequence: 2,
537+ type: "thread.turn-diff-completed",
538+ aggregateKind: "thread",
539+ aggregateId: "thread-revert",
540+ occurredAt: "2026-02-26T12:00:01.000Z",
541+ commandId: "cmd-turn-1",
542+ payload: {
543+ threadId: "thread-revert",
544+ turnId: "turn-1",
545+ checkpointTurnCount: 1,
546+ checkpointRef: "refs/t3/checkpoints/thread-revert/turn/1",
547+ status: "ready",
548+ files: [],
549+ assistantMessageId: "assistant-keep",
550+ completedAt: "2026-02-26T12:00:01.000Z",
551+ },
552+ }),
553+ makeEvent({
554+ sequence: 3,
555+ type: "thread.message-sent",
556+ aggregateKind: "thread",
557+ aggregateId: "thread-revert",
558+ occurredAt: "2026-02-26T12:00:01.100Z",
559+ commandId: "cmd-assistant-keep",
560+ payload: {
561+ threadId: "thread-revert",
562+ messageId: "assistant-keep",
563+ role: "assistant",
564+ text: "kept",
565+ turnId: "turn-1",
566+ streaming: false,
567+ createdAt: "2026-02-26T12:00:01.100Z",
568+ updatedAt: "2026-02-26T12:00:01.100Z",
569+ },
570+ }),
571+ makeEvent({
572+ sequence: 4,
573+ type: "thread.turn-diff-completed",
574+ aggregateKind: "thread",
575+ aggregateId: "thread-revert",
576+ occurredAt: "2026-02-26T12:00:02.000Z",
577+ commandId: "cmd-turn-2",
578+ payload: {
579+ threadId: "thread-revert",
580+ turnId: "turn-2",
581+ checkpointTurnCount: 2,
582+ checkpointRef: "refs/t3/checkpoints/thread-revert/turn/2",
583+ status: "ready",
584+ files: [],
585+ assistantMessageId: "assistant-remove",
586+ completedAt: "2026-02-26T12:00:02.000Z",
587+ },
588+ }),
589+ makeEvent({
590+ sequence: 5,
591+ type: "thread.message-sent",
592+ aggregateKind: "thread",
593+ aggregateId: "thread-revert",
594+ occurredAt: "2026-02-26T12:00:02.050Z",
595+ commandId: "cmd-user-remove",
596+ payload: {
597+ threadId: "thread-revert",
598+ messageId: "user-remove",
599+ role: "user",
600+ text: "removed",
601+ turnId: "turn-2",
602+ streaming: false,
603+ createdAt: "2026-02-26T12:00:02.050Z",
604+ updatedAt: "2026-02-26T12:00:02.050Z",
605+ },
606+ }),
607+ makeEvent({
608+ sequence: 6,
609+ type: "thread.message-sent",
610+ aggregateKind: "thread",
611+ aggregateId: "thread-revert",
612+ occurredAt: "2026-02-26T12:00:02.100Z",
613+ commandId: "cmd-assistant-remove",
614+ payload: {
615+ threadId: "thread-revert",
616+ messageId: "assistant-remove",
617+ role: "assistant",
618+ text: "removed",
619+ turnId: "turn-2",
620+ streaming: false,
621+ createdAt: "2026-02-26T12:00:02.100Z",
622+ updatedAt: "2026-02-26T12:00:02.100Z",
623+ },
624+ }),
625+ makeEvent({
626+ sequence: 7,
627+ type: "thread.reverted",
628+ aggregateKind: "thread",
629+ aggregateId: "thread-revert",
630+ occurredAt: "2026-02-26T12:00:03.000Z",
631+ commandId: "cmd-revert",
632+ payload: {
633+ threadId: "thread-revert",
634+ turnCount: 1,
635+ },
636+ }),
637+ ];
638+
639+ const afterRevert = await events.reduce<Promise<ReturnType<typeof createEmptyReadModel>>>(
640+ (statePromise, event) =>
641+ statePromise.then((state) => Effect.runPromise(projectEvent(state, event))),
642+ Promise.resolve(afterCreate),
643+ );
644+
645+ const thread = afterRevert.threads[0];
646+ expect(
647+ thread?.messages.map((message) => ({
648+ id: message.id,
649+ role: message.role,
650+ turnId: message.turnId,
651+ })),
652+ ).toEqual([{ id: "assistant-keep", role: "assistant", turnId: "turn-1" }]);
653+ });
505654 });
apps/server/src/orchestration/projector.ts+12−2
@@ -65,7 +65,12 @@ function retainThreadMessagesAfterRevert( · 64 unmodified lines
6565 const missingUserCount = Math.max(0, turnCount - retainedUserCount);
6666 if (missingUserCount > 0) {
6767 const fallbackUserMessages = messages
68− .filter((message) => message.role === "user" && !retainedMessageIds.has(message.id))
68+ .filter(
69+ (message) =>
70+ message.role === "user" &&
71+ !retainedMessageIds.has(message.id) &&
72+ (message.turnId === null || retainedTurnIds.has(message.turnId)),
73+ )
6974 .toSorted(
7075 (left, right) =>
7176 left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
@@ -82,7 +87,12 @@ function retainThreadMessagesAfterRevert( · 10 unmodified lines
8287 const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount);
8388 if (missingAssistantCount > 0) {
8489 const fallbackAssistantMessages = messages
85− .filter((message) => message.role === "assistant" && !retainedMessageIds.has(message.id))
90+ .filter(
91+ (message) =>
92+ message.role === "assistant" &&
93+ !retainedMessageIds.has(message.id) &&
94+ (message.turnId === null || retainedTurnIds.has(message.turnId)),
95+ )
8696 .toSorted(
8797 (left, right) =>
8898 left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
apps/server/src/persistence/Layers/ProjectionPendingTurnStarts.ts+92−0
@@ -0,0 +1,92 @@
1+import * as SqlClient from "effect/unstable/sql/SqlClient";
2+import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3+import { Effect, Layer } from "effect";
4+
5+import { toPersistenceSqlError } from "../Errors.ts";
6+import {
7+ DeleteProjectionPendingTurnStartInput,
8+ GetProjectionPendingTurnStartInput,
9+ ProjectionPendingTurnStart,
10+ ProjectionPendingTurnStartRepository,
11+ type ProjectionPendingTurnStartRepositoryShape,
12+} from "../Services/ProjectionPendingTurnStarts.ts";
13+
14+const makeProjectionPendingTurnStartRepository = Effect.gen(function* () {
15+ const sql = yield* SqlClient.SqlClient;
16+
17+ const upsertProjectionPendingTurnStartRow = SqlSchema.void({
18+ Request: ProjectionPendingTurnStart,
19+ execute: (row) =>
20+ sql`
21+ INSERT INTO projection_pending_turn_starts (
22+ thread_id,
23+ message_id,
24+ created_at
25+ )
26+ VALUES (
27+ ${row.threadId},
28+ ${row.messageId},
29+ ${row.createdAt}
30+ )
31+ ON CONFLICT (thread_id)
32+ DO UPDATE SET
33+ message_id = excluded.message_id,
34+ created_at = excluded.created_at
35+ `,
36+ });
37+
38+ const getProjectionPendingTurnStartRow = SqlSchema.findOneOption({
39+ Request: GetProjectionPendingTurnStartInput,
40+ Result: ProjectionPendingTurnStart,
41+ execute: ({ threadId }) =>
42+ sql`
43+ SELECT
44+ thread_id AS "threadId",
45+ message_id AS "messageId",
46+ created_at AS "createdAt"
47+ FROM projection_pending_turn_starts
48+ WHERE thread_id = ${threadId}
49+ `,
50+ });
51+
52+ const deleteProjectionPendingTurnStartRow = SqlSchema.void({
53+ Request: DeleteProjectionPendingTurnStartInput,
54+ execute: ({ threadId }) =>
55+ sql`
56+ DELETE FROM projection_pending_turn_starts
57+ WHERE thread_id = ${threadId}
58+ `,
59+ });
60+
61+ const upsert: ProjectionPendingTurnStartRepositoryShape["upsert"] = (row) =>
62+ upsertProjectionPendingTurnStartRow(row).pipe(
63+ Effect.mapError(toPersistenceSqlError("ProjectionPendingTurnStartRepository.upsert:query")),
64+ );
65+
66+ const getByThreadId: ProjectionPendingTurnStartRepositoryShape["getByThreadId"] = (input) =>
67+ getProjectionPendingTurnStartRow(input).pipe(
68+ Effect.mapError(
69+ toPersistenceSqlError("ProjectionPendingTurnStartRepository.getByThreadId:query"),
70+ ),
71+ );
72+
73+ const deleteByThreadId: ProjectionPendingTurnStartRepositoryShape["deleteByThreadId"] = (
74+ input,
75+ ) =>
76+ deleteProjectionPendingTurnStartRow(input).pipe(
77+ Effect.mapError(
78+ toPersistenceSqlError("ProjectionPendingTurnStartRepository.deleteByThreadId:query"),
79+ ),
80+ );
81+
82+ return {
83+ upsert,
84+ getByThreadId,
85+ deleteByThreadId,
86+ } satisfies ProjectionPendingTurnStartRepositoryShape;
87+});
88+
89+export const ProjectionPendingTurnStartRepositoryLive = Layer.effect(
90+ ProjectionPendingTurnStartRepository,
91+ makeProjectionPendingTurnStartRepository,
92+);
apps/server/src/persistence/Migrations.ts+2−0
@@ -17,6 +17,7 @@ import Migration0002 from "./Migrations/002_OrchestrationCommandReceipts.ts"; · 16 unmodified lines
1717 import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts";
1818 import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts";
1919 import Migration0005 from "./Migrations/005_Projections.ts";
20+import Migration0006 from "./Migrations/006_ProjectionPendingTurnStarts.ts";
2021
2122 /**
2223 * Migration loader with all migrations defined inline.
@@ -34,6 +35,7 @@ const loader = Migrator.fromRecord({ · 11 unmodified lines
3435 "3_CheckpointDiffBlobs": Migration0003,
3536 "4_ProviderSessionRuntime": Migration0004,
3637 "5_Projections": Migration0005,
38+ "6_ProjectionPendingTurnStarts": Migration0006,
3739 });
3840
3941 /**
apps/server/src/persistence/Migrations/006_ProjectionPendingTurnStarts.ts+14−0
@@ -0,0 +1,14 @@
1+import { Effect } from "effect";
2+import * as SqlClient from "effect/unstable/sql/SqlClient";
3+
4+export default Effect.gen(function* () {
5+ const sql = yield* SqlClient.SqlClient;
6+
7+ yield* sql`
8+ CREATE TABLE IF NOT EXISTS projection_pending_turn_starts (
9+ thread_id TEXT PRIMARY KEY,
10+ message_id TEXT NOT NULL,
11+ created_at TEXT NOT NULL
12+ )
13+ `;
14+});
apps/server/src/persistence/Services/ProjectionPendingTurnStarts.ts+41−0
@@ -0,0 +1,41 @@
1+import { IsoDateTime, MessageId, ThreadId } from "@t3tools/contracts";
2+import { Option, Schema, ServiceMap } from "effect";
3+import type { Effect } from "effect";
4+
5+import type { ProjectionRepositoryError } from "../Errors.ts";
6+
7+export const ProjectionPendingTurnStart = Schema.Struct({
8+ threadId: ThreadId,
9+ messageId: MessageId,
10+ createdAt: IsoDateTime,
11+});
12+export type ProjectionPendingTurnStart = typeof ProjectionPendingTurnStart.Type;
13+
14+export const GetProjectionPendingTurnStartInput = Schema.Struct({
15+ threadId: ThreadId,
16+});
17+export type GetProjectionPendingTurnStartInput = typeof GetProjectionPendingTurnStartInput.Type;
18+
19+export const DeleteProjectionPendingTurnStartInput = Schema.Struct({
20+ threadId: ThreadId,
21+});
22+export type DeleteProjectionPendingTurnStartInput = typeof DeleteProjectionPendingTurnStartInput.Type;
23+
24+export interface ProjectionPendingTurnStartRepositoryShape {
25+ readonly upsert: (
26+ row: ProjectionPendingTurnStart,
27+ ) => Effect.Effect<void, ProjectionRepositoryError>;
28+
29+ readonly getByThreadId: (
30+ input: GetProjectionPendingTurnStartInput,
31+ ) => Effect.Effect<Option.Option<ProjectionPendingTurnStart>, ProjectionRepositoryError>;
32+
33+ readonly deleteByThreadId: (
34+ input: DeleteProjectionPendingTurnStartInput,
35+ ) => Effect.Effect<void, ProjectionRepositoryError>;
36+}
37+
38+export class ProjectionPendingTurnStartRepository extends ServiceMap.Service<
39+ ProjectionPendingTurnStartRepository,
40+ ProjectionPendingTurnStartRepositoryShape
41+>()("persistence/ProjectionPendingTurnStartRepository") {}