authorgravatar for 145564979+justsomelegs@users.noreply.github.comlegs <145564979+justsomelegs@users.noreply.github.com> 2026-05-04 21:56:01+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-04 13:56:01-07:00
logaca0fa4e5b0458ab640c2f479da0a1587c125e4a
tree4767f813988ab797fc14eca12d7a11f214dab3ed
parent0576820755fe12e32e50f03259035c863913b29f
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: Julius Marminge <julius0216@outlook.com>

30 files changed, 1850 insertions(+), 458 deletions(-)

apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+9
......@@ -86,10 +86,13 @@ describe("CheckpointDiffQueryLive", () => {
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", () => {
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", () => {
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";
1717
1818import { cli } from "./cli.ts";
1919import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";
20import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
2120import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
2221import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
2322import {
......@@ -316,8 +315,8 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => {
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 {
4141 type OrchestrationEngineShape,
4242} from "../Services/OrchestrationEngine.ts";
4343import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
44import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
4445import {
4546 ProviderService,
4647 type ProviderServiceShape,
......@@ -132,7 +133,14 @@ function createProviderServiceHarness(
132133}
133134
134135async 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(
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)
231239
232240describe("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", () => {
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", () => {
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", () => {
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", () => {
387401
388402 return {
389403 engine,
404 readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),
390405 provider,
391406 cwd,
392407 drain,
......@@ -443,7 +458,7 @@ describe("CheckpointReactor", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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
2020import { ProviderService } from "../../provider/Services/ProviderService.ts";
2121import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
2222import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
23import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
2324import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
2425import type { CheckpointStoreError } from "../../checkpointing/Errors.ts";
2526import type { OrchestrationDispatchError } from "../Errors.ts";
......@@ -66,6 +67,7 @@ const serverCommandId = (tag: string): CommandId =>
6667
6768const 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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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 {
88 TurnId,
99 type OrchestrationEvent,
1010 ProviderInstanceId,
11 type OrchestrationReadModel,
1112} from "@t3tools/contracts";
1213import { Effect, Layer, ManagedRuntime, Metric, Option, Queue, Stream } from "effect";
1314import { describe, expect, it } from "vitest";
......@@ -42,9 +43,13 @@ async function createOrchestrationSystem() {
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() {
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 = (
7784 );
7885
7986describe("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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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 {
3434 OrchestrationCommandInvariantError,
3535 OrchestrationCommandPreviouslyRejectedError,
3636 type OrchestrationDispatchError,
37 type OrchestrationProjectorDecodeError,
3738} from "../Errors.ts";
3839import { decideOrchestrationCommand } from "../decider.ts";
3940import { createEmptyReadModel, projectEvent } from "../projector.ts";
......@@ -77,13 +78,25 @@ const makeOrchestrationEngine = Effect.gen(function* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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
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
......@@ -12,6 +12,7 @@ import { Effect, Layer } from "effect";
1212import * as SqlClient from "effect/unstable/sql/SqlClient";
1313
1414import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
15import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts";
1516import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
1617import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
1718import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
......@@ -1029,4 +1030,378 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
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
1315it.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(
219219 };
220220}
221221
222function 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
222236function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
223237 return (cause: unknown): ProjectionRepositoryError =>
224238 Schema.isSchemaError(cause)
......@@ -230,6 +244,37 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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 {
4545} from "./ProviderCommandReactor.ts";
4646import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
4747import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
48import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
4849import * as NodeServices from "@effect/platform-node/NodeServices";
4950import { ServerSettingsService } from "../../serverSettings.ts";
5051import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
......@@ -79,7 +80,7 @@ async function waitFor(
7980
8081describe("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", () => {
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", () => {
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", () => {
382389
383390 return {
384391 engine,
392 readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),
385393 startSession,
386394 sendTurn,
387395 interruptTurn,
......@@ -431,7 +439,7 @@ describe("ProviderCommandReactor", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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 {
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";
2223import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
2324import { ProviderService } from "../../provider/Services/ProviderService.ts";
2425import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
26import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
2527import {
2628 ProviderCommandReactor,
2729 type ProviderCommandReactorShape,
......@@ -167,6 +169,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string {
167169
168170const 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* () {
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* () {
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* () {
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* () {
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* () {
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";
3636import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
3737import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
3838import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";
39import {
40 OrchestrationEngineService,
41 type OrchestrationEngineShape,
42} from "../Services/OrchestrationEngine.ts";
39import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
4340import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
41import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
4442import { ServerConfig } from "../../config.ts";
4543import { ServerSettingsService } from "../../serverSettings.ts";
4644import * as NodeServices from "@effect/platform-node/NodeServices";
......@@ -154,16 +152,23 @@ function createProviderServiceHarness() {
154152 };
155153}
156154
155type ProviderRuntimeTestReadModel = OrchestrationReadModel;
156type ProviderRuntimeTestThread = ProviderRuntimeTestReadModel["threads"][number];
157type ProviderRuntimeTestMessage = ProviderRuntimeTestThread["messages"][number];
158type ProviderRuntimeTestProposedPlan = ProviderRuntimeTestThread["proposedPlans"][number];
159type ProviderRuntimeTestActivity = ProviderRuntimeTestThread["activities"][number];
160type ProviderRuntimeTestCheckpoint = ProviderRuntimeTestThread["checkpoints"][number];
161
157162async 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(
176181 return poll();
177182}
178183
179type ProviderRuntimeTestReadModel = OrchestrationReadModel;
180type ProviderRuntimeTestThread = ProviderRuntimeTestReadModel["threads"][number];
181type ProviderRuntimeTestMessage = ProviderRuntimeTestThread["messages"][number];
182type ProviderRuntimeTestProposedPlan = ProviderRuntimeTestThread["proposedPlans"][number];
183type ProviderRuntimeTestActivity = ProviderRuntimeTestThread["activities"][number];
184type ProviderRuntimeTestCheckpoint = ProviderRuntimeTestThread["checkpoints"][number];
185
186184describe("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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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 {
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";
1923import { ProviderService } from "../../provider/Services/ProviderService.ts";
2024import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
2125import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
22import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
2326import { isGitRepository } from "../../git/Utils.ts";
2427import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
28import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
2529import {
2630 ProviderRuntimeIngestionService,
2731 type ProviderRuntimeIngestionShape,
......@@ -77,6 +81,82 @@ function sameId(left: string | null | undefined, right: string | null | undefine
7781 return left === right;
7882}
7983
84function 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
105function 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
118function 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
135function 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
147function 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
80160function truncateDetail(value: string, limit = 180): string {
81161 return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;
82162}
......@@ -522,6 +602,7 @@ function runtimeEventToActivities(
522602
523603const 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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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* () {
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 @@
1010 *
1111 * @module OrchestrationEngineService
1212 */
13import type {
14 OrchestrationCommand,
15 OrchestrationEvent,
16 OrchestrationReadModel,
17} from "@t3tools/contracts";
13import type { OrchestrationCommand, OrchestrationEvent } from "@t3tools/contracts";
1814import { Context } from "effect";
1915import type { Effect, Stream } from "effect";
2016
......@@ -25,13 +21,6 @@ import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts";
2521 * OrchestrationEngineShape - Service API for orchestration command and event flow.
2622 */
2723export 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 {
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
......@@ -28,6 +28,10 @@ export interface ProjectionSnapshotCounts {
2828 readonly threadCount: number;
2929}
3030
31export interface ProjectionSnapshotSequence {
32 readonly snapshotSequence: number;
33}
34
3135export interface ProjectionThreadCheckpointContext {
3236 readonly threadId: ThreadId;
3337 readonly projectId: ProjectId;
......@@ -40,6 +44,15 @@ export interface ProjectionThreadCheckpointContext {
4044 * ProjectionSnapshotQueryShape - Service API for read-model snapshots.
4145 */
4246export 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 {
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
......@@ -41,6 +41,7 @@ import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingAppro
4141import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts";
4242import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts";
4343import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts";
44import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts";
4445
4546/**
4647 * Migration loader with all migrations defined inline.
......@@ -81,6 +82,7 @@ export const migrationEntries = [
8182 [26, "CanonicalizeModelSelectionOptions", Migration0026],
8283 [27, "ProviderSessionRuntimeInstanceId", Migration0027],
8384 [28, "ProjectionThreadSessionInstanceId", Migration0028],
85 [29, "ProjectionThreadDetailOrderingIndexes", Migration0029],
8486] as const;
8587
8688export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts created+73
......@@ -0,0 +1,73 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4
5import { runMigrations } from "../Migrations.ts";
6import * as NodeSqliteClient from "../NodeSqliteClient.ts";
7
8const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9
10layer("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 created+16
......@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export 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 @@
1import { Effect, Layer, Stream } from "effect";
1import { ProjectId, type OrchestrationProject } from "@t3tools/contracts";
2import { Effect, Layer, Option } from "effect";
23import { describe, expect, it, vi } from "vitest";
3import type { OrchestrationReadModel } from "@t3tools/contracts";
44
5import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
5import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
66import { TerminalManager } from "../../terminal/Services/Manager.ts";
77import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts";
88import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts";
99
10const 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;
10const 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
21const 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
3540describe("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", () => {
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 @@
1import { ProjectId } from "@t3tools/contracts";
12import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts";
2import { Effect, Layer } from "effect";
3import { Effect, Layer, Option } from "effect";
34
4import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
5import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
56import { TerminalManager } from "../../terminal/Services/Manager.ts";
67import {
78 type ProjectSetupScriptRunnerShape,
......@@ -9,18 +10,21 @@ import {
910} from "../Services/ProjectSetupScriptRunner.ts";
1011
1112const 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 {
99import { Effect, Exit, Layer, ManagedRuntime, Option, Scope, Stream } from "effect";
1010import { afterEach, describe, expect, it, vi } from "vitest";
1111
12import {
13 OrchestrationEngineService,
14 type OrchestrationEngineShape,
15} from "../../orchestration/Services/OrchestrationEngine.ts";
12import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
1613import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
1714import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts";
1815import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
......@@ -92,6 +89,10 @@ function makeReadModel(
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", () => {
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", () => {
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 @@
1import { Duration, Effect, Layer, Schedule } from "effect";
1import { Duration, Effect, Layer, Option, Schedule } from "effect";
22
3import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts";
3import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
44import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
55import {
66 ProviderSessionReaper,
......@@ -20,7 +20,7 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) =
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) =
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) =
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?: {
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?: {
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?: {
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
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
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
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
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
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) =>
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) =>
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) =>
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() {
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", () => {
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(
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(
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 created+326
......@@ -0,0 +1,326 @@
1import { QueryClient } from "@tanstack/react-query";
2import { EnvironmentId } from "@t3tools/contracts";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
5const mockCreateEnvironmentConnection = vi.fn();
6const mockCreateWsRpcClient = vi.fn();
7const mockFetchRemoteSessionState = vi.fn();
8const mockWaitForSavedEnvironmentRegistryHydration = vi.fn();
9const mockListSavedEnvironmentRecords = vi.fn();
10const mockSavedEnvironmentRegistrySubscribe = vi.fn();
11const mockReadSavedEnvironmentBearerToken = vi.fn();
12const mockGetSavedEnvironmentRecord = vi.fn();
13
14function MockWsTransport() {
15 return undefined;
16}
17
18vi.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
31vi.mock("../remote/api", () => ({
32 bootstrapRemoteBearerSession: vi.fn(),
33 fetchRemoteEnvironmentDescriptor: vi.fn(),
34 fetchRemoteSessionState: mockFetchRemoteSessionState,
35 resolveRemoteWebSocketConnectionUrl: vi.fn(() => "ws://remote.example.test"),
36}));
37
38vi.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
65vi.mock("./connection", () => ({
66 createEnvironmentConnection: mockCreateEnvironmentConnection,
67}));
68
69vi.mock("../../rpc/wsRpcClient", () => ({
70 createWsRpcClient: mockCreateWsRpcClient,
71}));
72
73vi.mock("../../rpc/wsTransport", () => ({
74 WsTransport: MockWsTransport,
75}));
76
77vi.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
88vi.mock("~/localApi", () => ({
89 ensureLocalApi: vi.fn(() => ({
90 persistence: {
91 setSavedEnvironmentRegistry: vi.fn(async () => undefined),
92 },
93 })),
94}));
95
96vi.mock("~/lib/terminalStateCleanup", () => ({
97 collectActiveTerminalThreadIds: vi.fn(() => []),
98}));
99
100vi.mock("~/orchestrationEventEffects", () => ({
101 deriveOrchestrationBatchEffects: vi.fn(() => ({
102 promotedThreadRefs: [],
103 invalidatedProviderState: false,
104 })),
105}));
106
107vi.mock("~/lib/projectReactQuery", () => ({
108 projectQueryKeys: {
109 all: ["projects"],
110 },
111}));
112
113vi.mock("~/lib/providerReactQuery", () => ({
114 providerQueryKeys: {
115 all: ["providers"],
116 },
117}));
118
119vi.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
134vi.mock("~/terminalStateStore", () => ({
135 useTerminalStateStore: {
136 getState: () => ({
137 applyTerminalEvent: vi.fn(),
138 removeTerminalState: vi.fn(),
139 clearTerminalSelection: vi.fn(),
140 }),
141 },
142}));
143
144vi.mock("~/uiStateStore", () => ({
145 useUiStateStore: {
146 getState: () => ({
147 clearThreadUi: vi.fn(),
148 syncPromotedDraftThreadRefs: vi.fn(),
149 }),
150 },
151}));
152
153const 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
160const configSnapshot = {
161 environment: {
162 environmentId: savedRecord.environmentId,
163 label: "Remote environment",
164 },
165};
166
167function 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
221describe("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", () => {
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;
139139const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000;
140140const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32;
141141const BROWSER_RESUME_RECONNECT_COOLDOWN_MS = 2_000;
142const INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS = 150;
142143const NOOP = () => undefined;
143144const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u;
144145
146function 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
161async 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
180function 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}
145206function 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
233294 updatedAt: currentVersion?.updatedAt ?? null,
234295 });
235296}
236
237297function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string {
238298 return scopedThreadKey(scopeThreadRef(environmentId, threadId));
239299}
......@@ -1284,6 +1344,7 @@ async function ensureSavedEnvironmentConnection(
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(
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(
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): ()
16991767 trailing: true,
17001768 },
17011769 );
1770 const requestSavedEnvironmentSync = createSavedEnvironmentSyncScheduler();
17021771
17031772 maybeCreatePrimaryEnvironmentConnection();
17041773
......@@ -1706,11 +1775,11 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): ()
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();