History

reduce startup time by ~47% and memory usage by up to 150-300MB (#2204)

legs committed 6 months agov0.0.22-nightly.20260504.200

Commit aca0fa41 parent 057682030 files · +1850 −458Browse files

Unified · Split

30 files
  1. apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +9 −0
  2. apps/server/src/cli.test.ts +2 −3
  3. apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +30 −12
  4. apps/server/src/orchestration/Layers/CheckpointReactor.ts +32 −34
  5. apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +82 −43
  6. apps/server/src/orchestration/Layers/OrchestrationEngine.ts +25 −20
  7. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +23 −1
  8. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +375 −0
  9. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +299 −43
  10. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +32 −24
  11. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +27 −20
  12. apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +89 −84
  13. apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +132 −42
  14. apps/server/src/orchestration/Services/OrchestrationEngine.ts +2 −13
  15. apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +22 −0
  16. apps/server/src/persistence/Migrations.ts +2 −0
  17. apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts +73 −0
  18. apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.ts +16 −0
  19. apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +44 −54
  20. apps/server/src/project/Layers/ProjectSetupScriptRunner.ts +10 −6
  21. apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +26 −12
  22. apps/server/src/provider/Layers/ProviderSessionReaper.ts +6 −6
  23. apps/server/src/server.test.ts +2 −1
  24. apps/server/src/serverRuntimeStartup.test.ts +6 −2
  25. apps/server/src/ws.ts +18 −7
  26. apps/web/src/environments/runtime/connection.test.ts +33 −4
  27. apps/web/src/environments/runtime/connection.ts +31 −22
  28. apps/web/src/environments/runtime/service.savedEnvironments.test.ts +326 −0
  29. apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +3 −1
  30. apps/web/src/environments/runtime/service.ts +73 −4
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+9−0
@@ -86,10 +86,13 @@ describe("CheckpointDiffQueryLive", () => { · 85 unmodified lines
8686 Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)),
8787 Layer.provideMerge(
8888 Layer.succeed(ProjectionSnapshotQuery, {
89+ getCommandReadModel: () =>
90+ Effect.die("CheckpointDiffQuery should not request the command read model"),
8991 getSnapshot: () =>
9092 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
9193 getShellSnapshot: () =>
9294 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
95+ getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
9396 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
9497 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
9598 getProjectShellById: () => Effect.succeed(Option.none()),
@@ -163,10 +166,13 @@ describe("CheckpointDiffQueryLive", () => { · 67 unmodified lines
163166 Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)),
164167 Layer.provideMerge(
165168 Layer.succeed(ProjectionSnapshotQuery, {
169+ getCommandReadModel: () =>
170+ Effect.die("CheckpointDiffQuery should not request the command read model"),
166171 getSnapshot: () =>
167172 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
168173 getShellSnapshot: () =>
169174 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
175+ getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
170176 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
171177 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
172178 getProjectShellById: () => Effect.succeed(Option.none()),
@@ -208,10 +214,13 @@ describe("CheckpointDiffQueryLive", () => { · 35 unmodified lines
208214 Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)),
209215 Layer.provideMerge(
210216 Layer.succeed(ProjectionSnapshotQuery, {
217+ getCommandReadModel: () =>
218+ Effect.die("CheckpointDiffQuery should not request the command read model"),
211219 getSnapshot: () =>
212220 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
213221 getShellSnapshot: () =>
214222 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
223+ getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
215224 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
216225 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
217226 getProjectShellById: () => Effect.succeed(Option.none()),
apps/server/src/cli.test.ts+2−3
@@ -17,7 +17,6 @@ import { Command } from "effect/unstable/cli"; · 16 unmodified lines
1717
1818 import { cli } from "./cli.ts";
1919 import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";
20−import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
2120 import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
2221 import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
2322 import {
@@ -316,8 +315,8 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => { · 292 unmodified lines
316315 "--base-dir",
317316 baseDir,
318317 ]);
319− const orchestrationEngine = yield* OrchestrationEngineService;
320− const readModel = yield* orchestrationEngine.getReadModel();
318+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
319+ const readModel = yield* projectionSnapshotQuery.getSnapshot();
321320 const addedProject = readModel.projects.find(
322321 (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null,
323322 );
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+30−12
@@ -41,6 +41,7 @@ import { · 40 unmodified lines
4141 type OrchestrationEngineShape,
4242 } from "../Services/OrchestrationEngine.ts";
4343 import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
44+import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
4445 import {
4546 ProviderService,
4647 type ProviderServiceShape,
@@ -132,7 +133,14 @@ function createProviderServiceHarness( · 85 unmodified lines
132133 }
133134
134135 async function waitForThread(
135− engine: OrchestrationEngineShape,
136+ readModel: () => Promise<{
137+ readonly threads: ReadonlyArray<{
138+ readonly id: ThreadId;
139+ readonly latestTurn: { readonly turnId: string } | null;
140+ readonly checkpoints: ReadonlyArray<{ readonly checkpointTurnCount: number }>;
141+ readonly activities: ReadonlyArray<{ readonly kind: string }>;
142+ }>;
143+ }>,
136144 predicate: (thread: {
137145 latestTurn: { turnId: string } | null;
138146 checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>;
@@ -146,8 +154,8 @@ async function waitForThread( · 7 unmodified lines
146154 checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>;
147155 activities: ReadonlyArray<{ kind: string }>;
148156 }> => {
149− const readModel = await Effect.runPromise(engine.getReadModel());
150− const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
157+ const snapshot = await readModel();
158+ const thread = snapshot.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
151159 if (thread && predicate(thread)) {
152160 return thread;
153161 }
@@ -231,7 +239,7 @@ async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 15_000) · 77 unmodified lines
231239
232240 describe("CheckpointReactor", () => {
233241 let runtime: ManagedRuntime.ManagedRuntime<
234− OrchestrationEngineService | CheckpointReactor | CheckpointStore,
242+ OrchestrationEngineService | CheckpointReactor | CheckpointStore | ProjectionSnapshotQuery,
235243 unknown
236244 > | null = null;
237245 let scope: Scope.Closeable | null = null;
@@ -279,6 +287,10 @@ describe("CheckpointReactor", () => { · 41 unmodified lines
279287 Layer.provide(RepositoryIdentityResolverLive),
280288 Layer.provide(SqlitePersistenceMemory),
281289 );
290+ const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
291+ Layer.provide(RepositoryIdentityResolverLive),
292+ Layer.provide(SqlitePersistenceMemory),
293+ );
282294
283295 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
284296 prefix: "t3-checkpoint-reactor-test-",
@@ -304,6 +316,7 @@ describe("CheckpointReactor", () => { · 19 unmodified lines
304316
305317 const layer = CheckpointReactorLive.pipe(
306318 Layer.provideMerge(orchestrationLayer),
319+ Layer.provideMerge(projectionSnapshotLayer),
307320 Layer.provideMerge(RuntimeReceiptBusLive),
308321 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
309322 Layer.provideMerge(vcsStatusBroadcasterLayer),
@@ -322,6 +335,7 @@ describe("CheckpointReactor", () => { · 12 unmodified lines
322335
323336 runtime = ManagedRuntime.make(layer);
324337 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
338+ const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
325339 const reactor = await runtime.runPromise(Effect.service(CheckpointReactor));
326340 const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore));
327341 scope = await Effect.runPromise(Scope.make("sequential"));
@@ -387,6 +401,7 @@ describe("CheckpointReactor", () => { · 59 unmodified lines
387401
388402 return {
389403 engine,
404+ readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),
390405 provider,
391406 cwd,
392407 drain,
@@ -443,7 +458,7 @@ describe("CheckpointReactor", () => { · 50 unmodified lines
443458
444459 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");
445460 const thread = await waitForThread(
446− harness.engine,
461+ harness.readModel,
447462 (entry) => entry.latestTurn?.turnId === "turn-1" && entry.checkpoints.length === 1,
448463 );
449464 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
@@ -541,7 +556,7 @@ describe("CheckpointReactor", () => { · 91 unmodified lines
541556 });
542557
543558 await harness.drain();
544− const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
559+ const midReadModel = await harness.readModel();
545560 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
546561 expect(midThread?.checkpoints).toHaveLength(0);
547562
@@ -557,7 +572,7 @@ describe("CheckpointReactor", () => { · 9 unmodified lines
557572 });
558573
559574 const thread = await waitForThread(
560− harness.engine,
575+ harness.readModel,
561576 (entry) => entry.latestTurn?.turnId === "turn-main" && entry.checkpoints.length === 1,
562577 );
563578 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
@@ -614,7 +629,7 @@ describe("CheckpointReactor", () => { · 50 unmodified lines
614629
615630 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");
616631 const thread = await waitForThread(
617− harness.engine,
632+ harness.readModel,
618633 (entry) => entry.latestTurn?.turnId === "turn-claude-1" && entry.checkpoints.length === 1,
619634 );
620635
@@ -659,7 +674,7 @@ describe("CheckpointReactor", () => { · 38 unmodified lines
659674
660675 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");
661676 const thread = await waitForThread(
662− harness.engine,
677+ harness.readModel,
663678 (entry) =>
664679 entry.checkpoints.length === 1 &&
665680 entry.activities.some((activity) => activity.kind === "checkpoint.capture.failed"),
@@ -794,7 +809,7 @@ describe("CheckpointReactor", () => { · 128 unmodified lines
794809 });
795810
796811 await harness.drain();
797− const readModel = await Effect.runPromise(harness.engine.getReadModel());
812+ const readModel = await harness.readModel();
798813 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
799814 expect(thread?.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe(
800815 false,
@@ -923,7 +938,10 @@ describe("CheckpointReactor", () => { · 122 unmodified lines
923938 );
924939
925940 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
926− const thread = await waitForThread(harness.engine, (entry) => entry.checkpoints.length === 1);
941+ const thread = await waitForThread(
942+ harness.readModel,
943+ (entry) => entry.checkpoints.length === 1,
944+ );
927945
928946 expect(thread.latestTurn?.turnId).toBe("turn-1");
929947 expect(thread.checkpoints).toHaveLength(1);
@@ -1105,7 +1123,7 @@ describe("CheckpointReactor", () => { · 175 unmodified lines
11051123 }),
11061124 );
11071125
1108− const thread = await waitForThread(harness.engine, (entry) =>
1126+ const thread = await waitForThread(harness.readModel, (entry) =>
11091127 entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"),
11101128 );
11111129
apps/server/src/orchestration/Layers/CheckpointReactor.ts+32−34
@@ -20,6 +20,7 @@ import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts · 19 unmodified lines
2020 import { ProviderService } from "../../provider/Services/ProviderService.ts";
2121 import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
2222 import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
23+import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
2324 import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
2425 import type { CheckpointStoreError } from "../../checkpointing/Errors.ts";
2526 import type { OrchestrationDispatchError } from "../Errors.ts";
@@ -66,6 +67,7 @@ const serverCommandId = (tag: string): CommandId => · 40 unmodified lines
6667
6768 const make = Effect.gen(function* () {
6869 const orchestrationEngine = yield* OrchestrationEngineService;
70+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
6971 const providerService = yield* ProviderService;
7072 const checkpointStore = yield* CheckpointStore;
7173 const receiptBus = yield* RuntimeReceiptBus;
@@ -124,29 +126,26 @@ const make = Effect.gen(function* () { · 52 unmodified lines
124126 const resolveSessionRuntimeForThread = Effect.fn("resolveSessionRuntimeForThread")(function* (
125127 threadId: ThreadId,
126128 ): Effect.fn.Return<Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }>> {
127− const readModel = yield* orchestrationEngine.getReadModel();
128− const thread = readModel.threads.find((entry) => entry.id === threadId);
129−
130129 const sessions = yield* providerService.listSessions();
130+ const session = sessions.find((entry) => entry.threadId === threadId);
131+ return session?.cwd
132+ ? Option.some({ threadId: session.threadId, cwd: session.cwd })
133+ : Option.none();
134+ });
131135
132− const findSessionWithCwd = (
133− session: (typeof sessions)[number] | undefined,
134− ): Option.Option<{ readonly threadId: ThreadId; readonly cwd: string }> => {
135− if (!session?.cwd) {
136− return Option.none();
137− }
138− return Option.some({ threadId: session.threadId, cwd: session.cwd });
139− };
140−
141− if (thread) {
142− const projectedSession = sessions.find((session) => session.threadId === thread.id);
143− const fromProjected = findSessionWithCwd(projectedSession);
144− if (Option.isSome(fromProjected)) {
145− return fromProjected;
146− }
147− }
136+ const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) {
137+ return yield* projectionSnapshotQuery
138+ .getThreadDetailById(threadId)
139+ .pipe(Effect.map(Option.getOrUndefined));
140+ });
148141
149− return Option.none();
142+ const resolveThreadProjects = Effect.fn("resolveThreadProjects")(function* (
143+ projectId: ProjectId,
144+ ) {
145+ const project = yield* projectionSnapshotQuery
146+ .getProjectShellById(projectId)
147+ .pipe(Effect.map(Option.getOrUndefined));
148+ return project ? [project] : [];
150149 });
151150
152151 const isGitWorkspace = (cwd: string) => isGitRepository(cwd);
@@ -331,8 +330,7 @@ const make = Effect.gen(function* () { · 178 unmodified lines
331330 return;
332331 }
333332
334− const readModel = yield* orchestrationEngine.getReadModel();
335− const thread = readModel.threads.find((entry) => entry.id === event.threadId);
333+ const thread = yield* resolveThreadDetail(event.threadId);
336334 if (!thread) {
337335 return;
338336 }
@@ -353,10 +351,11 @@ const make = Effect.gen(function* () { · 14 unmodified lines
353351 return;
354352 }
355353
354+ const projects = yield* resolveThreadProjects(thread.projectId);
356355 const checkpointCwd = yield* resolveCheckpointCwd({
357356 threadId: thread.id,
358357 thread,
359− projects: readModel.projects,
358+ projects,
360359 preferSessionRuntime: true,
361360 });
362361 if (!checkpointCwd) {
@@ -407,8 +406,7 @@ const make = Effect.gen(function* () { · 44 unmodified lines
407406 return;
408407 }
409408
410− const readModel = yield* orchestrationEngine.getReadModel();
411− const thread = readModel.threads.find((entry) => entry.id === threadId);
409+ const thread = yield* resolveThreadDetail(threadId);
412410 if (!thread) {
413411 yield* Effect.logWarning("checkpoint capture from placeholder skipped: thread not found", {
414412 threadId,
@@ -429,10 +427,11 @@ const make = Effect.gen(function* () { · 14 unmodified lines
429427 return;
430428 }
431429
430+ const projects = yield* resolveThreadProjects(thread.projectId);
432431 const checkpointCwd = yield* resolveCheckpointCwd({
433432 threadId,
434433 thread,
435− projects: readModel.projects,
434+ projects,
436435 preferSessionRuntime: true,
437436 });
438437 if (!checkpointCwd) {
@@ -458,16 +457,16 @@ const make = Effect.gen(function* () { · 19 unmodified lines
458457 return;
459458 }
460459
461− const readModel = yield* orchestrationEngine.getReadModel();
462− const thread = readModel.threads.find((entry) => entry.id === event.threadId);
460+ const thread = yield* resolveThreadDetail(event.threadId);
463461 if (!thread) {
464462 return;
465463 }
466464
465+ const projects = yield* resolveThreadProjects(thread.projectId);
467466 const checkpointCwd = yield* resolveCheckpointCwd({
468467 threadId: thread.id,
469468 thread,
470− projects: readModel.projects,
469+ projects,
471470 preferSessionRuntime: false,
472471 });
473472 if (!checkpointCwd) {
@@ -540,16 +539,16 @@ const make = Effect.gen(function* () { · 66 unmodified lines
540539 }
541540
542541 const threadId = event.payload.threadId;
543− const readModel = yield* orchestrationEngine.getReadModel();
544− const thread = readModel.threads.find((entry) => entry.id === threadId);
542+ const thread = yield* resolveThreadDetail(threadId);
545543 if (!thread) {
546544 return;
547545 }
548546
547+ const projects = yield* resolveThreadProjects(thread.projectId);
549548 const checkpointCwd = yield* resolveCheckpointCwd({
550549 threadId,
551550 thread,
552− projects: readModel.projects,
551+ projects,
553552 preferSessionRuntime: false,
554553 });
555554 if (!checkpointCwd) {
@@ -587,8 +586,7 @@ const make = Effect.gen(function* () { · 31 unmodified lines
587586 ) {
588587 const now = new Date().toISOString();
589588
590− const readModel = yield* orchestrationEngine.getReadModel();
591− const thread = readModel.threads.find((entry) => entry.id === event.payload.threadId);
589+ const thread = yield* resolveThreadDetail(event.payload.threadId);
592590 if (!thread) {
593591 yield* appendRevertFailureActivity({
594592 threadId: event.payload.threadId,
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+82−43
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 TurnId,
99 type OrchestrationEvent,
1010 ProviderInstanceId,
11+ type OrchestrationReadModel,
1112 } from "@t3tools/contracts";
1213 import { Effect, Layer, ManagedRuntime, Metric, Option, Queue, Stream } from "effect";
1314 import { describe, expect, it } from "vitest";
@@ -42,9 +43,13 @@ async function createOrchestrationSystem() { · 28 unmodified lines
4243 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
4344 prefix: "t3-orchestration-engine-test-",
4445 });
45− const orchestrationLayer = OrchestrationEngineLive.pipe(
46− Layer.provide(OrchestrationProjectionSnapshotQueryLive),
47− Layer.provide(OrchestrationProjectionPipelineLive),
46+ const orchestrationLayer = Layer.mergeAll(
47+ OrchestrationEngineLive.pipe(
48+ Layer.provide(OrchestrationProjectionSnapshotQueryLive),
49+ Layer.provide(OrchestrationProjectionPipelineLive),
50+ ),
51+ OrchestrationProjectionSnapshotQueryLive,
52+ ).pipe(
4853 Layer.provide(OrchestrationEventStoreLive),
4954 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
5055 Layer.provide(RepositoryIdentityResolverLive),
@@ -54,8 +59,10 @@ async function createOrchestrationSystem() { · 3 unmodified lines
5459 );
5560 const runtime = ManagedRuntime.make(orchestrationLayer);
5661 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
62+ const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
5763 return {
5864 engine,
65+ readModel: () => runtime.runPromise(snapshotQuery.getSnapshot()),
5966 run: <A, E>(effect: Effect.Effect<A, E>) => runtime.runPromise(effect),
6067 dispose: () => runtime.dispose(),
6168 };
@@ -77,15 +84,18 @@ const hasMetricSnapshot = ( · 15 unmodified lines
7784 );
7885
7986 describe("OrchestrationEngine", () => {
80− it("bootstraps the in-memory read model from persisted projections", async () => {
81− const failOnHistoricalReplayStore: OrchestrationEventStoreShape = {
82− append: () =>
83− Effect.fail(
84− new PersistenceSqlError({
85− operation: "test.append",
86− detail: "append should not be called during bootstrap",
87− }),
88− ),
87+ it("bootstraps command handling from persisted projections without reading the full snapshot", async () => {
88+ let nextSequence = 8;
89+ const eventStore: OrchestrationEventStoreShape = {
90+ append: (event) =>
91+ Effect.sync(() => {
92+ const savedEvent = {
93+ ...event,
94+ sequence: nextSequence,
95+ } as OrchestrationEvent;
96+ nextSequence += 1;
97+ return savedEvent;
98+ }),
8999 readFromSequence: () => Stream.empty,
90100 readAll: () =>
91101 Stream.fail(
@@ -140,11 +150,27 @@ describe("OrchestrationEngine", () => { · 48 unmodified lines
140150 },
141151 ],
142152 };
153+ const commandReadModel = {
154+ ...projectionSnapshot,
155+ threads: projectionSnapshot.threads.map((thread) => ({
156+ ...thread,
157+ messages: [],
158+ proposedPlans: [],
159+ activities: [],
160+ checkpoints: [],
161+ })),
162+ };
163+ let fullSnapshotReadCount = 0;
143164
144165 const layer = OrchestrationEngineLive.pipe(
145166 Layer.provide(
146167 Layer.succeed(ProjectionSnapshotQuery, {
147− getSnapshot: () => Effect.succeed(projectionSnapshot),
168+ getCommandReadModel: () => Effect.succeed(commandReadModel),
169+ getSnapshot: () =>
170+ Effect.sync(() => {
171+ fullSnapshotReadCount += 1;
172+ return projectionSnapshot;
173+ }),
148174 getShellSnapshot: () =>
149175 Effect.succeed({
150176 snapshotSequence: projectionSnapshot.snapshotSequence,
@@ -152,6 +178,8 @@ describe("OrchestrationEngine", () => { · 1 unmodified line
152178 threads: [],
153179 updatedAt: projectionSnapshot.updatedAt,
154180 }),
181+ getSnapshotSequence: () =>
182+ Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence }),
155183 getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }),
156184 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
157185 getProjectShellById: () => Effect.succeed(Option.none()),
@@ -167,7 +195,7 @@ describe("OrchestrationEngine", () => { · 9 unmodified lines
167195 projectEvent: () => Effect.void,
168196 } satisfies OrchestrationProjectionPipelineShape),
169197 ),
170− Layer.provide(Layer.succeed(OrchestrationEventStore, failOnHistoricalReplayStore)),
198+ Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)),
171199 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
172200 Layer.provide(SqlitePersistenceMemory),
173201 );
@@ -175,18 +203,22 @@ describe("OrchestrationEngine", () => { · 1 unmodified line
175203 const runtime = ManagedRuntime.make(layer);
176204
177205 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
178− const readModel = await runtime.runPromise(engine.getReadModel());
206+ const result = await runtime.runPromise(
207+ engine.dispatch({
208+ type: "thread.meta.update",
209+ commandId: CommandId.make("cmd-bootstrap-thread-update"),
210+ threadId: ThreadId.make("thread-bootstrap"),
211+ title: "Updated Bootstrap Thread",
212+ }),
213+ );
179214
180− expect(readModel.snapshotSequence).toBe(7);
181− expect(readModel.projects).toHaveLength(1);
182− expect(readModel.projects[0]?.title).toBe("Bootstrap Project");
183− expect(readModel.threads).toHaveLength(1);
184− expect(readModel.threads[0]?.title).toBe("Bootstrap Thread");
215+ expect(result.sequence).toBe(8);
216+ expect(fullSnapshotReadCount).toBe(0);
185217
186218 await runtime.dispose();
187219 });
188220
189− it("returns deterministic read models for repeated reads", async () => {
221+ it("persists deterministic read models for repeated snapshot reads", async () => {
190222 const createdAt = now();
191223 const system = await createOrchestrationSystem();
192224 const { engine } = system;
@@ -240,8 +272,8 @@ describe("OrchestrationEngine", () => { · 47 unmodified lines
240272 }),
241273 );
242274
243− const readModelA = await system.run(engine.getReadModel());
244− const readModelB = await system.run(engine.getReadModel());
275+ const readModelA = await system.readModel();
276+ const readModelB = await system.readModel();
245277 expect(readModelB).toEqual(readModelA);
246278 await system.dispose();
247279 });
@@ -292,9 +324,8 @@ describe("OrchestrationEngine", () => { · 44 unmodified lines
292324 }),
293325 );
294326 expect(
295− (await system.run(engine.getReadModel())).threads.find(
296− (thread) => thread.id === "thread-archive",
297− )?.archivedAt,
327+ (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")
328+ ?.archivedAt,
298329 ).not.toBeNull();
299330
300331 await system.run(
@@ -305,9 +336,8 @@ describe("OrchestrationEngine", () => { · 4 unmodified lines
305336 }),
306337 );
307338 expect(
308− (await system.run(engine.getReadModel())).threads.find(
309− (thread) => thread.id === "thread-archive",
310− )?.archivedAt,
339+ (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")
340+ ?.archivedAt,
311341 ).toBeNull();
312342
313343 await system.dispose();
@@ -573,7 +603,7 @@ describe("OrchestrationEngine", () => { · 259 unmodified lines
573603 }),
574604 );
575605
576− const thread = (await system.run(engine.getReadModel())).threads.find(
606+ const thread = (await system.readModel()).threads.find(
577607 (entry) => entry.id === "thread-turn-diff",
578608 );
579609 expect(thread?.checkpoints).toEqual([
@@ -701,7 +731,15 @@ describe("OrchestrationEngine", () => { · 121 unmodified lines
701731 );
702732
703733 expect(result.sequence).toBe(2);
704− expect((await runtime.runPromise(engine.getReadModel())).snapshotSequence).toBe(2);
734+ const eventsAfterRetry = await runtime.runPromise(
735+ Stream.runCollect(engine.readEvents(0)).pipe(
736+ Effect.map((chunk): OrchestrationEvent[] => Array.from(chunk)),
737+ ),
738+ );
739+ expect(eventsAfterRetry.map((event) => event.type)).toEqual([
740+ "project.created",
741+ "thread.created",
742+ ]);
705743 await runtime.dispose();
706744 });
707745
@@ -801,7 +839,6 @@ describe("OrchestrationEngine", () => { · 93 unmodified lines
801839 "project.created",
802840 "thread.created",
803841 ]);
804− expect((await runtime.runPromise(engine.getReadModel())).snapshotSequence).toBe(2);
805842
806843 const retryResult = await runtime.runPromise(engine.dispatch(turnStartCommand));
807844 expect(retryResult.sequence).toBe(4);
@@ -824,7 +861,7 @@ describe("OrchestrationEngine", () => { · 16 unmodified lines
824861 await runtime.dispose();
825862 });
826863
827− it("reconciles in-memory state when append persists but projection fails", async () => {
864+ it("reconciles command state when append persists but projection fails", async () => {
828865 type StoredEvent =
829866 ReturnType<OrchestrationEventStoreShape["append"]> extends Effect.Effect<infer A, any, any>
830867 ? A
@@ -856,7 +893,7 @@ describe("OrchestrationEngine", () => { · 25 unmodified lines
856893 projectEvent: (event) => {
857894 if (
858895 shouldFailProjection &&
859− event.commandId === CommandId.make("cmd-thread-meta-sync-fail")
896+ event.commandId === CommandId.make("cmd-thread-archive-sync-fail")
860897 ) {
861898 shouldFailProjection = false;
862899 return Effect.fail(
@@ -919,20 +956,22 @@ describe("OrchestrationEngine", () => { · 56 unmodified lines
919956 await expect(
920957 runtime.runPromise(
921958 engine.dispatch({
922− type: "thread.meta.update",
923− commandId: CommandId.make("cmd-thread-meta-sync-fail"),
959+ type: "thread.archive",
960+ commandId: CommandId.make("cmd-thread-archive-sync-fail"),
924961 threadId: ThreadId.make("thread-sync"),
925− title: "sync-after-failed-projection",
926962 }),
927963 ),
928964 ).rejects.toThrow("projection failed");
929965
930− const readModelAfterFailure = await runtime.runPromise(engine.getReadModel());
931− const updatedThread = readModelAfterFailure.threads.find(
932− (thread) => thread.id === "thread-sync",
933− );
934− expect(readModelAfterFailure.snapshotSequence).toBe(3);
935− expect(updatedThread?.title).toBe("sync-after-failed-projection");
966+ await expect(
967+ runtime.runPromise(
968+ engine.dispatch({
969+ type: "thread.archive",
970+ commandId: CommandId.make("cmd-thread-archive-sync-retry"),
971+ threadId: ThreadId.make("thread-sync"),
972+ }),
973+ ),
974+ ).rejects.toThrow("already archived");
936975
937976 await runtime.dispose();
938977 });
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+25−20
@@ -34,6 +34,7 @@ import { · 33 unmodified lines
3434 OrchestrationCommandInvariantError,
3535 OrchestrationCommandPreviouslyRejectedError,
3636 type OrchestrationDispatchError,
37+ type OrchestrationProjectorDecodeError,
3738 } from "../Errors.ts";
3839 import { decideOrchestrationCommand } from "../decider.ts";
3940 import { createEmptyReadModel, projectEvent } from "../projector.ts";
@@ -77,13 +78,25 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 37 unmodified lines
7778 const projectionPipeline = yield* OrchestrationProjectionPipeline;
7879 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
7980
80− let readModel = createEmptyReadModel(new Date().toISOString());
81+ let commandReadModel = createEmptyReadModel(new Date().toISOString());
8182
8283 const commandQueue = yield* Queue.unbounded<CommandEnvelope>();
8384 const eventPubSub = yield* PubSub.unbounded<OrchestrationEvent>();
8485
86+ const projectEventsOntoReadModel = (
87+ baseReadModel: OrchestrationReadModel,
88+ events: ReadonlyArray<OrchestrationEvent>,
89+ ): Effect.Effect<OrchestrationReadModel, OrchestrationProjectorDecodeError, never> =>
90+ Effect.gen(function* () {
91+ let nextReadModel = baseReadModel;
92+ for (const event of events) {
93+ nextReadModel = yield* projectEvent(nextReadModel, event);
94+ }
95+ return nextReadModel;
96+ });
97+
8598 const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> => {
86− const dispatchStartSequence = readModel.snapshotSequence;
99+ const dispatchStartSequence = commandReadModel.snapshotSequence;
87100 const processingStartedAtMs = Date.now();
88101 const aggregateRef = commandToAggregateRef(envelope.command);
89102 const baseMetricAttributes = {
@@ -98,11 +111,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 8 unmodified lines
98111 return;
99112 }
100113
101− let nextReadModel = readModel;
102− for (const persistedEvent of persistedEvents) {
103− nextReadModel = yield* projectEvent(nextReadModel, persistedEvent);
104− }
105− readModel = nextReadModel;
114+ commandReadModel = yield* projectEventsOntoReadModel(commandReadModel, persistedEvents);
106115
107116 for (const persistedEvent of persistedEvents) {
108117 yield* PubSub.publish(eventPubSub, persistedEvent);
@@ -135,18 +144,18 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 26 unmodified lines
135144
136145 const eventBase = yield* decideOrchestrationCommand({
137146 command: envelope.command,
138− readModel,
147+ readModel: commandReadModel,
139148 });
140149 const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase];
141150 const committedCommand = yield* sql
142151 .withTransaction(
143152 Effect.gen(function* () {
144153 const committedEvents: OrchestrationEvent[] = [];
145− let nextReadModel = readModel;
154+ let nextCommandReadModel = commandReadModel;
146155
147156 for (const nextEvent of eventBases) {
148157 const savedEvent = yield* eventStore.append(nextEvent);
149− nextReadModel = yield* projectEvent(nextReadModel, savedEvent);
158+ nextCommandReadModel = yield* projectEvent(nextCommandReadModel, savedEvent);
150159 yield* projectionPipeline.projectEvent(savedEvent);
151160 committedEvents.push(savedEvent);
152161 }
@@ -172,7 +181,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 19 unmodified lines
172181 return {
173182 committedEvents,
174183 lastSequence: lastSavedEvent.sequence,
175− nextReadModel,
184+ nextCommandReadModel,
176185 } as const;
177186 }),
178187 )
@@ -184,7 +193,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 5 unmodified lines
184193 ),
185194 );
186195
187− readModel = committedCommand.nextReadModel;
196+ commandReadModel = committedCommand.nextCommandReadModel;
188197 for (const [index, event] of committedCommand.committedEvents.entries()) {
189198 yield* PubSub.publish(eventPubSub, event);
190199 if (index === 0) {
@@ -242,7 +251,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 51 unmodified lines
242251 ).pipe(
243252 Effect.annotateLogs({
244253 commandId: envelope.command.commandId,
245− snapshotSequence: readModel.snapshotSequence,
254+ snapshotSequence: commandReadModel.snapshotSequence,
246255 }),
247256 ),
248257 ),
@@ -255,7 +264,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 6 unmodified lines
255264 aggregateKind: aggregateRef.aggregateKind,
256265 aggregateId: aggregateRef.aggregateId,
257266 acceptedAt: new Date().toISOString(),
258− resultSequence: readModel.snapshotSequence,
267+ resultSequence: commandReadModel.snapshotSequence,
259268 status: "rejected",
260269 error: error.message,
261270 })
@@ -270,17 +279,14 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 8 unmodified lines
270279 };
271280
272281 yield* projectionPipeline.bootstrap;
273− readModel = yield* projectionSnapshotQuery.getSnapshot();
282+ commandReadModel = yield* projectionSnapshotQuery.getCommandReadModel();
274283
275284 const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope)));
276285 yield* Effect.forkScoped(worker);
277286 yield* Effect.logDebug("orchestration engine started").pipe(
278− Effect.annotateLogs({ sequence: readModel.snapshotSequence }),
287+ Effect.annotateLogs({ sequence: commandReadModel.snapshotSequence }),
279288 );
280289
281− const getReadModel: OrchestrationEngineShape["getReadModel"] = () =>
282− Effect.sync((): OrchestrationReadModel => readModel);
283−
284290 const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive) =>
285291 eventStore.readFromSequence(fromSequenceExclusive);
286292
@@ -292,7 +298,6 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 5 unmodified lines
292298 });
293299
294300 return {
295− getReadModel,
296301 readEvents,
297302 dispatch,
298303 // Each access creates a fresh PubSub subscription so that multiple
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+23−1
@@ -739,9 +739,31 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 738 unmodified lines
739739 if (Option.isNone(existingRow)) {
740740 return;
741741 }
742+
743+ const retainedTurns = yield* projectionTurnRepository.listByThreadId({
744+ threadId: event.payload.threadId,
745+ });
746+ let latestTurnId: ProjectionTurn["turnId"] = null;
747+ let latestCheckpointTurnCount = -1;
748+ for (let index = 0; index < retainedTurns.length; index += 1) {
749+ const turn = retainedTurns[index];
750+ if (
751+ !turn ||
752+ turn.turnId === null ||
753+ turn.checkpointTurnCount === null ||
754+ turn.checkpointTurnCount > event.payload.turnCount
755+ ) {
756+ continue;
757+ }
758+ if (turn.checkpointTurnCount > latestCheckpointTurnCount) {
759+ latestCheckpointTurnCount = turn.checkpointTurnCount;
760+ latestTurnId = turn.turnId;
761+ }
762+ }
763+
742764 yield* projectionThreadRepository.upsert({
743765 ...existingRow.value,
744− latestTurnId: null,
766+ latestTurnId,
745767 updatedAt: event.occurredAt,
746768 });
747769 yield* refreshThreadShellSummary(event.payload.threadId);
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+375−0
@@ -12,6 +12,7 @@ import { Effect, Layer } from "effect"; · 11 unmodified lines
1212 import * as SqlClient from "effect/unstable/sql/SqlClient";
1313
1414 import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
15+import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts";
1516 import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
1617 import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
1718 import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
@@ -1029,4 +1030,378 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 1011 unmodified lines
10291030 }
10301031 }),
10311032 );
1033+
1034+ it.effect("uses projection_threads.latest_turn_id for bulk command and shell snapshots", () =>
1035+ Effect.gen(function* () {
1036+ const snapshotQuery = yield* ProjectionSnapshotQuery;
1037+ const sql = yield* SqlClient.SqlClient;
1038+
1039+ yield* sql`DELETE FROM projection_projects`;
1040+ yield* sql`DELETE FROM projection_threads`;
1041+ yield* sql`DELETE FROM projection_turns`;
1042+ yield* sql`DELETE FROM projection_state`;
1043+
1044+ yield* sql`
1045+ INSERT INTO projection_projects (
1046+ project_id,
1047+ title,
1048+ workspace_root,
1049+ default_model_selection_json,
1050+ scripts_json,
1051+ created_at,
1052+ updated_at,
1053+ deleted_at
1054+ )
1055+ VALUES (
1056+ 'project-1',
1057+ 'Project 1',
1058+ '/tmp/project-1',
1059+ '{"provider":"codex","model":"gpt-5-codex"}',
1060+ '[]',
1061+ '2026-04-03T00:00:00.000Z',
1062+ '2026-04-03T00:00:01.000Z',
1063+ NULL
1064+ )
1065+ `;
1066+
1067+ yield* sql`
1068+ INSERT INTO projection_threads (
1069+ thread_id,
1070+ project_id,
1071+ title,
1072+ model_selection_json,
1073+ runtime_mode,
1074+ interaction_mode,
1075+ branch,
1076+ worktree_path,
1077+ latest_turn_id,
1078+ latest_user_message_at,
1079+ pending_approval_count,
1080+ pending_user_input_count,
1081+ has_actionable_proposed_plan,
1082+ created_at,
1083+ updated_at,
1084+ archived_at,
1085+ deleted_at
1086+ )
1087+ VALUES (
1088+ 'thread-1',
1089+ 'project-1',
1090+ 'Thread 1',
1091+ '{"provider":"codex","model":"gpt-5-codex"}',
1092+ 'full-access',
1093+ 'default',
1094+ NULL,
1095+ NULL,
1096+ 'turn-running',
1097+ '2026-04-03T00:00:04.000Z',
1098+ 0,
1099+ 0,
1100+ 0,
1101+ '2026-04-03T00:00:02.000Z',
1102+ '2026-04-03T00:00:03.000Z',
1103+ NULL,
1104+ NULL
1105+ )
1106+ `;
1107+
1108+ yield* sql`
1109+ INSERT INTO projection_turns (
1110+ thread_id,
1111+ turn_id,
1112+ pending_message_id,
1113+ source_proposed_plan_thread_id,
1114+ source_proposed_plan_id,
1115+ assistant_message_id,
1116+ state,
1117+ requested_at,
1118+ started_at,
1119+ completed_at,
1120+ checkpoint_turn_count,
1121+ checkpoint_ref,
1122+ checkpoint_status,
1123+ checkpoint_files_json
1124+ )
1125+ VALUES
1126+ (
1127+ 'thread-1',
1128+ 'turn-running',
1129+ 'message-user-2',
1130+ NULL,
1131+ NULL,
1132+ NULL,
1133+ 'running',
1134+ '2026-04-03T00:00:30.000Z',
1135+ '2026-04-03T00:00:30.000Z',
1136+ NULL,
1137+ NULL,
1138+ NULL,
1139+ NULL,
1140+ '[]'
1141+ ),
1142+ (
1143+ 'thread-1',
1144+ 'turn-completed',
1145+ 'message-user-1',
1146+ NULL,
1147+ NULL,
1148+ 'message-assistant-1',
1149+ 'completed',
1150+ '2026-04-03T00:00:05.000Z',
1151+ '2026-04-03T00:00:06.000Z',
1152+ '2026-04-03T00:00:20.000Z',
1153+ NULL,
1154+ NULL,
1155+ NULL,
1156+ '[]'
1157+ )
1158+ `;
1159+
1160+ yield* sql`
1161+ INSERT INTO projection_state (projector, last_applied_sequence, updated_at)
1162+ VALUES
1163+ (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 3, '2026-04-03T00:00:40.000Z'),
1164+ (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 3, '2026-04-03T00:00:40.000Z'),
1165+ (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 3, '2026-04-03T00:00:40.000Z'),
1166+ (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 3, '2026-04-03T00:00:40.000Z'),
1167+ (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 3, '2026-04-03T00:00:40.000Z'),
1168+ (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 3, '2026-04-03T00:00:40.000Z'),
1169+ (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 3, '2026-04-03T00:00:40.000Z')
1170+ `;
1171+
1172+ const commandReadModel = yield* snapshotQuery.getCommandReadModel();
1173+ assert.equal(commandReadModel.threads[0]?.latestTurn?.turnId, asTurnId("turn-running"));
1174+ assert.equal(commandReadModel.threads[0]?.latestTurn?.state, "running");
1175+
1176+ const shellSnapshot = yield* snapshotQuery.getShellSnapshot();
1177+ assert.equal(shellSnapshot.threads[0]?.latestTurn?.turnId, asTurnId("turn-running"));
1178+ assert.equal(shellSnapshot.threads[0]?.latestTurn?.state, "running");
1179+
1180+ const fullSnapshot = yield* snapshotQuery.getSnapshot();
1181+ assert.equal(fullSnapshot.threads[0]?.latestTurn?.turnId, asTurnId("turn-running"));
1182+ assert.equal(fullSnapshot.threads[0]?.latestTurn?.state, "running");
1183+ }),
1184+ );
1185+
1186+ it.effect("keeps deleted project and thread tombstones in the command read model", () =>
1187+ Effect.gen(function* () {
1188+ const snapshotQuery = yield* ProjectionSnapshotQuery;
1189+ const sql = yield* SqlClient.SqlClient;
1190+
1191+ yield* sql`DELETE FROM projection_projects`;
1192+ yield* sql`DELETE FROM projection_threads`;
1193+ yield* sql`DELETE FROM projection_turns`;
1194+ yield* sql`DELETE FROM projection_state`;
1195+
1196+ yield* sql`
1197+ INSERT INTO projection_projects (
1198+ project_id,
1199+ title,
1200+ workspace_root,
1201+ default_model_selection_json,
1202+ scripts_json,
1203+ created_at,
1204+ updated_at,
1205+ deleted_at
1206+ )
1207+ VALUES (
1208+ 'project-deleted',
1209+ 'Deleted Project',
1210+ '/tmp/deleted-project',
1211+ '{"provider":"codex","model":"gpt-5-codex"}',
1212+ '[]',
1213+ '2026-04-05T00:00:00.000Z',
1214+ '2026-04-05T00:00:01.000Z',
1215+ '2026-04-05T00:00:02.000Z'
1216+ )
1217+ `;
1218+
1219+ yield* sql`
1220+ INSERT INTO projection_threads (
1221+ thread_id,
1222+ project_id,
1223+ title,
1224+ model_selection_json,
1225+ runtime_mode,
1226+ interaction_mode,
1227+ branch,
1228+ worktree_path,
1229+ latest_turn_id,
1230+ latest_user_message_at,
1231+ pending_approval_count,
1232+ pending_user_input_count,
1233+ has_actionable_proposed_plan,
1234+ created_at,
1235+ updated_at,
1236+ archived_at,
1237+ deleted_at
1238+ )
1239+ VALUES (
1240+ 'thread-deleted',
1241+ 'project-deleted',
1242+ 'Deleted Thread',
1243+ '{"provider":"codex","model":"gpt-5-codex"}',
1244+ 'full-access',
1245+ 'default',
1246+ NULL,
1247+ NULL,
1248+ 'turn-deleted',
1249+ NULL,
1250+ 0,
1251+ 0,
1252+ 0,
1253+ '2026-04-05T00:00:03.000Z',
1254+ '2026-04-05T00:00:04.000Z',
1255+ NULL,
1256+ '2026-04-05T00:00:05.000Z'
1257+ )
1258+ `;
1259+
1260+ yield* sql`
1261+ INSERT INTO projection_turns (
1262+ thread_id,
1263+ turn_id,
1264+ pending_message_id,
1265+ source_proposed_plan_thread_id,
1266+ source_proposed_plan_id,
1267+ assistant_message_id,
1268+ state,
1269+ requested_at,
1270+ started_at,
1271+ completed_at,
1272+ checkpoint_turn_count,
1273+ checkpoint_ref,
1274+ checkpoint_status,
1275+ checkpoint_files_json
1276+ )
1277+ VALUES (
1278+ 'thread-deleted',
1279+ 'turn-deleted',
1280+ 'message-deleted-user',
1281+ NULL,
1282+ NULL,
1283+ 'message-deleted-assistant',
1284+ 'completed',
1285+ '2026-04-05T00:00:04.100Z',
1286+ '2026-04-05T00:00:04.200Z',
1287+ '2026-04-05T00:00:04.300Z',
1288+ NULL,
1289+ NULL,
1290+ NULL,
1291+ '[]'
1292+ )
1293+ `;
1294+
1295+ const commandReadModel = yield* snapshotQuery.getCommandReadModel();
1296+ assert.equal(commandReadModel.projects[0]?.id, asProjectId("project-deleted"));
1297+ assert.equal(commandReadModel.projects[0]?.deletedAt, "2026-04-05T00:00:02.000Z");
1298+ assert.equal(commandReadModel.threads[0]?.id, ThreadId.make("thread-deleted"));
1299+ assert.equal(commandReadModel.threads[0]?.deletedAt, "2026-04-05T00:00:05.000Z");
1300+ assert.equal(commandReadModel.threads[0]?.latestTurn?.turnId, asTurnId("turn-deleted"));
1301+ assert.equal(commandReadModel.threads[0]?.latestTurn?.state, "completed");
1302+
1303+ const fullSnapshot = yield* snapshotQuery.getSnapshot();
1304+ assert.equal(fullSnapshot.threads[0]?.id, ThreadId.make("thread-deleted"));
1305+ assert.equal(fullSnapshot.threads[0]?.latestTurn?.turnId, asTurnId("turn-deleted"));
1306+ assert.equal(fullSnapshot.threads[0]?.latestTurn?.state, "completed");
1307+
1308+ const shellSnapshot = yield* snapshotQuery.getShellSnapshot();
1309+ assert.equal(shellSnapshot.projects.length, 0);
1310+ assert.equal(shellSnapshot.threads.length, 0);
1311+ }),
1312+ );
10321313 });
1314+
1315+it.effect(
1316+ "ProjectionSnapshotQuery dedupes repository identity resolution by workspace root and skips deleted projects for shell snapshots",
1317+ () => {
1318+ const resolveCalls: string[] = [];
1319+ const layer = OrchestrationProjectionSnapshotQueryLive.pipe(
1320+ Layer.provideMerge(
1321+ Layer.succeed(RepositoryIdentityResolver, {
1322+ resolve: (cwd: string) =>
1323+ Effect.sync(() => {
1324+ resolveCalls.push(cwd);
1325+ return {
1326+ canonicalKey: `github.com/acme${cwd}`,
1327+ locator: {
1328+ source: "git-remote" as const,
1329+ remoteName: "origin",
1330+ remoteUrl: `https://github.com/acme${cwd}.git`,
1331+ },
1332+ rootPath: cwd,
1333+ };
1334+ }),
1335+ }),
1336+ ),
1337+ Layer.provideMerge(SqlitePersistenceMemory),
1338+ );
1339+
1340+ return Effect.gen(function* () {
1341+ const snapshotQuery = yield* ProjectionSnapshotQuery;
1342+ const sql = yield* SqlClient.SqlClient;
1343+
1344+ yield* sql`DELETE FROM projection_projects`;
1345+ yield* sql`DELETE FROM projection_threads`;
1346+ yield* sql`DELETE FROM projection_turns`;
1347+ yield* sql`DELETE FROM projection_state`;
1348+
1349+ yield* sql`
1350+ INSERT INTO projection_projects (
1351+ project_id,
1352+ title,
1353+ workspace_root,
1354+ default_model_selection_json,
1355+ scripts_json,
1356+ created_at,
1357+ updated_at,
1358+ deleted_at
1359+ )
1360+ VALUES
1361+ (
1362+ 'project-1',
1363+ 'Shared Project 1',
1364+ '/tmp/shared-root',
1365+ '{"provider":"codex","model":"gpt-5-codex"}',
1366+ '[]',
1367+ '2026-04-04T00:00:00.000Z',
1368+ '2026-04-04T00:00:01.000Z',
1369+ NULL
1370+ ),
1371+ (
1372+ 'project-2',
1373+ 'Shared Project 2',
1374+ '/tmp/shared-root',
1375+ '{"provider":"codex","model":"gpt-5-codex"}',
1376+ '[]',
1377+ '2026-04-04T00:00:02.000Z',
1378+ '2026-04-04T00:00:03.000Z',
1379+ NULL
1380+ ),
1381+ (
1382+ 'project-3',
1383+ 'Deleted Project',
1384+ '/tmp/deleted-root',
1385+ '{"provider":"codex","model":"gpt-5-codex"}',
1386+ '[]',
1387+ '2026-04-04T00:00:04.000Z',
1388+ '2026-04-04T00:00:05.000Z',
1389+ '2026-04-04T00:00:06.000Z'
1390+ )
1391+ `;
1392+
1393+ const shellSnapshot = yield* snapshotQuery.getShellSnapshot();
1394+ assert.deepStrictEqual(resolveCalls.toSorted(), ["/tmp/shared-root"]);
1395+ assert.equal(shellSnapshot.projects.length, 2);
1396+ assert.equal(shellSnapshot.projects[0]?.repositoryIdentity?.rootPath, "/tmp/shared-root");
1397+ assert.equal(shellSnapshot.projects[1]?.repositoryIdentity?.rootPath, "/tmp/shared-root");
1398+
1399+ resolveCalls.length = 0;
1400+
1401+ const fullSnapshot = yield* snapshotQuery.getSnapshot();
1402+ assert.deepStrictEqual(resolveCalls.toSorted(), ["/tmp/deleted-root", "/tmp/shared-root"]);
1403+ assert.equal(fullSnapshot.projects.length, 3);
1404+ assert.equal(fullSnapshot.projects[2]?.repositoryIdentity?.rootPath, "/tmp/deleted-root");
1405+ }).pipe(Effect.provide(layer));
1406+ },
1407+);
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+299−43
@@ -219,6 +219,20 @@ function mapProjectShellRow( · 218 unmodified lines
219219 };
220220 }
221221
222+function mapProposedPlanRow(
223+ row: Schema.Schema.Type<typeof ProjectionThreadProposedPlanDbRowSchema>,
224+): OrchestrationProposedPlan {
225+ return {
226+ id: row.planId,
227+ turnId: row.turnId,
228+ planMarkdown: row.planMarkdown,
229+ implementedAt: row.implementedAt,
230+ implementationThreadId: row.implementationThreadId,
231+ createdAt: row.createdAt,
232+ updatedAt: row.updatedAt,
233+ };
234+}
235+
222236 function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
223237 return (cause: unknown): ProjectionRepositoryError =>
224238 Schema.isSchemaError(cause)
@@ -230,6 +244,37 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 5 unmodified lines
230244 const sql = yield* SqlClient.SqlClient;
231245 const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
232246 const repositoryIdentityResolutionConcurrency = 4;
247+ const resolveRepositoryIdentitiesForProjects = Effect.fn(
248+ "ProjectionSnapshotQuery.resolveRepositoryIdentitiesForProjects",
249+ )(function* (
250+ projectRows: ReadonlyArray<Schema.Schema.Type<typeof ProjectionProjectDbRowSchema>>,
251+ options?: {
252+ readonly includeDeleted?: boolean;
253+ },
254+ ) {
255+ const filteredProjectRows =
256+ options?.includeDeleted === true
257+ ? projectRows
258+ : projectRows.filter((row) => row.deletedAt === null);
259+ const uniqueWorkspaceRoots = [...new Set(filteredProjectRows.map((row) => row.workspaceRoot))];
260+ const repositoryIdentityByWorkspaceRoot = new Map(
261+ yield* Effect.forEach(
262+ uniqueWorkspaceRoots,
263+ (workspaceRoot) =>
264+ repositoryIdentityResolver
265+ .resolve(workspaceRoot)
266+ .pipe(Effect.map((identity) => [workspaceRoot, identity] as const)),
267+ { concurrency: repositoryIdentityResolutionConcurrency },
268+ ),
269+ );
270+
271+ return new Map(
272+ filteredProjectRows.map((row) => [
273+ row.projectId,
274+ repositoryIdentityByWorkspaceRoot.get(row.workspaceRoot) ?? null,
275+ ]),
276+ );
277+ });
233278
234279 const listProjectRows = SqlSchema.findAll({
235280 Request: Schema.Void,
@@ -335,7 +380,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 99 unmodified lines
335380 FROM projection_thread_activities
336381 ORDER BY
337382 thread_id ASC,
338− CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC,
339383 sequence ASC,
340384 created_at ASC,
341385 activity_id ASC
@@ -389,18 +433,46 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 47 unmodified lines
389433 execute: () =>
390434 sql`
391435 SELECT
392− thread_id AS "threadId",
393− turn_id AS "turnId",
394− state,
395− requested_at AS "requestedAt",
396− started_at AS "startedAt",
397− completed_at AS "completedAt",
398− assistant_message_id AS "assistantMessageId",
399− source_proposed_plan_thread_id AS "sourceProposedPlanThreadId",
400− source_proposed_plan_id AS "sourceProposedPlanId"
401− FROM projection_turns
402− WHERE turn_id IS NOT NULL
403− ORDER BY thread_id ASC, requested_at DESC, turn_id DESC
436+ turns.thread_id AS "threadId",
437+ turns.turn_id AS "turnId",
438+ turns.state,
439+ turns.requested_at AS "requestedAt",
440+ turns.started_at AS "startedAt",
441+ turns.completed_at AS "completedAt",
442+ turns.assistant_message_id AS "assistantMessageId",
443+ turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId",
444+ turns.source_proposed_plan_id AS "sourceProposedPlanId"
445+ FROM projection_threads threads
446+ JOIN projection_turns turns
447+ ON turns.thread_id = threads.thread_id
448+ AND turns.turn_id = threads.latest_turn_id
449+ WHERE threads.latest_turn_id IS NOT NULL
450+ ORDER BY turns.thread_id ASC
451+ `,
452+ });
453+
454+ const listActiveLatestTurnRows = SqlSchema.findAll({
455+ Request: Schema.Void,
456+ Result: ProjectionLatestTurnDbRowSchema,
457+ execute: () =>
458+ sql`
459+ SELECT
460+ turns.thread_id AS "threadId",
461+ turns.turn_id AS "turnId",
462+ turns.state,
463+ turns.requested_at AS "requestedAt",
464+ turns.started_at AS "startedAt",
465+ turns.completed_at AS "completedAt",
466+ turns.assistant_message_id AS "assistantMessageId",
467+ turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId",
468+ turns.source_proposed_plan_id AS "sourceProposedPlanId"
469+ FROM projection_threads threads
470+ JOIN projection_turns turns
471+ ON turns.thread_id = threads.thread_id
472+ AND turns.turn_id = threads.latest_turn_id
473+ WHERE threads.deleted_at IS NULL
474+ AND threads.latest_turn_id IS NOT NULL
475+ ORDER BY turns.thread_id ASC
404476 `,
405477 });
406478
@@ -594,7 +666,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 187 unmodified lines
594666 FROM projection_thread_activities
595667 WHERE thread_id = ${threadId}
596668 ORDER BY
597− CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC,
598669 sequence ASC,
599670 created_at ASC,
600671 activity_id ASC
@@ -892,15 +963,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 291 unmodified lines
892963 });
893964 }
894965
895− const repositoryIdentities = new Map(
896− yield* Effect.forEach(
897− projectRows,
898− (row) =>
899− repositoryIdentityResolver
900− .resolve(row.workspaceRoot)
901− .pipe(Effect.map((identity) => [row.projectId, identity] as const)),
902− { concurrency: repositoryIdentityResolutionConcurrency },
903− ),
966+ const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(
967+ projectRows,
968+ { includeDeleted: true },
904969 );
905970
906971 const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({
@@ -958,6 +1023,199 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 51 unmodified lines
9581023 }),
9591024 );
9601025
1026+ const getCommandReadModel: ProjectionSnapshotQueryShape["getCommandReadModel"] = () =>
1027+ sql
1028+ .withTransaction(
1029+ Effect.all([
1030+ listProjectRows(undefined).pipe(
1031+ Effect.mapError(
1032+ toPersistenceSqlOrDecodeError(
1033+ "ProjectionSnapshotQuery.getCommandReadModel:listProjects:query",
1034+ "ProjectionSnapshotQuery.getCommandReadModel:listProjects:decodeRows",
1035+ ),
1036+ ),
1037+ ),
1038+ listThreadRows(undefined).pipe(
1039+ Effect.mapError(
1040+ toPersistenceSqlOrDecodeError(
1041+ "ProjectionSnapshotQuery.getCommandReadModel:listThreads:query",
1042+ "ProjectionSnapshotQuery.getCommandReadModel:listThreads:decodeRows",
1043+ ),
1044+ ),
1045+ ),
1046+ listThreadProposedPlanRows(undefined).pipe(
1047+ Effect.mapError(
1048+ toPersistenceSqlOrDecodeError(
1049+ "ProjectionSnapshotQuery.getCommandReadModel:listThreadProposedPlans:query",
1050+ "ProjectionSnapshotQuery.getCommandReadModel:listThreadProposedPlans:decodeRows",
1051+ ),
1052+ ),
1053+ ),
1054+ listThreadSessionRows(undefined).pipe(
1055+ Effect.mapError(
1056+ toPersistenceSqlOrDecodeError(
1057+ "ProjectionSnapshotQuery.getCommandReadModel:listThreadSessions:query",
1058+ "ProjectionSnapshotQuery.getCommandReadModel:listThreadSessions:decodeRows",
1059+ ),
1060+ ),
1061+ ),
1062+ listLatestTurnRows(undefined).pipe(
1063+ Effect.mapError(
1064+ toPersistenceSqlOrDecodeError(
1065+ "ProjectionSnapshotQuery.getCommandReadModel:listLatestTurns:query",
1066+ "ProjectionSnapshotQuery.getCommandReadModel:listLatestTurns:decodeRows",
1067+ ),
1068+ ),
1069+ ),
1070+ listProjectionStateRows(undefined).pipe(
1071+ Effect.mapError(
1072+ toPersistenceSqlOrDecodeError(
1073+ "ProjectionSnapshotQuery.getCommandReadModel:listProjectionState:query",
1074+ "ProjectionSnapshotQuery.getCommandReadModel:listProjectionState:decodeRows",
1075+ ),
1076+ ),
1077+ ),
1078+ ]),
1079+ )
1080+ .pipe(
1081+ Effect.flatMap(
1082+ ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) =>
1083+ Effect.sync(() => {
1084+ let updatedAt: string | null = null;
1085+ const projects: OrchestrationProject[] = [];
1086+ const threads: OrchestrationThread[] = [];
1087+
1088+ for (let index = 0; index < projectRows.length; index += 1) {
1089+ const row = projectRows[index];
1090+ if (!row) {
1091+ continue;
1092+ }
1093+ updatedAt = maxIso(updatedAt, row.updatedAt);
1094+ projects.push({
1095+ id: row.projectId,
1096+ title: row.title,
1097+ workspaceRoot: row.workspaceRoot,
1098+ defaultModelSelection: row.defaultModelSelection,
1099+ scripts: row.scripts,
1100+ createdAt: row.createdAt,
1101+ updatedAt: row.updatedAt,
1102+ deletedAt: row.deletedAt,
1103+ });
1104+ }
1105+ for (let index = 0; index < threadRows.length; index += 1) {
1106+ const row = threadRows[index];
1107+ if (!row) {
1108+ continue;
1109+ }
1110+ updatedAt = maxIso(updatedAt, row.updatedAt);
1111+ }
1112+ for (let index = 0; index < proposedPlanRows.length; index += 1) {
1113+ const row = proposedPlanRows[index];
1114+ if (!row) {
1115+ continue;
1116+ }
1117+ updatedAt = maxIso(updatedAt, row.updatedAt);
1118+ }
1119+ for (let index = 0; index < sessionRows.length; index += 1) {
1120+ const row = sessionRows[index];
1121+ if (!row) {
1122+ continue;
1123+ }
1124+ updatedAt = maxIso(updatedAt, row.updatedAt);
1125+ }
1126+ for (let index = 0; index < latestTurnRows.length; index += 1) {
1127+ const row = latestTurnRows[index];
1128+ if (!row) {
1129+ continue;
1130+ }
1131+ updatedAt = maxIso(updatedAt, row.requestedAt);
1132+ if (row.startedAt !== null) {
1133+ updatedAt = maxIso(updatedAt, row.startedAt);
1134+ }
1135+ if (row.completedAt !== null) {
1136+ updatedAt = maxIso(updatedAt, row.completedAt);
1137+ }
1138+ }
1139+ for (let index = 0; index < stateRows.length; index += 1) {
1140+ const row = stateRows[index];
1141+ if (!row) {
1142+ continue;
1143+ }
1144+ updatedAt = maxIso(updatedAt, row.updatedAt);
1145+ }
1146+
1147+ const latestTurnByThread = new Map<string, OrchestrationLatestTurn>();
1148+ for (let index = 0; index < latestTurnRows.length; index += 1) {
1149+ const row = latestTurnRows[index];
1150+ if (!row) {
1151+ continue;
1152+ }
1153+ latestTurnByThread.set(row.threadId, mapLatestTurn(row));
1154+ }
1155+ const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();
1156+ const sessionByThread = new Map<string, OrchestrationSession>();
1157+
1158+ for (let index = 0; index < sessionRows.length; index += 1) {
1159+ const row = sessionRows[index];
1160+ if (!row) {
1161+ continue;
1162+ }
1163+ sessionByThread.set(row.threadId, mapSessionRow(row));
1164+ }
1165+
1166+ for (let index = 0; index < proposedPlanRows.length; index += 1) {
1167+ const row = proposedPlanRows[index];
1168+ if (!row) {
1169+ continue;
1170+ }
1171+ const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? [];
1172+ threadProposedPlans.push(mapProposedPlanRow(row));
1173+ proposedPlansByThread.set(row.threadId, threadProposedPlans);
1174+ }
1175+
1176+ for (let index = 0; index < threadRows.length; index += 1) {
1177+ const row = threadRows[index];
1178+ if (!row) {
1179+ continue;
1180+ }
1181+ threads.push({
1182+ id: row.threadId,
1183+ projectId: row.projectId,
1184+ title: row.title,
1185+ modelSelection: row.modelSelection,
1186+ runtimeMode: row.runtimeMode,
1187+ interactionMode: row.interactionMode,
1188+ branch: row.branch,
1189+ worktreePath: row.worktreePath,
1190+ latestTurn: latestTurnByThread.get(row.threadId) ?? null,
1191+ createdAt: row.createdAt,
1192+ updatedAt: row.updatedAt,
1193+ archivedAt: row.archivedAt,
1194+ deletedAt: row.deletedAt,
1195+ messages: [],
1196+ proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
1197+ activities: [],
1198+ checkpoints: [],
1199+ session: sessionByThread.get(row.threadId) ?? null,
1200+ });
1201+ }
1202+
1203+ return {
1204+ snapshotSequence: computeSnapshotSequence(stateRows),
1205+ projects,
1206+ threads,
1207+ updatedAt: updatedAt ?? new Date(0).toISOString(),
1208+ } satisfies OrchestrationReadModel;
1209+ }),
1210+ ),
1211+ Effect.mapError((error) => {
1212+ if (isPersistenceError(error)) {
1213+ return error;
1214+ }
1215+ return toPersistenceSqlError("ProjectionSnapshotQuery.getCommandReadModel:query")(error);
1216+ }),
1217+ );
1218+
9611219 const getShellSnapshot: ProjectionSnapshotQueryShape["getShellSnapshot"] = () =>
9621220 sql
9631221 .withTransaction(
@@ -986,7 +1244,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 22 unmodified lines
9861244 ),
9871245 ),
9881246 ),
989− listLatestTurnRows(undefined).pipe(
1247+ listActiveLatestTurnRows(undefined).pipe(
9901248 Effect.mapError(
9911249 toPersistenceSqlOrDecodeError(
9921250 "ProjectionSnapshotQuery.getShellSnapshot:listLatestTurns:query",
@@ -1030,16 +1288,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 37 unmodified lines
10301288 updatedAt = maxIso(updatedAt, row.updatedAt);
10311289 }
10321290
1033− const repositoryIdentities = new Map(
1034− yield* Effect.forEach(
1035− projectRows,
1036− (row) =>
1037− repositoryIdentityResolver
1038− .resolve(row.workspaceRoot)
1039− .pipe(Effect.map((identity) => [row.projectId, identity] as const)),
1040− { concurrency: repositoryIdentityResolutionConcurrency },
1041− ),
1042− );
1291+ const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(projectRows);
10431292 const latestTurnByThread = new Map(
10441293 latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),
10451294 );
@@ -1097,6 +1346,19 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 51 unmodified lines
10971346 }),
10981347 );
10991348
1349+ const getSnapshotSequence: ProjectionSnapshotQueryShape["getSnapshotSequence"] = () =>
1350+ listProjectionStateRows(undefined).pipe(
1351+ Effect.mapError(
1352+ toPersistenceSqlOrDecodeError(
1353+ "ProjectionSnapshotQuery.getSnapshotSequence:query",
1354+ "ProjectionSnapshotQuery.getSnapshotSequence:decodeRows",
1355+ ),
1356+ ),
1357+ Effect.map((stateRows) => ({
1358+ snapshotSequence: computeSnapshotSequence(stateRows),
1359+ })),
1360+ );
1361+
11001362 const getCounts: ProjectionSnapshotQueryShape["getCounts"] = () =>
11011363 readProjectionCounts(undefined).pipe(
11021364 Effect.mapError(
@@ -1376,15 +1638,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 273 unmodified lines
13761638 }
13771639 return message;
13781640 }),
1379− proposedPlans: proposedPlanRows.map((row) => ({
1380− id: row.planId,
1381− turnId: row.turnId,
1382− planMarkdown: row.planMarkdown,
1383− implementedAt: row.implementedAt,
1384− implementationThreadId: row.implementationThreadId,
1385− createdAt: row.createdAt,
1386− updatedAt: row.updatedAt,
1387− })),
1641+ proposedPlans: proposedPlanRows.map(mapProposedPlanRow),
13881642 activities: activityRows.map((row) => {
13891643 const activity = {
13901644 id: row.activityId,
@@ -1422,8 +1676,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 31 unmodified lines
14221676 });
14231677
14241678 return {
1679+ getCommandReadModel,
14251680 getSnapshot,
14261681 getShellSnapshot,
1682+ getSnapshotSequence,
14271683 getCounts,
14281684 getActiveProjectByWorkspaceRoot,
14291685 getProjectShellById,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+32−24
@@ -45,6 +45,7 @@ import { · 44 unmodified lines
4545 } from "./ProviderCommandReactor.ts";
4646 import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
4747 import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
48+import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
4849 import * as NodeServices from "@effect/platform-node/NodeServices";
4950 import { ServerSettingsService } from "../../serverSettings.ts";
5051 import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
@@ -79,7 +80,7 @@ async function waitFor( · 28 unmodified lines
7980
8081 describe("ProviderCommandReactor", () => {
8182 let runtime: ManagedRuntime.ManagedRuntime<
82− OrchestrationEngineService | ProviderCommandReactor,
83+ OrchestrationEngineService | ProviderCommandReactor | ProjectionSnapshotQuery,
8384 unknown
8485 > | null = null;
8586 let scope: Scope.Closeable | null = null;
@@ -318,8 +319,13 @@ describe("ProviderCommandReactor", () => { · 232 unmodified lines
318319 Layer.provide(RepositoryIdentityResolverLive),
319320 Layer.provide(SqlitePersistenceMemory),
320321 );
322+ const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
323+ Layer.provide(RepositoryIdentityResolverLive),
324+ Layer.provide(SqlitePersistenceMemory),
325+ );
321326 const layer = ProviderCommandReactorLive.pipe(
322327 Layer.provideMerge(orchestrationLayer),
328+ Layer.provideMerge(projectionSnapshotLayer),
323329 Layer.provideMerge(Layer.succeed(ProviderService, service)),
324330 Layer.provideMerge(
325331 Layer.mock(GitWorkflowService)({
@@ -348,6 +354,7 @@ describe("ProviderCommandReactor", () => { · 22 unmodified lines
348354 runtime = ManagedRuntime.make(layer);
349355
350356 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
357+ const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
351358 const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));
352359 scope = await Effect.runPromise(Scope.make("sequential"));
353360 await Effect.runPromise(reactor.start().pipe(Scope.provide(scope)));
@@ -382,6 +389,7 @@ describe("ProviderCommandReactor", () => { · 28 unmodified lines
382389
383390 return {
384391 engine,
392+ readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),
385393 startSession,
386394 sendTurn,
387395 interruptTurn,
@@ -431,7 +439,7 @@ describe("ProviderCommandReactor", () => { · 43 unmodified lines
431439 runtimeMode: "approval-required",
432440 });
433441
434− const readModel = await Effect.runPromise(harness.engine.getReadModel());
442+ const readModel = await harness.readModel();
435443 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
436444 expect(thread?.session?.threadId).toBe("thread-1");
437445 expect(thread?.session?.runtimeMode).toBe("approval-required");
@@ -476,13 +484,13 @@ describe("ProviderCommandReactor", () => { · 38 unmodified lines
476484 });
477485
478486 await waitFor(async () => {
479− const readModel = await Effect.runPromise(harness.engine.getReadModel());
487+ const readModel = await harness.readModel();
480488 return (
481489 readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title ===
482490 "Generated title"
483491 );
484492 });
485− const readModel = await Effect.runPromise(harness.engine.getReadModel());
493+ const readModel = await harness.readModel();
486494 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
487495 expect(thread?.title).toBe("Generated title");
488496 });
@@ -522,7 +530,7 @@ describe("ProviderCommandReactor", () => { · 33 unmodified lines
522530 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
523531 expect(harness.generateThreadTitle).not.toHaveBeenCalled();
524532
525− const readModel = await Effect.runPromise(harness.engine.getReadModel());
533+ const readModel = await harness.readModel();
526534 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
527535 expect(thread?.title).toBe("Keep this custom title");
528536 });
@@ -566,14 +574,14 @@ describe("ProviderCommandReactor", () => { · 37 unmodified lines
566574
567575 await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1);
568576 await waitFor(async () => {
569− const readModel = await Effect.runPromise(harness.engine.getReadModel());
577+ const readModel = await harness.readModel();
570578 return (
571579 readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title ===
572580 "Reconnect spinner resume bug"
573581 );
574582 });
575583
576− const readModel = await Effect.runPromise(harness.engine.getReadModel());
584+ const readModel = await harness.readModel();
577585 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
578586 expect(thread?.title).toBe("Reconnect spinner resume bug");
579587 });
@@ -902,7 +910,7 @@ describe("ProviderCommandReactor", () => { · 322 unmodified lines
902910 },
903911 });
904912
905− const readModel = await Effect.runPromise(harness.engine.getReadModel());
913+ const readModel = await harness.readModel();
906914 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
907915 expect(thread?.session?.providerName).toBe("claudeAgent");
908916 expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("claudeAgent"));
@@ -1014,7 +1022,7 @@ describe("ProviderCommandReactor", () => { · 105 unmodified lines
10141022 resumeCursor: { opaque: "resume-1" },
10151023 });
10161024
1017− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1025+ const readModel = await harness.readModel();
10181026 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
10191027 expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work"));
10201028 });
@@ -1205,7 +1213,7 @@ describe("ProviderCommandReactor", () => { · 184 unmodified lines
12051213 );
12061214
12071215 await waitFor(async () => {
1208− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1216+ const readModel = await harness.readModel();
12091217 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
12101218 return thread?.runtimeMode === "approval-required";
12111219 });
@@ -1239,7 +1247,7 @@ describe("ProviderCommandReactor", () => { · 27 unmodified lines
12391247 threadId: ThreadId.make("thread-1"),
12401248 });
12411249
1242− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1250+ const readModel = await harness.readModel();
12431251 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
12441252 expect(thread?.session?.threadId).toBe("thread-1");
12451253 expect(thread?.session?.runtimeMode).toBe("approval-required");
@@ -1342,7 +1350,7 @@ describe("ProviderCommandReactor", () => { · 96 unmodified lines
13421350 );
13431351
13441352 await waitFor(async () => {
1345− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1353+ const readModel = await harness.readModel();
13461354 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
13471355 return thread?.runtimeMode === "approval-required";
13481356 });
@@ -1352,7 +1360,7 @@ describe("ProviderCommandReactor", () => { · 3 unmodified lines
13521360 expect(harness.stopSession.mock.calls.length).toBe(0);
13531361 expect(harness.sendTurn.mock.calls.length).toBe(1);
13541362
1355− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1363+ const readModel = await harness.readModel();
13561364 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
13571365 expect(thread?.session?.threadId).toBe("thread-1");
13581366 expect(thread?.session?.runtimeMode).toBe("full-access");
@@ -1404,7 +1412,7 @@ describe("ProviderCommandReactor", () => { · 45 unmodified lines
14041412 );
14051413
14061414 await waitFor(async () => {
1407− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1415+ const readModel = await harness.readModel();
14081416 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
14091417 return (
14101418 thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??
@@ -1416,7 +1424,7 @@ describe("ProviderCommandReactor", () => { · 5 unmodified lines
14161424 expect(harness.sendTurn.mock.calls.length).toBe(1);
14171425 expect(harness.stopSession.mock.calls.length).toBe(0);
14181426
1419− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1427+ const readModel = await harness.readModel();
14201428 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
14211429 expect(thread?.session?.threadId).toBe("thread-1");
14221430 expect(thread?.session?.providerName).toBe("codex");
@@ -1475,7 +1483,7 @@ describe("ProviderCommandReactor", () => { · 52 unmodified lines
14751483 );
14761484
14771485 await waitFor(async () => {
1478− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1486+ const readModel = await harness.readModel();
14791487 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
14801488 return (
14811489 thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??
@@ -1485,7 +1493,7 @@ describe("ProviderCommandReactor", () => { · 3 unmodified lines
14851493
14861494 expect(harness.startSession.mock.calls.length).toBe(0);
14871495 expect(harness.sendTurn.mock.calls.length).toBe(0);
1488− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1496+ const readModel = await harness.readModel();
14891497 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
14901498 expect(
14911499 thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),
@@ -1639,7 +1647,7 @@ describe("ProviderCommandReactor", () => { · 147 unmodified lines
16391647 );
16401648
16411649 await waitFor(async () => {
1642− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1650+ const readModel = await harness.readModel();
16431651 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
16441652 return (
16451653 thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??
@@ -1649,7 +1657,7 @@ describe("ProviderCommandReactor", () => { · 3 unmodified lines
16491657
16501658 expect(harness.startSession.mock.calls.length).toBe(0);
16511659 expect(harness.sendTurn.mock.calls.length).toBe(0);
1652− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1660+ const readModel = await harness.readModel();
16531661 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
16541662 expect(
16551663 thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),
@@ -1810,7 +1818,7 @@ describe("ProviderCommandReactor", () => { · 154 unmodified lines
18101818 );
18111819
18121820 await waitFor(async () => {
1813− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1821+ const readModel = await harness.readModel();
18141822 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
18151823 if (!thread) return false;
18161824 return thread.activities.some(
@@ -1818,7 +1826,7 @@ describe("ProviderCommandReactor", () => { · 1 unmodified line
18181826 );
18191827 });
18201828
1821− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1829+ const readModel = await harness.readModel();
18221830 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
18231831 expect(thread).toBeDefined();
18241832
@@ -1919,7 +1927,7 @@ describe("ProviderCommandReactor", () => { · 94 unmodified lines
19191927 );
19201928
19211929 await waitFor(async () => {
1922− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1930+ const readModel = await harness.readModel();
19231931 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
19241932 if (!thread) return false;
19251933 return thread.activities.some(
@@ -1927,7 +1935,7 @@ describe("ProviderCommandReactor", () => { · 1 unmodified line
19271935 );
19281936 });
19291937
1930− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1938+ const readModel = await harness.readModel();
19311939 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
19321940 expect(thread).toBeDefined();
19331941
@@ -1983,7 +1991,7 @@ describe("ProviderCommandReactor", () => { · 49 unmodified lines
19831991 );
19841992
19851993 await waitFor(() => harness.stopSession.mock.calls.length === 1);
1986− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1994+ const readModel = await harness.readModel();
19871995 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
19881996 expect(thread?.session).not.toBeNull();
19891997 expect(thread?.session?.status).toBe("stopped");
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+27−20
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
55 type ModelSelection,
66 type OrchestrationEvent,
77 ProviderDriverKind,
8+ type ProjectId,
89 type OrchestrationSession,
910 ThreadId,
1011 type ProviderSession,
@@ -22,6 +23,7 @@ import type { ProviderServiceError } from "../../provider/Errors.ts"; · 11 unmodified lines
2223 import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
2324 import { ProviderService } from "../../provider/Services/ProviderService.ts";
2425 import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
26+import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
2527 import {
2628 ProviderCommandReactor,
2729 type ProviderCommandReactorShape,
@@ -167,6 +169,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string { · 139 unmodified lines
167169
168170 const make = Effect.gen(function* () {
169171 const orchestrationEngine = yield* OrchestrationEngineService;
172+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
170173 const providerService = yield* ProviderService;
171174 const gitWorkflow = yield* GitWorkflowService;
172175 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
@@ -267,9 +270,16 @@ const make = Effect.gen(function* () { · 94 unmodified lines
267270 });
268271 });
269272
273+ const resolveProject = Effect.fnUntraced(function* (projectId: ProjectId) {
274+ return yield* projectionSnapshotQuery
275+ .getProjectShellById(projectId)
276+ .pipe(Effect.map(Option.getOrUndefined));
277+ });
278+
270279 const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) {
271− const readModel = yield* orchestrationEngine.getReadModel();
272− return readModel.threads.find((entry) => entry.id === threadId);
280+ return yield* projectionSnapshotQuery
281+ .getThreadDetailById(threadId)
282+ .pipe(Effect.map(Option.getOrUndefined));
273283 });
274284
275285 const ensureSessionForThread = Effect.fn("ensureSessionForThread")(function* (
@@ -279,8 +289,7 @@ const make = Effect.gen(function* () { · 3 unmodified lines
279289 readonly modelSelection?: ModelSelection;
280290 },
281291 ) {
282− const readModel = yield* orchestrationEngine.getReadModel();
283− const thread = readModel.threads.find((entry) => entry.id === threadId);
292+ const thread = yield* resolveThread(threadId);
284293 if (!thread) {
285294 return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`));
286295 }
@@ -375,9 +384,10 @@ const make = Effect.gen(function* () { · 88 unmodified lines
375384 });
376385 }
377386 }
387+ const project = yield* resolveProject(thread.projectId);
378388 const effectiveCwd = resolveThreadWorkspaceCwd({
379389 thread,
380− projects: readModel.projects,
390+ projects: project ? [project] : [],
381391 });
382392
383393 const startProviderSession = (input?: {
@@ -682,10 +692,11 @@ const make = Effect.gen(function* () { · 298 unmodified lines
682692 const isFirstUserMessageTurn =
683693 thread.messages.filter((entry) => entry.role === "user").length === 1;
684694 if (isFirstUserMessageTurn) {
695+ const project = yield* resolveProject(thread.projectId);
685696 const generationCwd =
686697 resolveThreadWorkspaceCwd({
687698 thread,
688− projects: (yield* orchestrationEngine.getReadModel()).projects,
699+ projects: project ? [project] : [],
689700 }) ?? process.cwd();
690701 const generationInput = {
691702 messageText: message.text,
@@ -819,20 +830,16 @@ const make = Effect.gen(function* () { · 127 unmodified lines
819830 })
820831 .pipe(
821832 Effect.catchCause((cause) =>
822− Effect.gen(function* () {
823− yield* appendProviderFailureActivity({
824− threadId: event.payload.threadId,
825− kind: "provider.approval.respond.failed",
826− summary: "Provider approval response failed",
827− detail: isUnknownPendingApprovalRequestError(cause)
828− ? stalePendingRequestDetail("approval", event.payload.requestId)
829− : Cause.pretty(cause),
830− turnId: null,
831− createdAt: event.payload.createdAt,
832− requestId: event.payload.requestId,
833− });
834−
835− if (!isUnknownPendingApprovalRequestError(cause)) return;
833+ appendProviderFailureActivity({
834+ threadId: event.payload.threadId,
835+ kind: "provider.approval.respond.failed",
836+ summary: "Provider approval response failed",
837+ detail: isUnknownPendingApprovalRequestError(cause)
838+ ? stalePendingRequestDetail("approval", event.payload.requestId)
839+ : Cause.pretty(cause),
840+ turnId: null,
841+ createdAt: event.payload.createdAt,
842+ requestId: event.payload.requestId,
836843 }),
837844 ),
838845 );
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+89−84
@@ -36,11 +36,9 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; · 35 unmodified lines
3636 import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
3737 import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
3838 import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";
39−import {
40− OrchestrationEngineService,
41− type OrchestrationEngineShape,
42−} from "../Services/OrchestrationEngine.ts";
39+import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
4340 import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
41+import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
4442 import { ServerConfig } from "../../config.ts";
4543 import { ServerSettingsService } from "../../serverSettings.ts";
4644 import * as NodeServices from "@effect/platform-node/NodeServices";
@@ -154,16 +152,23 @@ function createProviderServiceHarness() { · 107 unmodified lines
154152 };
155153 }
156154
155+type ProviderRuntimeTestReadModel = OrchestrationReadModel;
156+type ProviderRuntimeTestThread = ProviderRuntimeTestReadModel["threads"][number];
157+type ProviderRuntimeTestMessage = ProviderRuntimeTestThread["messages"][number];
158+type ProviderRuntimeTestProposedPlan = ProviderRuntimeTestThread["proposedPlans"][number];
159+type ProviderRuntimeTestActivity = ProviderRuntimeTestThread["activities"][number];
160+type ProviderRuntimeTestCheckpoint = ProviderRuntimeTestThread["checkpoints"][number];
161+
157162 async function waitForThread(
158− engine: OrchestrationEngineShape,
163+ readModel: () => Promise<ProviderRuntimeTestReadModel>,
159164 predicate: (thread: ProviderRuntimeTestThread) => boolean,
160165 timeoutMs = 2000,
161166 threadId: ThreadId = asThreadId("thread-1"),
162167 ) {
163168 const deadline = Date.now() + timeoutMs;
164169 const poll = async (): Promise<ProviderRuntimeTestThread> => {
165− const readModel = await Effect.runPromise(engine.getReadModel());
166− const thread = readModel.threads.find((entry) => entry.id === threadId);
170+ const snapshot = await readModel();
171+ const thread = snapshot.threads.find((entry) => entry.id === threadId);
167172 if (thread && predicate(thread)) {
168173 return thread;
169174 }
@@ -176,16 +181,9 @@ async function waitForThread( · 6 unmodified lines
176181 return poll();
177182 }
178183
179−type ProviderRuntimeTestReadModel = OrchestrationReadModel;
180−type ProviderRuntimeTestThread = ProviderRuntimeTestReadModel["threads"][number];
181−type ProviderRuntimeTestMessage = ProviderRuntimeTestThread["messages"][number];
182−type ProviderRuntimeTestProposedPlan = ProviderRuntimeTestThread["proposedPlans"][number];
183−type ProviderRuntimeTestActivity = ProviderRuntimeTestThread["activities"][number];
184−type ProviderRuntimeTestCheckpoint = ProviderRuntimeTestThread["checkpoints"][number];
185−
186184 describe("ProviderRuntimeIngestion", () => {
187185 let runtime: ManagedRuntime.ManagedRuntime<
188− OrchestrationEngineService | ProviderRuntimeIngestionService,
186+ OrchestrationEngineService | ProviderRuntimeIngestionService | ProjectionSnapshotQuery,
189187 unknown
190188 > | null = null;
191189 let scope: Scope.Closeable | null = null;
@@ -223,8 +221,13 @@ describe("ProviderRuntimeIngestion", () => { · 31 unmodified lines
223221 Layer.provide(RepositoryIdentityResolverLive),
224222 Layer.provide(SqlitePersistenceMemory),
225223 );
224+ const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
225+ Layer.provide(RepositoryIdentityResolverLive),
226+ Layer.provide(SqlitePersistenceMemory),
227+ );
226228 const layer = ProviderRuntimeIngestionLive.pipe(
227229 Layer.provideMerge(orchestrationLayer),
230+ Layer.provideMerge(projectionSnapshotLayer),
228231 Layer.provideMerge(SqlitePersistenceMemory),
229232 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
230233 Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),
@@ -233,6 +236,7 @@ describe("ProviderRuntimeIngestion", () => { · 2 unmodified lines
233236 );
234237 runtime = ManagedRuntime.make(layer);
235238 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
239+ const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
236240 const ingestion = await runtime.runPromise(Effect.service(ProviderRuntimeIngestionService));
237241 scope = await Effect.runPromise(Scope.make("sequential"));
238242 await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope)));
@@ -299,6 +303,7 @@ describe("ProviderRuntimeIngestion", () => { · 60 unmodified lines
299303
300304 return {
301305 engine,
306+ readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),
302307 emit: provider.emit,
303308 setProviderSession: provider.setSession,
304309 drain,
@@ -319,7 +324,7 @@ describe("ProviderRuntimeIngestion", () => { · 14 unmodified lines
319324 });
320325
321326 await waitForThread(
322− harness.engine,
327+ harness.readModel,
323328 (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === "turn-1",
324329 );
325330
@@ -337,7 +342,7 @@ describe("ProviderRuntimeIngestion", () => { · 11 unmodified lines
337342 });
338343
339344 const thread = await waitForThread(
340− harness.engine,
345+ harness.readModel,
341346 (entry) =>
342347 entry.session?.status === "error" &&
343348 entry.session?.activeTurnId === null &&
@@ -364,7 +369,7 @@ describe("ProviderRuntimeIngestion", () => { · 20 unmodified lines
364369 });
365370
366371 let thread = await waitForThread(
367− harness.engine,
372+ harness.readModel,
368373 (entry) => entry.session?.status === "running" && entry.session?.activeTurnId === null,
369374 );
370375 expect(thread.session?.status).toBe("running");
@@ -383,7 +388,7 @@ describe("ProviderRuntimeIngestion", () => { · 12 unmodified lines
383388 });
384389
385390 thread = await waitForThread(
386− harness.engine,
391+ harness.readModel,
387392 (entry) =>
388393 entry.session?.status === "error" &&
389394 entry.session?.activeTurnId === null &&
@@ -404,7 +409,7 @@ describe("ProviderRuntimeIngestion", () => { · 14 unmodified lines
404409 });
405410
406411 thread = await waitForThread(
407− harness.engine,
412+ harness.readModel,
408413 (entry) =>
409414 entry.session?.status === "stopped" &&
410415 entry.session?.activeTurnId === null &&
@@ -425,7 +430,7 @@ describe("ProviderRuntimeIngestion", () => { · 14 unmodified lines
425430 });
426431
427432 thread = await waitForThread(
428− harness.engine,
433+ harness.readModel,
429434 (entry) =>
430435 entry.session?.status === "ready" &&
431436 entry.session?.activeTurnId === null &&
@@ -449,7 +454,7 @@ describe("ProviderRuntimeIngestion", () => { · 17 unmodified lines
449454 });
450455
451456 await waitForThread(
452− harness.engine,
457+ harness.readModel,
453458 (thread) =>
454459 thread.session?.status === "running" &&
455460 thread.session?.activeTurnId === "turn-midturn-lifecycle",
@@ -471,7 +476,7 @@ describe("ProviderRuntimeIngestion", () => { · 15 unmodified lines
471476 });
472477
473478 await harness.drain();
474− const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
479+ const midReadModel = await harness.readModel();
475480 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
476481 expect(midThread?.session?.status).toBe("running");
477482 expect(midThread?.session?.activeTurnId).toBe("turn-midturn-lifecycle");
@@ -487,7 +492,7 @@ describe("ProviderRuntimeIngestion", () => { · 9 unmodified lines
487492 });
488493
489494 await waitForThread(
490− harness.engine,
495+ harness.readModel,
491496 (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null,
492497 );
493498 });
@@ -524,7 +529,7 @@ describe("ProviderRuntimeIngestion", () => { · 30 unmodified lines
524529 });
525530
526531 await waitForThread(
527− harness.engine,
532+ harness.readModel,
528533 (thread) =>
529534 thread.session?.status === "running" &&
530535 thread.session?.activeTurnId === "turn-claude-placeholder",
@@ -541,7 +546,7 @@ describe("ProviderRuntimeIngestion", () => { · 10 unmodified lines
541546 });
542547
543548 await waitForThread(
544− harness.engine,
549+ harness.readModel,
545550 (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null,
546551 );
547552 });
@@ -560,7 +565,7 @@ describe("ProviderRuntimeIngestion", () => { · 12 unmodified lines
560565 });
561566
562567 await waitForThread(
563− harness.engine,
568+ harness.readModel,
564569 (thread) =>
565570 thread.session?.status === "running" && thread.session?.activeTurnId === "turn-primary",
566571 );
@@ -576,7 +581,7 @@ describe("ProviderRuntimeIngestion", () => { · 9 unmodified lines
576581 });
577582
578583 await harness.drain();
579− const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
584+ const midReadModel = await harness.readModel();
580585 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
581586 expect(midThread?.session?.status).toBe("running");
582587 expect(midThread?.session?.activeTurnId).toBe("turn-primary");
@@ -592,7 +597,7 @@ describe("ProviderRuntimeIngestion", () => { · 9 unmodified lines
592597 });
593598
594599 await waitForThread(
595− harness.engine,
600+ harness.readModel,
596601 (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null,
597602 );
598603 });
@@ -611,7 +616,7 @@ describe("ProviderRuntimeIngestion", () => { · 12 unmodified lines
611616 });
612617
613618 await waitForThread(
614− harness.engine,
619+ harness.readModel,
615620 (thread) =>
616621 thread.session?.status === "running" &&
617622 thread.session?.activeTurnId === "turn-guarded-main",
@@ -628,7 +633,7 @@ describe("ProviderRuntimeIngestion", () => { · 10 unmodified lines
628633 });
629634
630635 await harness.drain();
631− const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
636+ const midReadModel = await harness.readModel();
632637 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
633638 expect(midThread?.session?.status).toBe("running");
634639 expect(midThread?.session?.activeTurnId).toBe("turn-guarded-main");
@@ -644,7 +649,7 @@ describe("ProviderRuntimeIngestion", () => { · 9 unmodified lines
644649 });
645650
646651 await waitForThread(
647− harness.engine,
652+ harness.readModel,
648653 (thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null,
649654 );
650655 });
@@ -693,7 +698,7 @@ describe("ProviderRuntimeIngestion", () => { · 42 unmodified lines
693698 },
694699 });
695700
696− const thread = await waitForThread(harness.engine, (entry) =>
701+ const thread = await waitForThread(harness.readModel, (entry) =>
697702 entry.messages.some(
698703 (message: ProviderRuntimeTestMessage) =>
699704 message.id === "assistant:item-1" && !message.streaming,
@@ -725,7 +730,7 @@ describe("ProviderRuntimeIngestion", () => { · 25 unmodified lines
725730 },
726731 });
727732
728− const thread = await waitForThread(harness.engine, (entry) =>
733+ const thread = await waitForThread(harness.readModel, (entry) =>
729734 entry.messages.some(
730735 (message: ProviderRuntimeTestMessage) =>
731736 message.id === "assistant:item-no-delta" && !message.streaming,
@@ -764,7 +769,7 @@ describe("ProviderRuntimeIngestion", () => { · 32 unmodified lines
764769 },
765770 });
766771
767− const thread = await waitForThread(harness.engine, (entry) =>
772+ const thread = await waitForThread(harness.readModel, (entry) =>
768773 entry.activities.some(
769774 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-tool-completed-with-data",
770775 ),
@@ -819,7 +824,7 @@ describe("ProviderRuntimeIngestion", () => { · 48 unmodified lines
819824 },
820825 });
821826
822− const thread = await waitForThread(harness.engine, (entry) =>
827+ const thread = await waitForThread(harness.readModel, (entry) =>
823828 entry.activities.some(
824829 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-command-completed",
825830 ),
@@ -861,7 +866,7 @@ describe("ProviderRuntimeIngestion", () => { · 35 unmodified lines
861866 },
862867 });
863868
864− const thread = await waitForThread(harness.engine, (entry) =>
869+ const thread = await waitForThread(harness.readModel, (entry) =>
865870 entry.activities.some(
866871 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-read-path-completed",
867872 ),
@@ -894,7 +899,7 @@ describe("ProviderRuntimeIngestion", () => { · 26 unmodified lines
894899 },
895900 });
896901
897− const thread = await waitForThread(harness.engine, (entry) =>
902+ const thread = await waitForThread(harness.readModel, (entry) =>
898903 entry.proposedPlans.some(
899904 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
900905 proposedPlan.id === "plan:thread-1:turn:turn-plan-final",
@@ -1009,7 +1014,7 @@ describe("ProviderRuntimeIngestion", () => { · 108 unmodified lines
10091014 });
10101015
10111016 const sourceThreadWithPlan = await waitForThread(
1012− harness.engine,
1017+ harness.readModel,
10131018 (thread) =>
10141019 thread.proposedPlans.some(
10151020 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
@@ -1050,7 +1055,7 @@ describe("ProviderRuntimeIngestion", () => { · 34 unmodified lines
10501055 );
10511056
10521057 const sourceThreadBeforeStart = await waitForThread(
1053− harness.engine,
1058+ harness.readModel,
10541059 (thread) =>
10551060 thread.proposedPlans.some(
10561061 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
@@ -1076,7 +1081,7 @@ describe("ProviderRuntimeIngestion", () => { · 19 unmodified lines
10761081 });
10771082
10781083 const sourceThreadAfterStart = await waitForThread(
1079− harness.engine,
1084+ harness.readModel,
10801085 (thread) =>
10811086 thread.proposedPlans.some(
10821087 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
@@ -1158,7 +1163,7 @@ describe("ProviderRuntimeIngestion", () => { · 75 unmodified lines
11581163 });
11591164
11601165 await waitForThread(
1161− harness.engine,
1166+ harness.readModel,
11621167 (thread) =>
11631168 thread.session?.status === "running" && thread.session?.activeTurnId === activeTurnId,
11641169 2_000,
@@ -1178,7 +1183,7 @@ describe("ProviderRuntimeIngestion", () => { · 13 unmodified lines
11781183 });
11791184
11801185 const sourceThreadWithPlan = await waitForThread(
1181− harness.engine,
1186+ harness.readModel,
11821187 (thread) =>
11831188 thread.proposedPlans.some(
11841189 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
@@ -1229,7 +1234,7 @@ describe("ProviderRuntimeIngestion", () => { · 44 unmodified lines
12291234
12301235 await harness.drain();
12311236
1232− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1237+ const readModel = await harness.readModel();
12331238 const sourceThreadAfterRejectedStart = readModel.threads.find(
12341239 (entry) => entry.id === sourceThreadId,
12351240 );
@@ -1340,7 +1345,7 @@ describe("ProviderRuntimeIngestion", () => { · 104 unmodified lines
13401345 });
13411346
13421347 const sourceThreadWithPlan = await waitForThread(
1343− harness.engine,
1348+ harness.readModel,
13441349 (thread) =>
13451350 thread.proposedPlans.some(
13461351 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
@@ -1401,7 +1406,7 @@ describe("ProviderRuntimeIngestion", () => { · 54 unmodified lines
14011406
14021407 await harness.drain();
14031408
1404− const readModel = await Effect.runPromise(harness.engine.getReadModel());
1409+ const readModel = await harness.readModel();
14051410 const sourceThreadAfterUnrelatedStart = readModel.threads.find(
14061411 (entry) => entry.id === sourceThreadId,
14071412 );
@@ -1427,7 +1432,7 @@ describe("ProviderRuntimeIngestion", () => { · 19 unmodified lines
14271432 });
14281433
14291434 await waitForThread(
1430− harness.engine,
1435+ harness.readModel,
14311436 (thread) =>
14321437 thread.session?.status === "running" && thread.session?.activeTurnId === "turn-plan-buffer",
14331438 );
@@ -1466,7 +1471,7 @@ describe("ProviderRuntimeIngestion", () => { · 32 unmodified lines
14661471 },
14671472 });
14681473
1469− const thread = await waitForThread(harness.engine, (entry) =>
1474+ const thread = await waitForThread(harness.readModel, (entry) =>
14701475 entry.proposedPlans.some(
14711476 (proposedPlan: ProviderRuntimeTestProposedPlan) =>
14721477 proposedPlan.id === "plan:thread-1:turn:turn-plan-buffer",
@@ -1492,7 +1497,7 @@ describe("ProviderRuntimeIngestion", () => { · 19 unmodified lines
14921497 turnId: asTurnId("turn-buffered"),
14931498 });
14941499 await waitForThread(
1495− harness.engine,
1500+ harness.readModel,
14961501 (thread) =>
14971502 thread.session?.status === "running" && thread.session?.activeTurnId === "turn-buffered",
14981503 );
@@ -1512,7 +1517,7 @@ describe("ProviderRuntimeIngestion", () => { · 13 unmodified lines
15121517 });
15131518
15141519 await harness.drain();
1515− const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
1520+ const midReadModel = await harness.readModel();
15161521 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
15171522 expect(
15181523 midThread?.messages.some(
@@ -1534,7 +1539,7 @@ describe("ProviderRuntimeIngestion", () => { · 15 unmodified lines
15341539 },
15351540 });
15361541
1537− const thread = await waitForThread(harness.engine, (entry) =>
1542+ const thread = await waitForThread(harness.readModel, (entry) =>
15381543 entry.messages.some(
15391544 (message: ProviderRuntimeTestMessage) =>
15401545 message.id === "assistant:item-buffered" && !message.streaming,
@@ -1560,7 +1565,7 @@ describe("ProviderRuntimeIngestion", () => { · 19 unmodified lines
15601565 turnId: asTurnId("turn-buffered-request-flush"),
15611566 });
15621567 await waitForThread(
1563− harness.engine,
1568+ harness.readModel,
15641569 (thread) =>
15651570 thread.session?.status === "running" &&
15661571 thread.session?.activeTurnId === "turn-buffered-request-flush",
@@ -1593,7 +1598,7 @@ describe("ProviderRuntimeIngestion", () => { · 26 unmodified lines
15931598 },
15941599 });
15951600
1596− const thread = await waitForThread(harness.engine, (entry) =>
1601+ const thread = await waitForThread(harness.readModel, (entry) =>
15971602 entry.messages.some(
15981603 (message: ProviderRuntimeTestMessage) =>
15991604 message.id === "assistant:item-buffered-request-flush" &&
@@ -1620,7 +1625,7 @@ describe("ProviderRuntimeIngestion", () => { · 20 unmodified lines
16201625 turnId: asTurnId("turn-buffered-user-input-flush"),
16211626 });
16221627 await waitForThread(
1623− harness.engine,
1628+ harness.readModel,
16241629 (thread) =>
16251630 thread.session?.status === "running" &&
16261631 thread.session?.activeTurnId === "turn-buffered-user-input-flush",
@@ -1659,7 +1664,7 @@ describe("ProviderRuntimeIngestion", () => { · 32 unmodified lines
16591664 },
16601665 });
16611666
1662− const thread = await waitForThread(harness.engine, (entry) =>
1667+ const thread = await waitForThread(harness.readModel, (entry) =>
16631668 entry.messages.some(
16641669 (message: ProviderRuntimeTestMessage) =>
16651670 message.id === "assistant:item-buffered-user-input-flush" &&
@@ -1688,7 +1693,7 @@ describe("ProviderRuntimeIngestion", () => { · 22 unmodified lines
16881693 turnId: asTurnId("turn-buffered-whitespace-request"),
16891694 });
16901695 await waitForThread(
1691− harness.engine,
1696+ harness.readModel,
16921697 (thread) =>
16931698 thread.session?.status === "running" &&
16941699 thread.session?.activeTurnId === "turn-buffered-whitespace-request",
@@ -1721,7 +1726,7 @@ describe("ProviderRuntimeIngestion", () => { · 26 unmodified lines
17211726 },
17221727 });
17231728
1724− const thread = await waitForThread(harness.engine, (entry) =>
1729+ const thread = await waitForThread(harness.readModel, (entry) =>
17251730 entry.activities.some(
17261731 (activity: ProviderRuntimeTestActivity) => activity.kind === "approval.requested",
17271732 ),
@@ -1750,7 +1755,7 @@ describe("ProviderRuntimeIngestion", () => { · 22 unmodified lines
17501755 turnId: asTurnId("turn-buffered-request-append"),
17511756 });
17521757 await waitForThread(
1753− harness.engine,
1758+ harness.readModel,
17541759 (thread) =>
17551760 thread.session?.status === "running" &&
17561761 thread.session?.activeTurnId === "turn-buffered-request-append",
@@ -1783,7 +1788,7 @@ describe("ProviderRuntimeIngestion", () => { · 26 unmodified lines
17831788 },
17841789 });
17851790
1786− await waitForThread(harness.engine, (entry) =>
1791+ await waitForThread(harness.readModel, (entry) =>
17871792 entry.messages.some(
17881793 (message: ProviderRuntimeTestMessage) =>
17891794 message.id === "assistant:item-buffered-request-append" &&
@@ -1819,7 +1824,7 @@ describe("ProviderRuntimeIngestion", () => { · 29 unmodified lines
18191824 },
18201825 });
18211826
1822− const thread = await waitForThread(harness.engine, (entry) =>
1827+ const thread = await waitForThread(harness.readModel, (entry) =>
18231828 entry.messages.some(
18241829 (message: ProviderRuntimeTestMessage) =>
18251830 message.id === "assistant:item-buffered-request-append:segment:1" &&
@@ -1882,7 +1887,7 @@ describe("ProviderRuntimeIngestion", () => { · 56 unmodified lines
18821887 turnId: asTurnId("turn-streaming-request-segment"),
18831888 });
18841889 await waitForThread(
1885− harness.engine,
1890+ harness.readModel,
18861891 (thread) =>
18871892 thread.session?.status === "running" &&
18881893 thread.session?.activeTurnId === "turn-streaming-request-segment",
@@ -1915,7 +1920,7 @@ describe("ProviderRuntimeIngestion", () => { · 26 unmodified lines
19151920 },
19161921 });
19171922
1918− await waitForThread(harness.engine, (entry) =>
1923+ await waitForThread(harness.readModel, (entry) =>
19191924 entry.messages.some(
19201925 (message: ProviderRuntimeTestMessage) =>
19211926 message.id === "assistant:item-streaming-request-segment" &&
@@ -1951,7 +1956,7 @@ describe("ProviderRuntimeIngestion", () => { · 29 unmodified lines
19511956 },
19521957 });
19531958
1954− const thread = await waitForThread(harness.engine, (entry) =>
1959+ const thread = await waitForThread(harness.readModel, (entry) =>
19551960 entry.messages.some(
19561961 (message: ProviderRuntimeTestMessage) =>
19571962 message.id === "assistant:item-streaming-request-segment:segment:1" &&
@@ -2004,7 +2009,7 @@ describe("ProviderRuntimeIngestion", () => { · 46 unmodified lines
20042009 turnId: asTurnId("turn-streaming-mode"),
20052010 });
20062011 await waitForThread(
2007− harness.engine,
2012+ harness.readModel,
20082013 (thread) =>
20092014 thread.session?.status === "running" &&
20102015 thread.session?.activeTurnId === "turn-streaming-mode",
@@ -2024,7 +2029,7 @@ describe("ProviderRuntimeIngestion", () => { · 13 unmodified lines
20242029 },
20252030 });
20262031
2027− const liveThread = await waitForThread(harness.engine, (entry) =>
2032+ const liveThread = await waitForThread(harness.readModel, (entry) =>
20282033 entry.messages.some(
20292034 (message: ProviderRuntimeTestMessage) =>
20302035 message.id === "assistant:item-streaming-mode" &&
@@ -2052,7 +2057,7 @@ describe("ProviderRuntimeIngestion", () => { · 21 unmodified lines
20522057 },
20532058 });
20542059
2055− const finalThread = await waitForThread(harness.engine, (entry) =>
2060+ const finalThread = await waitForThread(harness.readModel, (entry) =>
20562061 entry.messages.some(
20572062 (message: ProviderRuntimeTestMessage) =>
20582063 message.id === "assistant:item-streaming-mode" && !message.streaming,
@@ -2079,7 +2084,7 @@ describe("ProviderRuntimeIngestion", () => { · 20 unmodified lines
20792084 turnId: asTurnId("turn-buffer-spill"),
20802085 });
20812086 await waitForThread(
2082− harness.engine,
2087+ harness.readModel,
20832088 (thread) =>
20842089 thread.session?.status === "running" &&
20852090 thread.session?.activeTurnId === "turn-buffer-spill",
@@ -2112,7 +2117,7 @@ describe("ProviderRuntimeIngestion", () => { · 26 unmodified lines
21122117 },
21132118 });
21142119
2115− const thread = await waitForThread(harness.engine, (entry) =>
2120+ const thread = await waitForThread(harness.readModel, (entry) =>
21162121 entry.messages.some(
21172122 (message: ProviderRuntimeTestMessage) =>
21182123 message.id === "assistant:item-buffer-spill" && !message.streaming,
@@ -2140,7 +2145,7 @@ describe("ProviderRuntimeIngestion", () => { · 21 unmodified lines
21402145 });
21412146
21422147 await waitForThread(
2143− harness.engine,
2148+ harness.readModel,
21442149 (thread) =>
21452150 thread.session?.status === "running" &&
21462151 thread.session?.activeTurnId === "turn-complete-dedup",
@@ -2185,7 +2190,7 @@ describe("ProviderRuntimeIngestion", () => { · 38 unmodified lines
21852190 });
21862191
21872192 await waitForThread(
2188− harness.engine,
2193+ harness.readModel,
21892194 (thread) =>
21902195 thread.session?.status === "ready" &&
21912196 thread.session?.activeTurnId === null &&
@@ -2243,7 +2248,7 @@ describe("ProviderRuntimeIngestion", () => { · 51 unmodified lines
22432248 });
22442249
22452250 await waitForThread(
2246− harness.engine,
2251+ harness.readModel,
22472252 (entry) =>
22482253 entry.activities.some(
22492254 (activity: ProviderRuntimeTestActivity) => activity.kind === "approval.requested",
@@ -2253,7 +2258,7 @@ describe("ProviderRuntimeIngestion", () => { · 3 unmodified lines
22532258 ),
22542259 );
22552260
2256− const readModel = await Effect.runPromise(harness.engine.getReadModel());
2261+ const readModel = await harness.readModel();
22572262 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
22582263 expect(thread).toBeDefined();
22592264
@@ -2295,7 +2300,7 @@ describe("ProviderRuntimeIngestion", () => { · 35 unmodified lines
22952300 });
22962301
22972302 const thread = await waitForThread(
2298− harness.engine,
2303+ harness.readModel,
22992304 (entry) =>
23002305 entry.session?.status === "error" &&
23012306 entry.session?.activeTurnId === "turn-3" &&
@@ -2321,7 +2326,7 @@ describe("ProviderRuntimeIngestion", () => { · 19 unmodified lines
23212326 },
23222327 });
23232328
2324− const thread = await waitForThread(harness.engine, (entry) =>
2329+ const thread = await waitForThread(harness.readModel, (entry) =>
23252330 entry.activities.some((activity) => activity.id === "evt-runtime-error-activity"),
23262331 );
23272332 const activity = thread.activities.find(
@@ -2366,7 +2371,7 @@ describe("ProviderRuntimeIngestion", () => { · 38 unmodified lines
23662371 });
23672372
23682373 const thread = await waitForThread(
2369− harness.engine,
2374+ harness.readModel,
23702375 (entry) =>
23712376 entry.session?.status === "running" &&
23722377 entry.session?.activeTurnId === "turn-warning" &&
@@ -2415,7 +2420,7 @@ describe("ProviderRuntimeIngestion", () => { · 42 unmodified lines
24152420 });
24162421
24172422 const thread = await waitForThread(
2418− harness.engine,
2423+ harness.readModel,
24192424 (entry) =>
24202425 entry.session?.status === "ready" &&
24212426 entry.session?.activeTurnId === null &&
@@ -2508,7 +2513,7 @@ describe("ProviderRuntimeIngestion", () => { · 86 unmodified lines
25082513 });
25092514
25102515 const thread = await waitForThread(
2511− harness.engine,
2516+ harness.readModel,
25122517 (entry) =>
25132518 entry.title === "Renamed by provider" &&
25142519 entry.activities.some(
@@ -2595,7 +2600,7 @@ describe("ProviderRuntimeIngestion", () => { · 80 unmodified lines
25952600 },
25962601 });
25972602
2598− const thread = await waitForThread(harness.engine, (entry) =>
2603+ const thread = await waitForThread(harness.readModel, (entry) =>
25992604 entry.activities.some(
26002605 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated",
26012606 ),
@@ -2647,7 +2652,7 @@ describe("ProviderRuntimeIngestion", () => { · 45 unmodified lines
26472652 },
26482653 });
26492654
2650− const thread = await waitForThread(harness.engine, (entry) =>
2655+ const thread = await waitForThread(harness.readModel, (entry) =>
26512656 entry.activities.some(
26522657 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated",
26532658 ),
@@ -2697,7 +2702,7 @@ describe("ProviderRuntimeIngestion", () => { · 43 unmodified lines
26972702 },
26982703 });
26992704
2700− const thread = await waitForThread(harness.engine, (entry) =>
2705+ const thread = await waitForThread(harness.readModel, (entry) =>
27012706 entry.activities.some(
27022707 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated",
27032708 ),
@@ -2732,7 +2737,7 @@ describe("ProviderRuntimeIngestion", () => { · 28 unmodified lines
27322737 },
27332738 });
27342739
2735− const thread = await waitForThread(harness.engine, (entry) =>
2740+ const thread = await waitForThread(harness.readModel, (entry) =>
27362741 entry.activities.some(
27372742 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-compaction",
27382743 ),
@@ -2802,7 +2807,7 @@ describe("ProviderRuntimeIngestion", () => { · 63 unmodified lines
28022807 });
28032808
28042809 const thread = await waitForThread(
2805− harness.engine,
2810+ harness.readModel,
28062811 (entry) =>
28072812 entry.activities.some(
28082813 (activity: ProviderRuntimeTestActivity) => activity.kind === "task.completed",
@@ -2893,7 +2898,7 @@ describe("ProviderRuntimeIngestion", () => { · 84 unmodified lines
28932898 });
28942899
28952900 const thread = await waitForThread(
2896− harness.engine,
2901+ harness.readModel,
28972902 (entry) =>
28982903 entry.activities.some(
28992904 (activity: ProviderRuntimeTestActivity) => activity.kind === "user-input.requested",
@@ -2952,7 +2957,7 @@ describe("ProviderRuntimeIngestion", () => { · 52 unmodified lines
29522957 });
29532958
29542959 const thread = await waitForThread(
2955− harness.engine,
2960+ harness.readModel,
29562961 (entry) =>
29572962 entry.session?.status === "error" &&
29582963 entry.session?.activeTurnId === "turn-after-failure" &&
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+132−42
@@ -4,12 +4,16 @@ import { · 3 unmodified lines
44 CommandId,
55 MessageId,
66 type OrchestrationEvent,
7+ type OrchestrationMessage,
78 type OrchestrationProposedPlanId,
89 CheckpointRef,
910 isToolLifecycleItemType,
1011 ThreadId,
1112 type ThreadTokenUsageSnapshot,
1213 TurnId,
14+ type OrchestrationCheckpointSummary,
15+ type OrchestrationProposedPlan,
16+ type OrchestrationThread,
1317 type OrchestrationThreadActivity,
1418 type ProviderRuntimeEvent,
1519 } from "@t3tools/contracts";
@@ -19,9 +23,9 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; · 3 unmodified lines
1923 import { ProviderService } from "../../provider/Services/ProviderService.ts";
2024 import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
2125 import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
22−import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
2326 import { isGitRepository } from "../../git/Utils.ts";
2427 import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
28+import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
2529 import {
2630 ProviderRuntimeIngestionService,
2731 type ProviderRuntimeIngestionShape,
@@ -77,6 +81,82 @@ function sameId(left: string | null | undefined, right: string | null | undefine · 49 unmodified lines
7781 return left === right;
7882 }
7983
84+function hasAssistantMessageForTurn(
85+ messages: ReadonlyArray<OrchestrationMessage>,
86+ turnId: TurnId,
87+ options?: { readonly streamingOnly?: boolean },
88+): boolean {
89+ for (let index = 0; index < messages.length; index += 1) {
90+ const message = messages[index];
91+ if (!message) {
92+ continue;
93+ }
94+ if (message.role !== "assistant" || message.turnId !== turnId) {
95+ continue;
96+ }
97+ if (options?.streamingOnly === true && !message.streaming) {
98+ continue;
99+ }
100+ return true;
101+ }
102+ return false;
103+}
104+
105+function findMessageById(
106+ messages: ReadonlyArray<OrchestrationMessage>,
107+ messageId: MessageId,
108+): OrchestrationMessage | undefined {
109+ for (let index = 0; index < messages.length; index += 1) {
110+ const message = messages[index];
111+ if (message?.id === messageId) {
112+ return message;
113+ }
114+ }
115+ return undefined;
116+}
117+
118+function findProposedPlanById(
119+ proposedPlans: ReadonlyArray<
120+ Pick<OrchestrationProposedPlan, "id" | "createdAt" | "implementedAt" | "implementationThreadId">
121+ >,
122+ planId: string,
123+):
124+ | Pick<OrchestrationProposedPlan, "id" | "createdAt" | "implementedAt" | "implementationThreadId">
125+ | undefined {
126+ for (let index = 0; index < proposedPlans.length; index += 1) {
127+ const proposedPlan = proposedPlans[index];
128+ if (proposedPlan?.id === planId) {
129+ return proposedPlan;
130+ }
131+ }
132+ return undefined;
133+}
134+
135+function hasCheckpointForTurn(
136+ checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>,
137+ turnId: TurnId,
138+): boolean {
139+ for (let index = 0; index < checkpoints.length; index += 1) {
140+ if (checkpoints[index]?.turnId === turnId) {
141+ return true;
142+ }
143+ }
144+ return false;
145+}
146+
147+function maxCheckpointTurnCount(
148+ checkpoints: ReadonlyArray<OrchestrationCheckpointSummary>,
149+): number {
150+ let maxTurnCount = 0;
151+ for (let index = 0; index < checkpoints.length; index += 1) {
152+ const checkpoint = checkpoints[index];
153+ if (checkpoint && checkpoint.checkpointTurnCount > maxTurnCount) {
154+ maxTurnCount = checkpoint.checkpointTurnCount;
155+ }
156+ }
157+ return maxTurnCount;
158+}
159+
80160 function truncateDetail(value: string, limit = 180): string {
81161 return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;
82162 }
@@ -522,6 +602,7 @@ function runtimeEventToActivities( · 439 unmodified lines
522602
523603 const make = Effect.gen(function* () {
524604 const orchestrationEngine = yield* OrchestrationEngineService;
605+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
525606 const providerService = yield* ProviderService;
526607 const projectionTurnRepository = yield* ProjectionTurnRepository;
527608 const serverSettingsService = yield* ServerSettingsService;
@@ -553,20 +634,16 @@ const make = Effect.gen(function* () { · 25 unmodified lines
553634 lookup: () => Effect.succeed({ text: "", createdAt: "" }),
554635 });
555636
556− const isGitRepoForThread = Effect.fn("isGitRepoForThread")(function* (threadId: ThreadId) {
557− const readModel = yield* orchestrationEngine.getReadModel();
558− const thread = readModel.threads.find((entry) => entry.id === threadId);
559− if (!thread) {
560− return false;
561− }
562− const workspaceCwd = resolveThreadWorkspaceCwd({
563− thread,
564− projects: readModel.projects,
565− });
566− if (!workspaceCwd) {
567− return false;
568− }
569− return isGitRepository(workspaceCwd);
637+ const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) {
638+ return yield* projectionSnapshotQuery
639+ .getThreadDetailById(threadId)
640+ .pipe(Effect.map(Option.getOrUndefined));
641+ });
642+
643+ const resolveThreadShell = Effect.fn("resolveThreadShell")(function* (threadId: ThreadId) {
644+ return yield* projectionSnapshotQuery
645+ .getThreadShellById(threadId)
646+ .pipe(Effect.map(Option.getOrUndefined));
570647 });
571648
572649 const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) =>
@@ -917,7 +994,7 @@ const make = Effect.gen(function* () { · 344 unmodified lines
917994 return;
918995 }
919996
920− const existingPlan = input.threadProposedPlans.find((entry) => entry.id === input.planId);
997+ const existingPlan = findProposedPlanById(input.threadProposedPlans, input.planId);
921998 yield* orchestrationEngine.dispatch({
922999 type: "thread.proposed-plan.upsert",
9231000 commandId: providerCommandId(input.event, "proposed-plan-upsert"),
@@ -1070,8 +1147,7 @@ const make = Effect.gen(function* () { · 146 unmodified lines
10701147 implementationThreadId: ThreadId,
10711148 implementedAt: string,
10721149 ) {
1073− const readModel = yield* orchestrationEngine.getReadModel();
1074− const sourceThread = readModel.threads.find((entry) => entry.id === sourceThreadId);
1150+ const sourceThread = yield* resolveThreadDetail(sourceThreadId);
10751151 const sourcePlan = sourceThread?.proposedPlans.find((entry) => entry.id === sourcePlanId);
10761152 if (!sourceThread || !sourcePlan || sourcePlan.implementedAt !== null) {
10771153 return;
@@ -1096,10 +1172,19 @@ const make = Effect.gen(function* () { · 18 unmodified lines
10961172
10971173 const processRuntimeEvent = (event: ProviderRuntimeEvent) =>
10981174 Effect.gen(function* () {
1099− const readModel = yield* orchestrationEngine.getReadModel();
1100− const thread = readModel.threads.find((entry) => entry.id === event.threadId);
1175+ const thread = yield* resolveThreadShell(event.threadId);
11011176 if (!thread) return;
11021177
1178+ let loadedThreadDetail: OrchestrationThread | null | undefined;
1179+ const getLoadedThreadDetail = () =>
1180+ Effect.gen(function* () {
1181+ if (loadedThreadDetail !== undefined) {
1182+ return loadedThreadDetail;
1183+ }
1184+ loadedThreadDetail = (yield* resolveThreadDetail(thread.id)) ?? null;
1185+ return loadedThreadDetail;
1186+ });
1187+
11031188 const now = event.createdAt;
11041189 const eventTurnId = toTurnId(event.turnId);
11051190 const activeTurnId = thread.session?.activeTurnId ?? null;
@@ -1277,6 +1362,7 @@ const make = Effect.gen(function* () { · 171 unmodified lines
12771362 ? toTurnId(event.turnId)
12781363 : undefined;
12791364 if (pauseForUserTurnId) {
1365+ const detailedThread = yield* getLoadedThreadDetail();
12801366 const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
12811367 serverSettingsService.getSettings,
12821368 (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"),
@@ -1307,10 +1393,11 @@ const make = Effect.gen(function* () { · 24 unmodified lines
13071393 event.type === "request.opened"
13081394 ? "assistant-delta-finalize-on-request-opened"
13091395 : "assistant-delta-finalize-on-user-input-requested",
1310− hasProjectedMessage: thread.messages.some(
1311− (entry) =>
1312− entry.role === "assistant" && entry.turnId === pauseForUserTurnId && entry.streaming,
1313− ),
1396+ hasProjectedMessage:
1397+ detailedThread !== null &&
1398+ hasAssistantMessageForTurn(detailedThread.messages, pauseForUserTurnId, {
1399+ streamingOnly: true,
1400+ }),
13141401 flushedMessageIds,
13151402 });
13161403 }
@@ -1339,21 +1426,19 @@ const make = Effect.gen(function* () { · 22 unmodified lines
13391426 : undefined;
13401427
13411428 if (assistantCompletion) {
1429+ const detailedThread = yield* getLoadedThreadDetail();
1430+ const messages = detailedThread?.messages ?? [];
13421431 const turnId = toTurnId(event.turnId);
13431432 const activeAssistantMessageId = turnId
13441433 ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId)
13451434 : Option.none<MessageId>();
13461435 const hasAssistantMessagesForTurn =
1347− turnId !== undefined
1348− ? thread.messages.some((entry) => entry.role === "assistant" && entry.turnId === turnId)
1349− : false;
1436+ turnId !== undefined ? hasAssistantMessageForTurn(messages, turnId) : false;
13501437 const assistantMessageId = Option.getOrElse(
13511438 activeAssistantMessageId,
13521439 () => assistantCompletion.messageId,
13531440 );
1354− const existingAssistantMessage = thread.messages.find(
1355− (entry) => entry.id === assistantMessageId,
1356− );
1441+ const existingAssistantMessage = findMessageById(messages, assistantMessageId);
13571442 const shouldApplyFallbackCompletionText =
13581443 !existingAssistantMessage || existingAssistantMessage.text.length === 0;
13591444
@@ -1393,10 +1478,11 @@ const make = Effect.gen(function* () { · 33 unmodified lines
13931478 }
13941479
13951480 if (proposedPlanCompletion) {
1481+ const detailedThread = yield* getLoadedThreadDetail();
13961482 yield* finalizeBufferedProposedPlan({
13971483 event,
13981484 threadId: thread.id,
1399− threadProposedPlans: thread.proposedPlans,
1485+ threadProposedPlans: detailedThread?.proposedPlans ?? [],
14001486 planId: proposedPlanCompletion.planId,
14011487 ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}),
14021488 fallbackMarkdown: proposedPlanCompletion.planMarkdown,
@@ -1405,6 +1491,9 @@ const make = Effect.gen(function* () { · 2 unmodified lines
14051491 }
14061492
14071493 if (event.type === "turn.completed") {
1494+ const detailedThread = yield* getLoadedThreadDetail();
1495+ const messages = detailedThread?.messages ?? [];
1496+ const proposedPlans = detailedThread?.proposedPlans ?? [];
14081497 const turnId = toTurnId(event.turnId);
14091498 if (turnId) {
14101499 const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId);
@@ -1419,9 +1508,7 @@ const make = Effect.gen(function* () { · 8 unmodified lines
14191508 createdAt: now,
14201509 commandTag: "assistant-complete-finalize",
14211510 finalDeltaCommandTag: "assistant-delta-finalize-fallback",
1422− hasProjectedMessage: thread.messages.some(
1423− (entry) => entry.id === assistantMessageId,
1424− ),
1511+ hasProjectedMessage: findMessageById(messages, assistantMessageId) !== undefined,
14251512 }),
14261513 { concurrency: 1 },
14271514 ).pipe(Effect.asVoid);
@@ -1431,7 +1518,7 @@ const make = Effect.gen(function* () { · 3 unmodified lines
14311518 yield* finalizeBufferedProposedPlan({
14321519 event,
14331520 threadId: thread.id,
1434− threadProposedPlans: thread.proposedPlans,
1521+ threadProposedPlans: proposedPlans,
14351522 planId: proposedPlanIdForTurn(thread.id, turnId),
14361523 turnId,
14371524 updatedAt: now,
@@ -1483,21 +1570,24 @@ const make = Effect.gen(function* () { · 45 unmodified lines
14831570
14841571 if (event.type === "turn.diff.updated") {
14851572 const turnId = toTurnId(event.turnId);
1486− if (turnId && (yield* isGitRepoForThread(thread.id))) {
1573+ const checkpointContext = turnId
1574+ ? yield* projectionSnapshotQuery
1575+ .getThreadCheckpointContext(thread.id)
1576+ .pipe(Effect.map(Option.getOrUndefined))
1577+ : undefined;
1578+ const workspaceCwd =
1579+ checkpointContext?.worktreePath ?? checkpointContext?.workspaceRoot ?? undefined;
1580+ if (turnId && checkpointContext && workspaceCwd && isGitRepository(workspaceCwd)) {
14871581 // Skip if a checkpoint already exists for this turn. A real
14881582 // (non-placeholder) capture from CheckpointReactor should not
14891583 // be clobbered, and dispatching a duplicate placeholder for the
14901584 // same turnId would produce an unstable checkpointTurnCount.
1491− if (thread.checkpoints.some((c) => c.turnId === turnId)) {
1585+ if (hasCheckpointForTurn(checkpointContext.checkpoints, turnId)) {
14921586 // Already tracked; no-op.
14931587 } else {
14941588 const assistantMessageId = MessageId.make(
14951589 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
14961590 );
1497− const maxTurnCount = thread.checkpoints.reduce(
1498− (max, c) => Math.max(max, c.checkpointTurnCount),
1499− 0,
1500− );
15011591 yield* orchestrationEngine.dispatch({
15021592 type: "thread.turn.diff.complete",
15031593 commandId: providerCommandId(event, "thread-turn-diff-complete"),
@@ -1508,7 +1598,7 @@ const make = Effect.gen(function* () { · 4 unmodified lines
15081598 status: "missing",
15091599 files: [],
15101600 assistantMessageId,
1511− checkpointTurnCount: maxTurnCount + 1,
1601+ checkpointTurnCount: maxCheckpointTurnCount(checkpointContext.checkpoints) + 1,
15121602 createdAt: now,
15131603 });
15141604 }
apps/server/src/orchestration/Services/OrchestrationEngine.ts+2−13
@@ -10,11 +10,7 @@ · 9 unmodified lines
1010 *
1111 * @module OrchestrationEngineService
1212 */
13−import type {
14− OrchestrationCommand,
15− OrchestrationEvent,
16− OrchestrationReadModel,
17−} from "@t3tools/contracts";
13+import type { OrchestrationCommand, OrchestrationEvent } from "@t3tools/contracts";
1814 import { Context } from "effect";
1915 import type { Effect, Stream } from "effect";
2016
@@ -25,13 +21,6 @@ import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts"; · 4 unmodified lines
2521 * OrchestrationEngineShape - Service API for orchestration command and event flow.
2622 */
2723 export interface OrchestrationEngineShape {
28− /**
29− * Read the current in-memory orchestration read model.
30− *
31− * @returns Effect containing the latest read model.
32− */
33− readonly getReadModel: () => Effect.Effect<OrchestrationReadModel, never, never>;
34−
3524 /**
3625 * Replay persisted orchestration events from an exclusive sequence cursor.
3726 *
@@ -70,7 +59,7 @@ export interface OrchestrationEngineShape { · 32 unmodified lines
7059 * ```ts
7160 * const program = Effect.gen(function* () {
7261 * const engine = yield* OrchestrationEngineService
73− * return yield* engine.getReadModel()
62+ * return yield* engine.dispatch(command)
7463 * })
7564 * ```
7665 */
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+22−0
@@ -28,6 +28,10 @@ export interface ProjectionSnapshotCounts { · 27 unmodified lines
2828 readonly threadCount: number;
2929 }
3030
31+export interface ProjectionSnapshotSequence {
32+ readonly snapshotSequence: number;
33+}
34+
3135 export interface ProjectionThreadCheckpointContext {
3236 readonly threadId: ThreadId;
3337 readonly projectId: ProjectId;
@@ -40,6 +44,15 @@ export interface ProjectionThreadCheckpointContext { · 6 unmodified lines
4044 * ProjectionSnapshotQueryShape - Service API for read-model snapshots.
4145 */
4246 export interface ProjectionSnapshotQueryShape {
47+ /**
48+ * Read the lightweight command snapshot used to bootstrap the in-memory
49+ * orchestration engine without hydrating message/activity/checkpoint bodies.
50+ */
51+ readonly getCommandReadModel: () => Effect.Effect<
52+ OrchestrationReadModel,
53+ ProjectionRepositoryError
54+ >;
55+
4356 /**
4457 * Read the latest orchestration projection snapshot.
4558 *
@@ -59,6 +72,15 @@ export interface ProjectionSnapshotQueryShape { · 13 unmodified lines
5972 ProjectionRepositoryError
6073 >;
6174
75+ /**
76+ * Read the latest projection snapshot sequence without hydrating read-model
77+ * entities.
78+ */
79+ readonly getSnapshotSequence: () => Effect.Effect<
80+ ProjectionSnapshotSequence,
81+ ProjectionRepositoryError
82+ >;
83+
6284 /**
6385 * Read aggregate projection counts without hydrating the full read model.
6486 */
apps/server/src/persistence/Migrations.ts+2−0
@@ -41,6 +41,7 @@ import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingAppro · 40 unmodified lines
4141 import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts";
4242 import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts";
4343 import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts";
44+import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts";
4445
4546 /**
4647 * Migration loader with all migrations defined inline.
@@ -81,6 +82,7 @@ export const migrationEntries = [ · 34 unmodified lines
8182 [26, "CanonicalizeModelSelectionOptions", Migration0026],
8283 [27, "ProviderSessionRuntimeInstanceId", Migration0027],
8384 [28, "ProjectionThreadSessionInstanceId", Migration0028],
85+ [29, "ProjectionThreadDetailOrderingIndexes", Migration0029],
8486 ] as const;
8587
8688 export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts+73−0
@@ -0,0 +1,73 @@
1+import { assert, it } from "@effect/vitest";
2+import { Effect, Layer } from "effect";
3+import * as SqlClient from "effect/unstable/sql/SqlClient";
4+
5+import { runMigrations } from "../Migrations.ts";
6+import * as NodeSqliteClient from "../NodeSqliteClient.ts";
7+
8+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9+
10+layer("029_ProjectionThreadDetailOrderingIndexes", (it) => {
11+ it.effect("creates indexes matching thread detail ordering queries", () =>
12+ Effect.gen(function* () {
13+ const sql = yield* SqlClient.SqlClient;
14+
15+ yield* runMigrations({ toMigrationInclusive: 28 });
16+ yield* runMigrations({ toMigrationInclusive: 29 });
17+
18+ const activityIndexes = yield* sql<{
19+ readonly seq: number;
20+ readonly name: string;
21+ readonly unique: number;
22+ readonly origin: string;
23+ readonly partial: number;
24+ }>`
25+ PRAGMA index_list(projection_thread_activities)
26+ `;
27+ assert.ok(
28+ activityIndexes.some(
29+ (index) => index.name === "idx_projection_thread_activities_thread_sequence_created_id",
30+ ),
31+ );
32+
33+ const activityIndexColumns = yield* sql<{
34+ readonly seqno: number;
35+ readonly cid: number;
36+ readonly name: string;
37+ }>`
38+ PRAGMA index_info('idx_projection_thread_activities_thread_sequence_created_id')
39+ `;
40+ assert.deepStrictEqual(
41+ activityIndexColumns.map((column) => column.name),
42+ ["thread_id", "sequence", "created_at", "activity_id"],
43+ );
44+
45+ const messageIndexes = yield* sql<{
46+ readonly seq: number;
47+ readonly name: string;
48+ readonly unique: number;
49+ readonly origin: string;
50+ readonly partial: number;
51+ }>`
52+ PRAGMA index_list(projection_thread_messages)
53+ `;
54+ assert.ok(
55+ messageIndexes.some(
56+ (index) => index.name === "idx_projection_thread_messages_thread_created_id",
57+ ),
58+ );
59+
60+ const messageIndexColumns = yield* sql<{
61+ readonly seqno: number;
62+ readonly cid: number;
63+ readonly name: string;
64+ }>`
65+ PRAGMA index_info('idx_projection_thread_messages_thread_created_id')
66+ `;
67+ assert.deepStrictEqual(
68+ messageIndexColumns.map((column) => column.name),
69+ ["thread_id", "created_at", "message_id"],
70+ );
71+ }),
72+ );
73+});
apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.ts+16−0
@@ -0,0 +1,16 @@
1+import * as Effect from "effect/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 INDEX IF NOT EXISTS idx_projection_thread_activities_thread_sequence_created_id
9+ ON projection_thread_activities(thread_id, sequence, created_at, activity_id)
10+ `;
11+
12+ yield* sql`
13+ CREATE INDEX IF NOT EXISTS idx_projection_thread_messages_thread_created_id
14+ ON projection_thread_messages(thread_id, created_at, message_id)
15+ `;
16+});
apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts+44−54
@@ -1,53 +1,52 @@
1−import { Effect, Layer, Stream } from "effect";
1+import { ProjectId, type OrchestrationProject } from "@t3tools/contracts";
2+import { Effect, Layer, Option } from "effect";
23 import { describe, expect, it, vi } from "vitest";
3−import type { OrchestrationReadModel } from "@t3tools/contracts";
44
5−import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
5+import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
66 import { TerminalManager } from "../../terminal/Services/Manager.ts";
77 import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts";
88 import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts";
99
10−const emptySnapshot = (
11− scripts: OrchestrationReadModel["projects"][number]["scripts"],
12−): OrchestrationReadModel =>
13− ({
14− snapshotSequence: 1,
15− updatedAt: "2026-01-01T00:00:00.000Z",
16− projects: [
17− {
18− id: "project-1",
19− title: "Project",
20− workspaceRoot: "/repo/project",
21− defaultModelSelection: null,
22− scripts,
23− createdAt: "2026-01-01T00:00:00.000Z",
24− updatedAt: "2026-01-01T00:00:00.000Z",
25− deletedAt: null,
26− },
27− ],
28− threads: [],
29− providerSessions: [],
30− providerStatuses: [],
31− pendingApprovals: [],
32− latestTurnByThreadId: {},
33− }) as unknown as OrchestrationReadModel;
10+const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationProject => ({
11+ id: ProjectId.make("project-1"),
12+ title: "Project",
13+ workspaceRoot: "/repo/project",
14+ defaultModelSelection: null,
15+ scripts,
16+ createdAt: "2026-01-01T00:00:00.000Z",
17+ updatedAt: "2026-01-01T00:00:00.000Z",
18+ deletedAt: null,
19+});
20+
21+const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
22+ Layer.succeed(ProjectionSnapshotQuery, {
23+ getCommandReadModel: () => Effect.die("unused"),
24+ getSnapshot: () => Effect.die("unused"),
25+ getShellSnapshot: () => Effect.die("unused"),
26+ getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }),
27+ getCounts: () => Effect.die("unused"),
28+ getActiveProjectByWorkspaceRoot: (workspaceRoot) =>
29+ Effect.succeed(
30+ workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(),
31+ ),
32+ getProjectShellById: (projectId) =>
33+ Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()),
34+ getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
35+ getThreadCheckpointContext: () => Effect.die("unused"),
36+ getThreadShellById: () => Effect.die("unused"),
37+ getThreadDetailById: () => Effect.die("unused"),
38+ });
3439
3540 describe("ProjectSetupScriptRunner", () => {
3641 it("returns no-script when no setup script exists", async () => {
3742 const open = vi.fn();
3843 const write = vi.fn();
44+ const project = makeProject([]);
3945 const runner = await Effect.runPromise(
4046 Effect.service(ProjectSetupScriptRunner).pipe(
4147 Effect.provide(
4248 ProjectSetupScriptRunnerLive.pipe(
43− Layer.provideMerge(
44− Layer.succeed(OrchestrationEngineService, {
45− getReadModel: () => Effect.succeed(emptySnapshot([])),
46− readEvents: () => Stream.empty,
47− dispatch: () => Effect.die(new Error("unused")),
48− streamDomainEvents: Stream.empty,
49− }),
50− ),
49+ Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),
5150 Layer.provideMerge(
5251 Layer.succeed(TerminalManager, {
5352 open,
@@ -93,29 +92,20 @@ describe("ProjectSetupScriptRunner", () => { · 39 unmodified lines
9392 }),
9493 );
9594 const write = vi.fn(() => Effect.void);
95+ const project = makeProject([
96+ {
97+ id: "setup",
98+ name: "Setup",
99+ command: "bun install",
100+ icon: "configure",
101+ runOnWorktreeCreate: true,
102+ },
103+ ]);
96104 const runner = await Effect.runPromise(
97105 Effect.service(ProjectSetupScriptRunner).pipe(
98106 Effect.provide(
99107 ProjectSetupScriptRunnerLive.pipe(
100− Layer.provideMerge(
101− Layer.succeed(OrchestrationEngineService, {
102− getReadModel: () =>
103− Effect.succeed(
104− emptySnapshot([
105− {
106− id: "setup",
107− name: "Setup",
108− command: "bun install",
109− icon: "configure",
110− runOnWorktreeCreate: true,
111− },
112− ]),
113− ),
114− readEvents: () => Stream.empty,
115− dispatch: () => Effect.die(new Error("unused")),
116− streamDomainEvents: Stream.empty,
117− }),
118− ),
108+ Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),
119109 Layer.provideMerge(
120110 Layer.succeed(TerminalManager, {
121111 open,
apps/server/src/project/Layers/ProjectSetupScriptRunner.ts+10−6
@@ -1,7 +1,8 @@
1+import { ProjectId } from "@t3tools/contracts";
12 import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts";
2−import { Effect, Layer } from "effect";
3+import { Effect, Layer, Option } from "effect";
34
4−import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
5+import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
56 import { TerminalManager } from "../../terminal/Services/Manager.ts";
67 import {
78 type ProjectSetupScriptRunnerShape,
@@ -9,18 +10,21 @@ import { · 1 unmodified line
910 } from "../Services/ProjectSetupScriptRunner.ts";
1011
1112 const makeProjectSetupScriptRunner = Effect.gen(function* () {
12− const orchestrationEngine = yield* OrchestrationEngineService;
13+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
1314 const terminalManager = yield* TerminalManager;
1415
1516 const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) =>
1617 Effect.gen(function* () {
17− const readModel = yield* orchestrationEngine.getReadModel();
1818 const project =
1919 (input.projectId
20− ? readModel.projects.find((entry) => entry.id === input.projectId)
20+ ? yield* projectionSnapshotQuery
21+ .getProjectShellById(ProjectId.make(input.projectId))
22+ .pipe(Effect.map(Option.getOrUndefined))
2123 : null) ??
2224 (input.projectCwd
23− ? readModel.projects.find((entry) => entry.workspaceRoot === input.projectCwd)
25+ ? yield* projectionSnapshotQuery
26+ .getActiveProjectByWorkspaceRoot(input.projectCwd)
27+ .pipe(Effect.map(Option.getOrUndefined))
2428 : null) ??
2529 null;
2630
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+26−12
@@ -9,10 +9,7 @@ import { · 8 unmodified lines
99 import { Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect";
1010 import { afterEach, describe, expect, it, vi } from "vitest";
1111
12−import {
13− OrchestrationEngineService,
14− type OrchestrationEngineShape,
15−} from "../../orchestration/Services/OrchestrationEngine.ts";
12+import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
1613 import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
1714 import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts";
1815 import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
@@ -92,6 +89,10 @@ function makeReadModel( · 73 unmodified lines
9289 createdAt: now,
9390 updatedAt: now,
9491 archivedAt: null,
92+ latestUserMessageAt: null,
93+ hasPendingApprovals: false,
94+ hasPendingUserInput: false,
95+ hasActionableProposedPlan: false,
9596 latestTurn: null,
9697 messages: [],
9798 session: thread.session,
@@ -163,13 +164,6 @@ describe("ProviderSessionReaper", () => { · 65 unmodified lines
163164 streamEvents: Stream.empty,
164165 };
165166
166− const orchestrationEngine: OrchestrationEngineShape = {
167− getReadModel: () => Effect.succeed(input.readModel),
168− readEvents: () => Stream.empty,
169− dispatch: () => unsupported(),
170− streamDomainEvents: Stream.empty,
171− };
172−
173167 const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
174168 Layer.provide(SqlitePersistenceMemory),
175169 );
@@ -183,7 +177,27 @@ describe("ProviderSessionReaper", () => { · 7 unmodified lines
183177 Layer.provideMerge(providerSessionDirectoryLayer),
184178 Layer.provideMerge(runtimeRepositoryLayer),
185179 Layer.provideMerge(Layer.succeed(ProviderService, providerService)),
186− Layer.provideMerge(Layer.succeed(OrchestrationEngineService, orchestrationEngine)),
180+ Layer.provideMerge(
181+ Layer.succeed(ProjectionSnapshotQuery, {
182+ getCommandReadModel: () => Effect.die("unused"),
183+ getSnapshot: () => Effect.die("unused"),
184+ getShellSnapshot: () => Effect.die("unused"),
185+ getSnapshotSequence: () =>
186+ Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }),
187+ getCounts: () => Effect.die("unused"),
188+ getActiveProjectByWorkspaceRoot: () => Effect.die("unused"),
189+ getProjectShellById: () => Effect.die("unused"),
190+ getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
191+ getThreadCheckpointContext: () => Effect.die("unused"),
192+ getThreadShellById: (threadId) =>
193+ Effect.succeed(
194+ input.readModel.threads.find((thread) => thread.id === threadId)
195+ ? Option.some(input.readModel.threads.find((thread) => thread.id === threadId)!)
196+ : Option.none(),
197+ ),
198+ getThreadDetailById: () => Effect.die("unused"),
199+ }),
200+ ),
187201 Layer.provideMerge(NodeServices.layer),
188202 );
189203
apps/server/src/provider/Layers/ProviderSessionReaper.ts+6−6
@@ -1,6 +1,6 @@
1−import { Duration, Effect, Layer, Schedule } from "effect";
1+import { Duration, Effect, Layer, Option, Schedule } from "effect";
22
3−import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
3+import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
44 import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
55 import {
66 ProviderSessionReaper,
@@ -20,7 +20,7 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = · 13 unmodified lines
2020 Effect.gen(function* () {
2121 const providerService = yield* ProviderService;
2222 const directory = yield* ProviderSessionDirectory;
23− const orchestrationEngine = yield* OrchestrationEngineService;
23+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
2424
2525 const inactivityThresholdMs = Math.max(
2626 1,
@@ -29,8 +29,6 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = · 2 unmodified lines
2929 const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS);
3030
3131 const sweep = Effect.gen(function* () {
32− const readModel = yield* orchestrationEngine.getReadModel();
33− const threadsById = new Map(readModel.threads.map((thread) => [thread.id, thread] as const));
3432 const bindings = yield* directory.listBindings();
3533 const now = Date.now();
3634 let reapedCount = 0;
@@ -55,7 +53,9 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = · 18 unmodified lines
5553 continue;
5654 }
5755
58− const thread = threadsById.get(binding.threadId);
56+ const thread = yield* projectionSnapshotQuery
57+ .getThreadShellById(binding.threadId)
58+ .pipe(Effect.map(Option.getOrUndefined));
5959 if (thread?.session?.activeTurnId != null) {
6060 yield* Effect.logDebug("provider.session.reaper.skipped-active-turn", {
6161 threadId: binding.threadId,
apps/server/src/server.test.ts+2−1
@@ -559,7 +559,6 @@ const buildAppUnderTest = (options?: { · 558 unmodified lines
559559 ),
560560 Layer.provide(
561561 Layer.mock(OrchestrationEngineService)({
562− getReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()),
563562 readEvents: () => Stream.empty,
564563 dispatch: () => Effect.succeed({ sequence: 0 }),
565564 streamDomainEvents: Stream.empty,
@@ -568,6 +567,7 @@ const buildAppUnderTest = (options?: { · 2 unmodified lines
568567 ),
569568 Layer.provide(
570569 Layer.mock(ProjectionSnapshotQuery)({
570+ getCommandReadModel: () => Effect.succeed(makeDefaultOrchestrationReadModel()),
571571 getSnapshot: () => Effect.succeed(makeDefaultOrchestrationReadModel()),
572572 getShellSnapshot: () =>
573573 Effect.succeed({
@@ -576,6 +576,7 @@ const buildAppUnderTest = (options?: { · 2 unmodified lines
576576 threads: [],
577577 updatedAt: new Date(0).toISOString(),
578578 }),
579+ getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
579580 getProjectShellById: () => Effect.succeed(Option.none()),
580581 getThreadShellById: () => Effect.succeed(Option.none()),
581582 getThreadDetailById: () => Effect.succeed(Option.none()),
apps/server/src/serverRuntimeStartup.test.ts+6−2
@@ -77,8 +77,10 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa · 76 unmodified lines
7777
7878 yield* launchStartupHeartbeat.pipe(
7979 Effect.provideService(ProjectionSnapshotQuery, {
80+ getCommandReadModel: () => Effect.die("unused"),
8081 getSnapshot: () => Effect.die("unused"),
8182 getShellSnapshot: () => Effect.die("unused"),
83+ getSnapshotSequence: () => Effect.die("unused"),
8284 getCounts: () =>
8385 Deferred.await(releaseCounts).pipe(
8486 Effect.as({
@@ -129,8 +131,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa · 44 unmodified lines
129131 autoBootstrapProjectFromCwd: true,
130132 } as never),
131133 Effect.provideService(ProjectionSnapshotQuery, {
134+ getCommandReadModel: () => Effect.die("unused"),
132135 getSnapshot: () => Effect.die("unused"),
133136 getShellSnapshot: () => Effect.die("unused"),
137+ getSnapshotSequence: () => Effect.die("unused"),
134138 getCounts: () => Effect.die("unused"),
135139 getActiveProjectByWorkspaceRoot: () =>
136140 Effect.succeed(
@@ -152,7 +156,6 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa · 15 unmodified lines
152156 getThreadDetailById: () => Effect.die("unused"),
153157 }),
154158 Effect.provideService(OrchestrationEngineService, {
155− getReadModel: () => Effect.die("unused"),
156159 readEvents: () => Stream.empty,
157160 dispatch: (command) =>
158161 Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(
@@ -180,8 +183,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when · 21 unmodified lines
180183 autoBootstrapProjectFromCwd: true,
181184 } as never),
182185 Effect.provideService(ProjectionSnapshotQuery, {
186+ getCommandReadModel: () => Effect.die("unused"),
183187 getSnapshot: () => Effect.die("unused"),
184188 getShellSnapshot: () => Effect.die("unused"),
189+ getSnapshotSequence: () => Effect.die("unused"),
185190 getCounts: () => Effect.die("unused"),
186191 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
187192 getProjectShellById: () => Effect.die("unused"),
@@ -191,7 +196,6 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when · 3 unmodified lines
191196 getThreadDetailById: () => Effect.die("unused"),
192197 }),
193198 Effect.provideService(OrchestrationEngineService, {
194− getReadModel: () => Effect.die("unused"),
195199 readEvents: () => Stream.empty,
196200 dispatch: (command) =>
197201 Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(
apps/server/src/ws.ts+18−7
@@ -236,9 +236,13 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => · 235 unmodified lines
236236 return Effect.gen(function* () {
237237 const workspaceRoot =
238238 event.payload.workspaceRoot ??
239− (yield* orchestrationEngine.getReadModel()).projects.find(
240− (project) => project.id === event.payload.projectId,
241− )?.workspaceRoot ??
239+ Option.match(
240+ yield* projectionSnapshotQuery.getProjectShellById(event.payload.projectId),
241+ {
242+ onNone: () => null,
243+ onSome: (project) => project.workspaceRoot,
244+ },
245+ ) ??
242246 null;
243247 if (workspaceRoot === null) {
244248 return event;
@@ -252,7 +256,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => · 7 unmodified lines
252256 repositoryIdentity,
253257 },
254258 } satisfies OrchestrationEvent;
255− });
259+ }).pipe(Effect.catch(() => Effect.succeed(event)));
256260 default:
257261 return Effect.succeed(event);
258262 }
@@ -723,9 +727,16 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => · 464 unmodified lines
723727 }),
724728 ),
725729 ),
726− orchestrationEngine
727− .getReadModel()
728− .pipe(Effect.map((readModel) => readModel.snapshotSequence)),
730+ projectionSnapshotQuery.getSnapshotSequence().pipe(
731+ Effect.map(({ snapshotSequence }) => snapshotSequence),
732+ Effect.mapError(
733+ (cause) =>
734+ new OrchestrationGetSnapshotError({
735+ message: "Failed to load orchestration snapshot sequence",
736+ cause,
737+ }),
738+ ),
739+ ),
729740 ]);
730741
731742 if (Option.isNone(threadDetail)) {
apps/web/src/environments/runtime/connection.test.ts+33−4
@@ -22,14 +22,14 @@ function createTestClient() { · 21 unmodified lines
2222 environmentId: EnvironmentId.make("env-1"),
2323 },
2424 })),
25− subscribeConfig: (listener: (event: any) => void) => {
25+ subscribeConfig: vi.fn((listener: (event: any) => void) => {
2626 configListeners.add(listener);
2727 return () => configListeners.delete(listener);
28− },
29− subscribeLifecycle: (listener: (event: any) => void) => {
28+ }),
29+ subscribeLifecycle: vi.fn((listener: (event: any) => void) => {
3030 lifecycleListeners.add(listener);
3131 return () => lifecycleListeners.delete(listener);
32− },
32+ }),
3333 subscribeAuthAccess: () => () => undefined,
3434 refreshProviders: vi.fn(async () => undefined),
3535 upsertKeybinding: vi.fn(async () => undefined),
@@ -237,4 +237,33 @@ describe("createEnvironmentConnection", () => { · 201 unmodified lines
237237
238238 await connection.dispose();
239239 });
240+
241+ it("skips primary lifecycle/config subscriptions when no handlers are registered", async () => {
242+ const environmentId = EnvironmentId.make("env-1");
243+ const { client } = createTestClient();
244+
245+ const connection = createEnvironmentConnection({
246+ kind: "primary",
247+ knownEnvironment: {
248+ id: "env-1",
249+ label: "Local env",
250+ source: "manual",
251+ target: {
252+ httpBaseUrl: "http://example.test",
253+ wsBaseUrl: "ws://example.test",
254+ },
255+ environmentId,
256+ },
257+ client,
258+ applyShellEvent: vi.fn(),
259+ syncShellSnapshot: vi.fn(),
260+ applyTerminalEvent: vi.fn(),
261+ });
262+
263+ expect(client.server.subscribeLifecycle).not.toHaveBeenCalled();
264+ expect(client.server.subscribeConfig).not.toHaveBeenCalled();
265+ expect(client.orchestration.subscribeShell).toHaveBeenCalledOnce();
266+
267+ await connection.dispose();
268+ });
240269 });
apps/web/src/environments/runtime/connection.ts+31−22
@@ -83,6 +83,8 @@ export function createEnvironmentConnection( · 82 unmodified lines
8383
8484 let disposed = false;
8585 const bootstrapGate = createBootstrapGate();
86+ const shouldObserveLifecycle = input.kind === "saved" || input.onWelcome !== undefined;
87+ const shouldObserveConfig = input.kind === "saved" || input.onConfigSnapshot !== undefined;
8688
8789 const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => {
8890 if (environmentId !== nextEnvironmentId) {
@@ -92,28 +94,35 @@ export function createEnvironmentConnection( · 3 unmodified lines
9294 }
9395 };
9496
95− const unsubLifecycle = input.client.server.subscribeLifecycle(
96− (event: Parameters<Parameters<WsRpcClient["server"]["subscribeLifecycle"]>[0]>[0]) => {
97− if (event.type !== "welcome") {
98− return;
99− }
100− observeEnvironmentIdentity(
101− event.payload.environment.environmentId,
102− "server lifecycle welcome",
103− );
104− input.onWelcome?.(event.payload);
105− },
106− );
107−
108− const unsubConfig = input.client.server.subscribeConfig(
109− (event: Parameters<Parameters<WsRpcClient["server"]["subscribeConfig"]>[0]>[0]) => {
110− if (event.type !== "snapshot") {
111− return;
112− }
113− observeEnvironmentIdentity(event.config.environment.environmentId, "server config snapshot");
114− input.onConfigSnapshot?.(event.config);
115− },
116− );
97+ const unsubLifecycle = shouldObserveLifecycle
98+ ? input.client.server.subscribeLifecycle(
99+ (event: Parameters<Parameters<WsRpcClient["server"]["subscribeLifecycle"]>[0]>[0]) => {
100+ if (event.type !== "welcome") {
101+ return;
102+ }
103+ observeEnvironmentIdentity(
104+ event.payload.environment.environmentId,
105+ "server lifecycle welcome",
106+ );
107+ input.onWelcome?.(event.payload);
108+ },
109+ )
110+ : () => undefined;
111+
112+ const unsubConfig = shouldObserveConfig
113+ ? input.client.server.subscribeConfig(
114+ (event: Parameters<Parameters<WsRpcClient["server"]["subscribeConfig"]>[0]>[0]) => {
115+ if (event.type !== "snapshot") {
116+ return;
117+ }
118+ observeEnvironmentIdentity(
119+ event.config.environment.environmentId,
120+ "server config snapshot",
121+ );
122+ input.onConfigSnapshot?.(event.config);
123+ },
124+ )
125+ : () => undefined;
117126
118127 const unsubShell = input.client.orchestration.subscribeShell(
119128 (item: Parameters<Parameters<WsRpcClient["orchestration"]["subscribeShell"]>[0]>[0]) => {
apps/web/src/environments/runtime/service.savedEnvironments.test.ts+326−0
@@ -0,0 +1,326 @@
1+import { QueryClient } from "@tanstack/react-query";
2+import { EnvironmentId } from "@t3tools/contracts";
3+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
5+const mockCreateEnvironmentConnection = vi.fn();
6+const mockCreateWsRpcClient = vi.fn();
7+const mockFetchRemoteSessionState = vi.fn();
8+const mockWaitForSavedEnvironmentRegistryHydration = vi.fn();
9+const mockListSavedEnvironmentRecords = vi.fn();
10+const mockSavedEnvironmentRegistrySubscribe = vi.fn();
11+const mockReadSavedEnvironmentBearerToken = vi.fn();
12+const mockGetSavedEnvironmentRecord = vi.fn();
13+
14+function MockWsTransport() {
15+ return undefined;
16+}
17+
18+vi.mock("../primary", () => ({
19+ getPrimaryKnownEnvironment: vi.fn(() => ({
20+ id: "env-1",
21+ label: "Primary environment",
22+ source: "window-origin",
23+ target: {
24+ httpBaseUrl: "http://127.0.0.1:3000/",
25+ wsBaseUrl: "ws://127.0.0.1:3000/",
26+ },
27+ environmentId: EnvironmentId.make("env-1"),
28+ })),
29+}));
30+
31+vi.mock("../remote/api", () => ({
32+ bootstrapRemoteBearerSession: vi.fn(),
33+ fetchRemoteEnvironmentDescriptor: vi.fn(),
34+ fetchRemoteSessionState: mockFetchRemoteSessionState,
35+ resolveRemoteWebSocketConnectionUrl: vi.fn(() => "ws://remote.example.test"),
36+}));
37+
38+vi.mock("./catalog", () => ({
39+ getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord,
40+ hasSavedEnvironmentRegistryHydrated: vi.fn(() => true),
41+ listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
42+ persistSavedEnvironmentRecord: vi.fn(),
43+ readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,
44+ removeSavedEnvironmentBearerToken: vi.fn(),
45+ useSavedEnvironmentRegistryStore: {
46+ subscribe: mockSavedEnvironmentRegistrySubscribe,
47+ getState: () => ({
48+ upsert: vi.fn(),
49+ remove: vi.fn(),
50+ markConnected: vi.fn(),
51+ rename: vi.fn(),
52+ }),
53+ },
54+ useSavedEnvironmentRuntimeStore: {
55+ getState: () => ({
56+ ensure: vi.fn(),
57+ patch: vi.fn(),
58+ clear: vi.fn(),
59+ }),
60+ },
61+ waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration,
62+ writeSavedEnvironmentBearerToken: vi.fn(),
63+}));
64+
65+vi.mock("./connection", () => ({
66+ createEnvironmentConnection: mockCreateEnvironmentConnection,
67+}));
68+
69+vi.mock("../../rpc/wsRpcClient", () => ({
70+ createWsRpcClient: mockCreateWsRpcClient,
71+}));
72+
73+vi.mock("../../rpc/wsTransport", () => ({
74+ WsTransport: MockWsTransport,
75+}));
76+
77+vi.mock("~/composerDraftStore", () => ({
78+ markPromotedDraftThreadByRef: vi.fn(),
79+ markPromotedDraftThreadsByRef: vi.fn(),
80+ useComposerDraftStore: {
81+ getState: () => ({
82+ getDraftThreadByRef: vi.fn(() => null),
83+ clearDraftThread: vi.fn(),
84+ }),
85+ },
86+}));
87+
88+vi.mock("~/localApi", () => ({
89+ ensureLocalApi: vi.fn(() => ({
90+ persistence: {
91+ setSavedEnvironmentRegistry: vi.fn(async () => undefined),
92+ },
93+ })),
94+}));
95+
96+vi.mock("~/lib/terminalStateCleanup", () => ({
97+ collectActiveTerminalThreadIds: vi.fn(() => []),
98+}));
99+
100+vi.mock("~/orchestrationEventEffects", () => ({
101+ deriveOrchestrationBatchEffects: vi.fn(() => ({
102+ promotedThreadRefs: [],
103+ invalidatedProviderState: false,
104+ })),
105+}));
106+
107+vi.mock("~/lib/projectReactQuery", () => ({
108+ projectQueryKeys: {
109+ all: ["projects"],
110+ },
111+}));
112+
113+vi.mock("~/lib/providerReactQuery", () => ({
114+ providerQueryKeys: {
115+ all: ["providers"],
116+ },
117+}));
118+
119+vi.mock("~/store", () => ({
120+ useStore: {
121+ getState: () => ({
122+ syncServerShellSnapshot: vi.fn(),
123+ syncServerThreadDetail: vi.fn(),
124+ removeServerThreadDetail: vi.fn(),
125+ applyServerShellEvent: vi.fn(),
126+ }),
127+ },
128+ selectProjectsAcrossEnvironments: vi.fn(() => []),
129+ selectSidebarThreadSummaryByRef: vi.fn(() => null),
130+ selectThreadByRef: vi.fn(() => null),
131+ selectThreadsAcrossEnvironments: vi.fn(() => []),
132+}));
133+
134+vi.mock("~/terminalStateStore", () => ({
135+ useTerminalStateStore: {
136+ getState: () => ({
137+ applyTerminalEvent: vi.fn(),
138+ removeTerminalState: vi.fn(),
139+ clearTerminalSelection: vi.fn(),
140+ }),
141+ },
142+}));
143+
144+vi.mock("~/uiStateStore", () => ({
145+ useUiStateStore: {
146+ getState: () => ({
147+ clearThreadUi: vi.fn(),
148+ syncPromotedDraftThreadRefs: vi.fn(),
149+ }),
150+ },
151+}));
152+
153+const savedRecord = {
154+ environmentId: EnvironmentId.make("env-saved"),
155+ label: "Remote environment",
156+ httpBaseUrl: "https://remote.example.test/",
157+ wsBaseUrl: "wss://remote.example.test/",
158+};
159+
160+const configSnapshot = {
161+ environment: {
162+ environmentId: savedRecord.environmentId,
163+ label: "Remote environment",
164+ },
165+};
166+
167+function createClient() {
168+ return {
169+ dispose: vi.fn(async () => undefined),
170+ reconnect: vi.fn(async () => undefined),
171+ server: {
172+ getConfig: vi.fn(async () => configSnapshot),
173+ subscribeConfig: vi.fn(() => () => undefined),
174+ subscribeLifecycle: vi.fn(() => () => undefined),
175+ subscribeAuthAccess: vi.fn(() => () => undefined),
176+ refreshProviders: vi.fn(async () => undefined),
177+ upsertKeybinding: vi.fn(async () => undefined),
178+ getSettings: vi.fn(async () => undefined),
179+ updateSettings: vi.fn(async () => undefined),
180+ },
181+ orchestration: {
182+ subscribeShell: vi.fn(() => () => undefined),
183+ subscribeThread: vi.fn(() => () => undefined),
184+ dispatchCommand: vi.fn(async () => undefined),
185+ getTurnDiff: vi.fn(async () => undefined),
186+ getFullThreadDiff: vi.fn(async () => undefined),
187+ },
188+ terminal: {
189+ open: vi.fn(async () => undefined),
190+ write: vi.fn(async () => undefined),
191+ resize: vi.fn(async () => undefined),
192+ clear: vi.fn(async () => undefined),
193+ restart: vi.fn(async () => undefined),
194+ close: vi.fn(async () => undefined),
195+ onEvent: vi.fn(() => () => undefined),
196+ },
197+ projects: {
198+ searchEntries: vi.fn(async () => []),
199+ writeFile: vi.fn(async () => undefined),
200+ },
201+ shell: {
202+ openInEditor: vi.fn(async () => undefined),
203+ },
204+ git: {
205+ pull: vi.fn(async () => undefined),
206+ refreshStatus: vi.fn(async () => undefined),
207+ onStatus: vi.fn(() => () => undefined),
208+ runStackedAction: vi.fn(async () => ({})),
209+ listBranches: vi.fn(async () => []),
210+ createWorktree: vi.fn(async () => undefined),
211+ removeWorktree: vi.fn(async () => undefined),
212+ createBranch: vi.fn(async () => undefined),
213+ checkout: vi.fn(async () => undefined),
214+ init: vi.fn(async () => undefined),
215+ resolvePullRequest: vi.fn(async () => undefined),
216+ preparePullRequestThread: vi.fn(async () => undefined),
217+ },
218+ };
219+}
220+
221+describe("saved environment startup", () => {
222+ beforeEach(() => {
223+ vi.useFakeTimers();
224+ vi.resetModules();
225+ vi.clearAllMocks();
226+
227+ mockFetchRemoteSessionState.mockResolvedValue({
228+ authenticated: true,
229+ role: "owner",
230+ });
231+ mockGetSavedEnvironmentRecord.mockImplementation((environmentId: EnvironmentId) =>
232+ environmentId === savedRecord.environmentId ? savedRecord : null,
233+ );
234+ mockListSavedEnvironmentRecords.mockReturnValue([savedRecord]);
235+ mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined);
236+ mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined);
237+ mockReadSavedEnvironmentBearerToken.mockResolvedValue("saved-bearer-token");
238+ mockCreateWsRpcClient.mockImplementation(() => createClient());
239+ mockCreateEnvironmentConnection.mockImplementation((input) => {
240+ if (input.kind === "saved") {
241+ queueMicrotask(() => {
242+ input.onConfigSnapshot?.(configSnapshot);
243+ });
244+ }
245+
246+ return {
247+ kind: input.kind,
248+ environmentId: input.knownEnvironment.environmentId,
249+ knownEnvironment: input.knownEnvironment,
250+ client: input.client,
251+ ensureBootstrapped: vi.fn(async () => undefined),
252+ reconnect: vi.fn(async () => undefined),
253+ dispose: vi.fn(async () => undefined),
254+ };
255+ });
256+ });
257+
258+ afterEach(async () => {
259+ const { resetEnvironmentServiceForTests } = await import("./service");
260+ await resetEnvironmentServiceForTests();
261+ vi.useRealTimers();
262+ });
263+
264+ it("uses the initial config snapshot instead of issuing an extra getConfig call", async () => {
265+ const { startEnvironmentConnectionService, resetEnvironmentServiceForTests } =
266+ await import("./service");
267+
268+ const stop = startEnvironmentConnectionService(new QueryClient());
269+ await vi.runAllTimersAsync();
270+
271+ const savedConnectionCall = mockCreateEnvironmentConnection.mock.calls.find(
272+ ([input]) => input.kind === "saved",
273+ );
274+ expect(savedConnectionCall).toBeDefined();
275+
276+ const savedClient = savedConnectionCall?.[0]?.client;
277+ expect(savedClient.server.getConfig).not.toHaveBeenCalled();
278+ expect(mockFetchRemoteSessionState).toHaveBeenCalledTimes(1);
279+
280+ stop();
281+ await resetEnvironmentServiceForTests();
282+ });
283+
284+ it("coalesces hydration and registry sync so the initial saved connection only starts once", async () => {
285+ let finishHydration!: () => void;
286+ let finishTokenRead!: (token: string) => void;
287+
288+ mockWaitForSavedEnvironmentRegistryHydration.mockImplementation(
289+ () =>
290+ new Promise<void>((resolve) => {
291+ finishHydration = () => resolve();
292+ }),
293+ );
294+ mockReadSavedEnvironmentBearerToken.mockImplementation(
295+ () =>
296+ new Promise<string>((resolve) => {
297+ finishTokenRead = resolve;
298+ }),
299+ );
300+
301+ const { startEnvironmentConnectionService, resetEnvironmentServiceForTests } =
302+ await import("./service");
303+
304+ const stop = startEnvironmentConnectionService(new QueryClient());
305+ const registryListener = mockSavedEnvironmentRegistrySubscribe.mock.calls[0]?.[0];
306+ expect(registryListener).toBeTypeOf("function");
307+
308+ registryListener?.();
309+ finishHydration();
310+ await vi.waitFor(() => {
311+ expect(mockReadSavedEnvironmentBearerToken).toHaveBeenCalledTimes(1);
312+ });
313+
314+ finishTokenRead("saved-bearer-token");
315+ await vi.runAllTimersAsync();
316+
317+ const savedConnectionCalls = mockCreateEnvironmentConnection.mock.calls.filter(
318+ ([input]) => input.kind === "saved",
319+ );
320+ expect(savedConnectionCalls).toHaveLength(1);
321+ expect(mockFetchRemoteSessionState).toHaveBeenCalledTimes(1);
322+
323+ stop();
324+ await resetEnvironmentServiceForTests();
325+ });
326+});
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+3−1
@@ -369,7 +369,9 @@ describe("retainThreadDetailSubscription", () => { · 368 unmodified lines
369369 listEnvironmentConnections().some((connection) => connection.environmentId === environmentId),
370370 ).toBe(false);
371371
372− await reconnectSavedEnvironment(environmentId);
372+ const reconnectPromise = reconnectSavedEnvironment(environmentId);
373+ await vi.advanceTimersByTimeAsync(200);
374+ await reconnectPromise;
373375 await vi.waitFor(() => {
374376 expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes(3);
375377 expect(mockSubscribeThread).toHaveBeenCalledTimes(2);
apps/web/src/environments/runtime/service.ts+73−4
@@ -139,9 +139,70 @@ let lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; · 138 unmodified lines
139139 const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000;
140140 const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32;
141141 const BROWSER_RESUME_RECONNECT_COOLDOWN_MS = 2_000;
142+const INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS = 150;
142143 const NOOP = () => undefined;
143144 const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u;
144145
146+function createDeferredPromise<T>() {
147+ let resolve: ((value: T) => void) | null = null;
148+ const promise = new Promise<T>((nextResolve) => {
149+ resolve = nextResolve;
150+ });
151+
152+ return {
153+ promise,
154+ resolve: (value: T) => {
155+ resolve?.(value);
156+ resolve = null;
157+ },
158+ };
159+}
160+
161+async function waitForConfigSnapshot(
162+ promise: Promise<ServerConfig>,
163+ timeoutMs: number,
164+): Promise<ServerConfig | null> {
165+ return await new Promise<ServerConfig | null>((resolve) => {
166+ const timeoutId = globalThis.setTimeout(() => resolve(null), timeoutMs);
167+ promise.then(
168+ (config) => {
169+ clearTimeout(timeoutId);
170+ resolve(config);
171+ },
172+ () => {
173+ clearTimeout(timeoutId);
174+ resolve(null);
175+ },
176+ );
177+ });
178+}
179+
180+function createSavedEnvironmentSyncScheduler() {
181+ let activeSync: Promise<void> | null = null;
182+ let queued = false;
183+
184+ const run = async (): Promise<void> => {
185+ do {
186+ queued = false;
187+ await syncSavedEnvironmentConnections(listSavedEnvironmentRecords());
188+ } while (queued);
189+ };
190+
191+ return () => {
192+ if (activeSync) {
193+ queued = true;
194+ return activeSync;
195+ }
196+
197+ activeSync = run()
198+ .catch(() => undefined)
199+ .finally(() => {
200+ activeSync = null;
201+ });
202+
203+ return activeSync;
204+ };
205+}
145206 function compareAppliedProjectionVersion(
146207 left: { readonly sequence: number; readonly updatedAt: string | null },
147208 right: { readonly sequence: number; readonly updatedAt: string | null },
@@ -233,7 +294,6 @@ function markAppliedProjectionEvent(environmentId: EnvironmentId, sequence: numb · 85 unmodified lines
233294 updatedAt: currentVersion?.updatedAt ?? null,
234295 });
235296 }
236−
237297 function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string {
238298 return scopedThreadKey(scopeThreadRef(environmentId, threadId));
239299 }
@@ -1284,6 +1344,7 @@ async function ensureSavedEnvironmentConnection( · 1044 unmodified lines
12841344 const client =
12851345 options?.client ??
12861346 createSavedEnvironmentClient(activeRecord.environmentId, activeBearerToken);
1347+ const initialConfigSnapshot = createDeferredPromise<ServerConfig>();
12871348 const knownEnvironment = createKnownEnvironment({
12881349 id: activeRecord.environmentId,
12891350 label: activeRecord.label,
@@ -1308,6 +1369,7 @@ async function ensureSavedEnvironmentConnection( · 18 unmodified lines
13081369 );
13091370 },
13101371 onConfigSnapshot: (config) => {
1372+ initialConfigSnapshot.resolve(config);
13111373 useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, {
13121374 descriptor: config.environment,
13131375 serverConfig: config,
@@ -1323,12 +1385,18 @@ async function ensureSavedEnvironmentConnection( · 9 unmodified lines
13231385
13241386 try {
13251387 try {
1388+ const initialServerConfig =
1389+ options?.serverConfig ??
1390+ (await waitForConfigSnapshot(
1391+ initialConfigSnapshot.promise,
1392+ INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS,
1393+ ));
13261394 await refreshSavedEnvironmentMetadata(
13271395 activeRecord.environmentId,
13281396 activeBearerToken,
13291397 client,
13301398 roleHint,
1331− options?.serverConfig ?? null,
1399+ initialServerConfig,
13321400 );
13331401 } catch (error) {
13341402 const isAuthError = activeRecord.desktopSsh
@@ -1699,6 +1767,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () · 364 unmodified lines
16991767 trailing: true,
17001768 },
17011769 );
1770+ const requestSavedEnvironmentSync = createSavedEnvironmentSyncScheduler();
17021771
17031772 maybeCreatePrimaryEnvironmentConnection();
17041773
@@ -1706,11 +1775,11 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () · 1 unmodified line
17061775 if (!hasSavedEnvironmentRegistryHydrated()) {
17071776 return;
17081777 }
1709− void syncSavedEnvironmentConnections(listSavedEnvironmentRecords());
1778+ void requestSavedEnvironmentSync();
17101779 });
17111780
17121781 void waitForSavedEnvironmentRegistryHydration()
1713− .then(() => syncSavedEnvironmentConnections(listSavedEnvironmentRecords()))
1782+ .then(() => requestSavedEnvironmentSync())
17141783 .catch(() => undefined);
17151784
17161785 const unsubscribeBrowserResumeReconnects = subscribeBrowserResumeReconnects();