History

perf(server): replay only the selected thread (#9726)

Read only the selected thread's events when resuming its detail stream. Measure bounded row counts and serialized payload bytes before replay, using the existing aggregate index and a captured authoritative head. Keep snapshot resets for oversized or invalid cursors. Reset recreated threads when a snapshot exists, and keep bounded replay for deleted threads whose snapshot is absent. Shell replay and the detail-event filter are unchanged. Created with GPT-6 Astra (preview) in Codex.

Theo Browne committed 2 months ago

Commit 50bfca41 parent c4353bc12 files · +781 −94Browse files

Unified · Split

12 files
  1. apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +6 −0
  2. apps/server/src/orchestration/Layers/OrchestrationEngine.ts +15 −0
  3. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +2 −0
  4. apps/server/src/orchestration/Services/OrchestrationEngine.ts +19 −2
  5. apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +183 −2
  6. apps/server/src/persistence/Layers/OrchestrationEventStore.ts +132 −0
  7. apps/server/src/persistence/Services/OrchestrationEventStore.ts +27 −0
  8. apps/server/src/relay/AgentAwarenessRelay.test.ts +4 −0
  9. apps/server/src/server.test.ts +337 −68
  10. apps/server/src/serverRuntimeStartup.reconcile.test.ts +4 −0
  11. apps/server/src/serverRuntimeStartup.test.ts +6 −0
  12. apps/server/src/ws.ts +46 −22
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+6−0
@@ -325,6 +325,8 @@ describe("OrchestrationEngine", () => { · 324 unmodified lines
325325 }),
326326 ),
327327 hasEventAfter: () => Effect.succeed(false),
328+ readAggregateRange: () => Stream.die("unused aggregate replay"),
329+ getAggregateReplayStats: () => Effect.die("unused aggregate replay stats"),
328330 };
329331
330332 const projectionSnapshot = {
@@ -1235,6 +1237,8 @@ describe("OrchestrationEngine", () => { · 904 unmodified lines
12351237 return Stream.fromIterable(events);
12361238 },
12371239 hasEventAfter: () => Effect.succeed(false),
1240+ readAggregateRange: () => Stream.die("unused aggregate replay"),
1241+ getAggregateReplayStats: () => Effect.die("unused aggregate replay stats"),
12381242 };
12391243
12401244 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
@@ -1473,6 +1477,8 @@ describe("OrchestrationEngine", () => { · 232 unmodified lines
14731477 return Stream.fromIterable(events);
14741478 },
14751479 hasEventAfter: () => Effect.succeed(false),
1480+ readAggregateRange: () => Stream.die("unused aggregate replay"),
1481+ getAggregateReplayStats: () => Effect.die("unused aggregate replay stats"),
14761482 };
14771483
14781484 let shouldFailProjection = true;
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+15−0
@@ -381,6 +381,19 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 380 unmodified lines
381381 const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) =>
382382 eventStore.readFromSequence(fromSequenceExclusive, limit);
383383
384+ const readThreadEvents: OrchestrationEngineShape["readThreadEvents"] = ({ threadId, ...range }) =>
385+ eventStore.readAggregateRange({ ...range, aggregateKind: "thread", aggregateId: threadId });
386+
387+ const getThreadReplayStats: OrchestrationEngineShape["getThreadReplayStats"] = ({
388+ threadId,
389+ ...range
390+ }) =>
391+ eventStore.getAggregateReplayStats({
392+ ...range,
393+ aggregateKind: "thread",
394+ aggregateId: threadId,
395+ });
396+
384397 const dispatch: OrchestrationEngineShape["dispatch"] = (command, options) =>
385398 Effect.gen(function* () {
386399 const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>();
@@ -395,6 +408,8 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 8 unmodified lines
395408
396409 return {
397410 readEvents,
411+ readThreadEvents,
412+ getThreadReplayStats,
398413 dispatch,
399414 subscribeDomainEvents: PubSub.subscribe(eventPubSub).pipe(Effect.map(Stream.fromSubscription)),
400415 // Each access creates a fresh PubSub subscription so that multiple
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+2−0
@@ -418,6 +418,8 @@ describe("ProviderCommandReactor", () => { · 417 unmodified lines
418418 const engine = yield* OrchestrationEngineService;
419419 return {
420420 readEvents: engine.readEvents,
421+ readThreadEvents: engine.readThreadEvents,
422+ getThreadReplayStats: engine.getThreadReplayStats,
421423 dispatch: (command) => {
422424 if (command.type === "thread.title.regeneration.complete") {
423425 titleRegenerationCompletionDispatchAttempts += 1;
apps/server/src/orchestration/Services/OrchestrationEngine.ts+19−2
@@ -14,6 +14,7 @@ import type { · 13 unmodified lines
1414 OrchestrationClientOrigin,
1515 OrchestrationCommand,
1616 OrchestrationEvent,
17+ ThreadId,
1718 } from "@t3tools/contracts";
1819 import * as Context from "effect/Context";
1920 import type * as Effect from "effect/Effect";
@@ -22,6 +23,13 @@ import type * as Stream from "effect/Stream"; · 2 unmodified lines
2223
2324 import type { OrchestrationDispatchError } from "../Errors.ts";
2425 import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts";
26+import type { OrchestrationAggregateReplayStats } from "../../persistence/Services/OrchestrationEventStore.ts";
27+
28+export interface OrchestrationThreadReplayRange {
29+ readonly threadId: ThreadId;
30+ readonly fromSequenceExclusive: number;
31+ readonly toSequenceInclusive: number;
32+}
2533
2634 /**
2735 * OrchestrationEngineShape - Service API for orchestration command and event flow.
@@ -33,8 +41,7 @@ export interface OrchestrationEngineShape { · 5 unmodified lines
3341 * @param fromSequenceExclusive - Sequence cursor (exclusive).
3442 * @param limit - Maximum number of events to read. Defaults to the event
3543 * store's page-bounded default; pass a higher value when the caller must
36− * read every event after the cursor (e.g. per-thread catch-up that filters
37− * a small subset out of a potentially larger global range).
44+ * read a wider global range. Thread subscriptions use readThreadEvents.
3845 * @returns Stream containing ordered events.
3946 */
4047 readonly readEvents: (
@@ -42,6 +49,16 @@ export interface OrchestrationEngineShape { · 1 unmodified line
4249 limit?: number,
4350 ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError, never>;
4451
52+ /** Read only this thread's events through a captured authoritative head. */
53+ readonly readThreadEvents: (
54+ input: OrchestrationThreadReplayRange & { readonly limit?: number },
55+ ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError>;
56+
57+ /** Measure a bounded thread replay without decoding its event bodies. */
58+ readonly getThreadReplayStats: (
59+ input: OrchestrationThreadReplayRange & { readonly maxEvents: number },
60+ ) => Effect.Effect<OrchestrationAggregateReplayStats, OrchestrationEventStoreError>;
61+
4562 /**
4663 * Dispatch a validated orchestration command.
4764 *
apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts+183−2
@@ -1,4 +1,11 @@
1−import { CommandId, EventId, ProjectId } from "@t3tools/contracts";
1+import {
2+ CommandId,
3+ EventId,
4+ MessageId,
5+ ProjectId,
6+ ThreadId,
7+ type OrchestrationEvent,
8+} from "@t3tools/contracts";
29 import { assert, it } from "@effect/vitest";
310 import * as Effect from "effect/Effect";
411 import * as Layer from "effect/Layer";
@@ -12,6 +19,31 @@ import { OrchestrationEventStoreLive } from "./OrchestrationEventStore.ts"; · 7 unmodified lines
1219 import { SqlitePersistenceMemory } from "./Sqlite.ts";
1320 const isPersistenceDecodeError = Schema.is(PersistenceDecodeError);
1421
22+function messageEvent(threadId: ThreadId, id: string): Omit<OrchestrationEvent, "sequence"> {
23+ const now = "2026-01-01T00:00:00.000Z";
24+ return {
25+ type: "thread.message-sent",
26+ eventId: EventId.make(id),
27+ aggregateKind: "thread",
28+ aggregateId: threadId,
29+ occurredAt: now,
30+ commandId: null,
31+ causationEventId: null,
32+ correlationId: null,
33+ metadata: {},
34+ payload: {
35+ threadId,
36+ messageId: MessageId.make(id),
37+ role: "assistant",
38+ text: id,
39+ turnId: null,
40+ streaming: false,
41+ createdAt: now,
42+ updatedAt: now,
43+ },
44+ };
45+}
46+
1547 const layer = it.layer(
1648 OrchestrationEventStoreLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
1749 );
@@ -79,7 +111,7 @@ layer("OrchestrationEventStore", (it) => { · 61 unmodified lines
79111 const sql = yield* SqlClient.SqlClient;
80112 const now = "2026-01-01T00:00:00.000Z";
81113
82− yield* sql`
114+ const invalidRows = yield* sql<{ readonly sequence: number }>`
83115 INSERT INTO orchestration_events (
84116 event_id,
85117 aggregate_kind,
@@ -108,6 +140,7 @@ layer("OrchestrationEventStore", (it) => { · 22 unmodified lines
108140 ${"{"},
109141 ${"{}"}
110142 )
143+ RETURNING sequence
111144 `;
112145
113146 const replayResult = yield* Effect.result(
@@ -122,6 +155,154 @@ layer("OrchestrationEventStore", (it) => { · 8 unmodified lines
122155 ),
123156 );
124157 }
158+ const scopedResult = yield* eventStore
159+ .readAggregateRange({
160+ aggregateKind: "project",
161+ aggregateId: "project-invalid-json",
162+ fromSequenceExclusive: 0,
163+ toSequenceInclusive: invalidRows[0]!.sequence,
164+ })
165+ .pipe(Stream.runCollect, Effect.result);
166+ assert.equal(scopedResult._tag, "Failure");
167+ if (scopedResult._tag === "Failure") {
168+ assert.ok(isPersistenceDecodeError(scopedResult.failure));
169+ assert.ok(
170+ scopedResult.failure.operation.includes(
171+ "OrchestrationEventStore.readAggregateRange:decodeRows",
172+ ),
173+ );
174+ }
175+ }),
176+ );
177+
178+ it.effect("reads one aggregate through the captured head across pruned global gaps", () =>
179+ Effect.gen(function* () {
180+ const store = yield* OrchestrationEventStore;
181+ const sql = yield* SqlClient.SqlClient;
182+ const threadId = ThreadId.make("shared-stream-id");
183+ const first = yield* store.append(messageEvent(threadId, "scoped-first"));
184+ const pruned = yield* store.append(
185+ messageEvent(ThreadId.make("pruned-thread"), "pruned-event"),
186+ );
187+ const second = yield* store.append(messageEvent(threadId, "scoped-second"));
188+ // The same stream ID in a different aggregate is not part of this thread.
189+ // Its invalid JSON must never reach the event decoder.
190+ yield* sql`
191+ INSERT INTO orchestration_events (
192+ event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at,
193+ actor_kind, payload_json, metadata_json
194+ ) VALUES (
195+ 'same-id-project', 'project', ${threadId}, 0, 'project.created',
196+ '2026-01-01T00:00:00.000Z', 'server', '{', '{'
197+ ), (
198+ 'unrelated-invalid', 'thread', 'unrelated-invalid-thread', 0, 'thread.activity-appended',
199+ '2026-01-01T00:00:00.000Z', 'server', '{', '{'
200+ )
201+ `;
202+ const last = yield* store.append(messageEvent(threadId, "scoped-last"));
203+ yield* sql`DELETE FROM orchestration_events WHERE sequence = ${pruned.sequence}`;
204+ yield* store.append(messageEvent(threadId, "after-captured-head"));
205+
206+ const events = yield* store
207+ .readAggregateRange({
208+ aggregateKind: "thread",
209+ aggregateId: threadId,
210+ fromSequenceExclusive: first.sequence,
211+ toSequenceInclusive: last.sequence,
212+ limit: 100,
213+ })
214+ .pipe(Stream.runCollect);
215+ assert.deepEqual(
216+ events.map((event) => event.sequence),
217+ [second.sequence, last.sequence],
218+ );
219+ }),
220+ );
221+
222+ it.effect("bounds thread replay metadata and counts UTF-8 bytes without decoding payloads", () =>
223+ Effect.gen(function* () {
224+ const store = yield* OrchestrationEventStore;
225+ const sql = yield* SqlClient.SqlClient;
226+ const rows = yield* sql<{ readonly sequence: number }>`
227+ INSERT INTO orchestration_events (
228+ event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at,
229+ actor_kind, payload_json, metadata_json
230+ ) VALUES
231+ ('stats-1', 'thread', 'stats-thread', 0, 'thread.message-sent',
232+ '2026-01-01T00:00:00.000Z', 'provider', '{"output":"😀"}', '{}'),
233+ ('stats-unrelated', 'thread', 'another-thread', 0, 'thread.created',
234+ '2026-01-01T00:00:00.000Z', 'provider', printf('%.*c', 10000, 'x'), '{}'),
235+ ('stats-2', 'thread', 'stats-thread', 1, 'thread.activity-appended',
236+ '2026-01-01T00:00:00.000Z', 'provider', '{', '{}'),
237+ ('stats-other-kind', 'project', 'stats-thread', 0, 'project.deleted',
238+ '2026-01-01T00:00:00.000Z', 'provider', printf('%.*c', 20000, 'x'), '{}'),
239+ ('stats-3', 'thread', 'stats-thread', 2, 'thread.deleted',
240+ '2026-01-01T00:00:00.000Z', 'provider', '{"output":"é"}', '{}'),
241+ ('stats-4', 'thread', 'stats-thread', 3, 'thread.created',
242+ '2026-01-01T00:00:00.000Z', 'provider', printf('%.*c', 2000, 'x'), '{}')
243+ RETURNING sequence
244+ `;
245+ const range = {
246+ aggregateKind: "thread" as const,
247+ aggregateId: "stats-thread",
248+ fromSequenceExclusive: 0,
249+ toSequenceInclusive: rows.at(-1)!.sequence,
250+ };
251+ assert.deepEqual(yield* store.getAggregateReplayStats({ ...range, maxEvents: 2 }), {
252+ eventCount: 3,
253+ payloadBytes: 33,
254+ hasCreateEvent: false,
255+ });
256+ assert.deepEqual(yield* store.getAggregateReplayStats({ ...range, maxEvents: 10 }), {
257+ eventCount: 4,
258+ payloadBytes: 2033,
259+ hasCreateEvent: true,
260+ });
261+ assert.deepEqual(
262+ yield* store.getAggregateReplayStats({
263+ ...range,
264+ toSequenceInclusive: rows[2]!.sequence,
265+ maxEvents: 10,
266+ }),
267+ {
268+ eventCount: 2,
269+ payloadBytes: 18,
270+ hasCreateEvent: false,
271+ },
272+ );
273+ }),
274+ );
275+
276+ it.effect("keeps later pages below the captured head when new events are appended", () =>
277+ Effect.gen(function* () {
278+ const store = yield* OrchestrationEventStore;
279+ const threadId = ThreadId.make("paged-thread");
280+ const persisted = yield* Effect.forEach(
281+ Array.from({ length: 502 }, (_, index) => index),
282+ (index) => store.append(messageEvent(threadId, `paged-${index}`)),
283+ );
284+ const head = persisted.at(-1)!.sequence;
285+ let appendedDuringReplay = false;
286+ const replayed = yield* store
287+ .readAggregateRange({
288+ aggregateKind: "thread",
289+ aggregateId: threadId,
290+ fromSequenceExclusive: 0,
291+ toSequenceInclusive: head,
292+ limit: 1_000,
293+ })
294+ .pipe(
295+ Stream.tap(() => {
296+ if (appendedDuringReplay) return Effect.void;
297+ appendedDuringReplay = true;
298+ return store.append(messageEvent(threadId, "appended-during-replay"));
299+ }),
300+ Stream.runCollect,
301+ );
302+ assert.deepEqual(
303+ replayed.map((event) => event.sequence),
304+ persisted.map((event) => event.sequence),
305+ );
125306 }),
126307 );
127308 });
apps/server/src/persistence/Layers/OrchestrationEventStore.ts+132−0
@@ -72,6 +72,18 @@ const ReadFromSequenceRequestSchema = Schema.Struct({ · 71 unmodified lines
7272 sequenceExclusive: NonNegativeInt,
7373 limit: Schema.Number,
7474 });
75+const AggregateReplayRequestSchema = Schema.Struct({
76+ aggregateKind: OrchestrationAggregateKind,
77+ aggregateId: Schema.String,
78+ fromSequenceExclusive: NonNegativeInt,
79+ toSequenceInclusive: NonNegativeInt,
80+ limit: Schema.Number,
81+});
82+const AggregateReplayStatsRowSchema = Schema.Struct({
83+ eventCount: Schema.Number,
84+ payloadBytes: Schema.Number,
85+ hasCreateEvent: Schema.Number,
86+});
7587 const DEFAULT_READ_FROM_SEQUENCE_LIMIT = 1_000;
7688 const READ_PAGE_SIZE = 500;
7789
@@ -189,6 +201,57 @@ const makeEventStore = Effect.gen(function* () { · 111 unmodified lines
189201 `,
190202 });
191203
204+ const readAggregateEventRows = SqlSchema.findAll({
205+ Request: AggregateReplayRequestSchema,
206+ Result: OrchestrationEventPersistedRowSchema,
207+ execute: (request) =>
208+ sql`
209+ SELECT
210+ sequence,
211+ event_id AS "eventId",
212+ event_type AS "type",
213+ aggregate_kind AS "aggregateKind",
214+ stream_id AS "aggregateId",
215+ occurred_at AS "occurredAt",
216+ command_id AS "commandId",
217+ causation_event_id AS "causationEventId",
218+ correlation_id AS "correlationId",
219+ payload_json AS "payload",
220+ metadata_json AS "metadata"
221+ FROM orchestration_events
222+ WHERE aggregate_kind = ${request.aggregateKind}
223+ AND stream_id = ${request.aggregateId}
224+ AND sequence > ${request.fromSequenceExclusive}
225+ AND sequence <= ${request.toSequenceInclusive}
226+ ORDER BY sequence ASC
227+ LIMIT ${request.limit}
228+ `,
229+ });
230+
231+ const readAggregateReplayStats = SqlSchema.findOne({
232+ Request: AggregateReplayRequestSchema,
233+ Result: AggregateReplayStatsRowSchema,
234+ execute: (request) =>
235+ sql`
236+ SELECT
237+ COUNT(*) AS "eventCount",
238+ COALESCE(SUM(octet_length(payload_json)), 0) AS "payloadBytes",
239+ COALESCE(MAX(event_type IN (
240+ 'thread.created', 'project.created'
241+ )), 0) AS "hasCreateEvent"
242+ FROM (
243+ SELECT payload_json, event_type
244+ FROM orchestration_events
245+ WHERE aggregate_kind = ${request.aggregateKind}
246+ AND stream_id = ${request.aggregateId}
247+ AND sequence > ${request.fromSequenceExclusive}
248+ AND sequence <= ${request.toSequenceInclusive}
249+ ORDER BY sequence ASC
250+ LIMIT ${request.limit}
251+ )
252+ `,
253+ });
254+
192255 const append: OrchestrationEventStoreShape["append"] = (event) =>
193256 appendEventRow({
194257 eventId: event.eventId,
@@ -295,9 +358,78 @@ const makeEventStore = Effect.gen(function* () { · 100 unmodified lines
295358 ),
296359 );
297360
361+ const readAggregateRange: OrchestrationEventStoreShape["readAggregateRange"] = (input) => {
362+ const limit = Math.max(0, Math.floor(input.limit ?? DEFAULT_READ_FROM_SEQUENCE_LIMIT));
363+ if (limit === 0 || input.fromSequenceExclusive >= input.toSequenceInclusive) {
364+ return Stream.empty;
365+ }
366+ const readPage = (
367+ cursor: number,
368+ remaining: number,
369+ ): Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError> =>
370+ Stream.fromEffect(
371+ readAggregateEventRows({
372+ ...input,
373+ fromSequenceExclusive: cursor,
374+ limit: Math.min(remaining, READ_PAGE_SIZE),
375+ }).pipe(
376+ Effect.mapError(
377+ toPersistenceSqlOrDecodeError(
378+ "OrchestrationEventStore.readAggregateRange:query",
379+ "OrchestrationEventStore.readAggregateRange:decodeRows",
380+ ),
381+ ),
382+ Effect.flatMap((rows) =>
383+ Effect.forEach(rows, (row) =>
384+ decodeEvent(row).pipe(
385+ Effect.mapError(
386+ toPersistenceDecodeError("OrchestrationEventStore.readAggregateRange:rowToEvent"),
387+ ),
388+ ),
389+ ),
390+ ),
391+ ),
392+ ).pipe(
393+ Stream.flatMap((events) => {
394+ const last = events.at(-1);
395+ if (last === undefined) {
396+ return Stream.empty;
397+ }
398+ const nextRemaining = remaining - events.length;
399+ if (
400+ events.length < READ_PAGE_SIZE ||
401+ nextRemaining === 0 ||
402+ last.sequence >= input.toSequenceInclusive
403+ ) {
404+ return Stream.fromIterable(events);
405+ }
406+ return Stream.concat(Stream.fromIterable(events), readPage(last.sequence, nextRemaining));
407+ }),
408+ );
409+ return readPage(input.fromSequenceExclusive, limit);
410+ };
411+
412+ const getAggregateReplayStats: OrchestrationEventStoreShape["getAggregateReplayStats"] = (
413+ input,
414+ ) =>
415+ readAggregateReplayStats({
416+ ...input,
417+ limit: Math.max(0, Math.floor(input.maxEvents)) + 1,
418+ }).pipe(
419+ Effect.mapError(
420+ toPersistenceSqlOrDecodeError(
421+ "OrchestrationEventStore.getAggregateReplayStats:query",
422+ "OrchestrationEventStore.getAggregateReplayStats:decodeRow",
423+ ),
424+ ),
425+ Effect.map((row) => ({ ...row, hasCreateEvent: row.hasCreateEvent !== 0 })),
426+ );
427+
298428 return {
299429 append,
300430 readFromSequence,
431+ readAggregateRange,
432+ getAggregateReplayStats,
301433 readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER),
302434 hasEventAfter,
303435 } satisfies OrchestrationEventStoreShape;
apps/server/src/persistence/Services/OrchestrationEventStore.ts+27−0
@@ -16,6 +16,20 @@ import type * as Stream from "effect/Stream"; · 15 unmodified lines
1616
1717 import type { OrchestrationEventStoreError } from "../Errors.ts";
1818
19+export interface OrchestrationAggregateReplayRange {
20+ readonly aggregateKind: OrchestrationEvent["aggregateKind"];
21+ readonly aggregateId: string;
22+ readonly fromSequenceExclusive: number;
23+ readonly toSequenceInclusive: number;
24+}
25+
26+export interface OrchestrationAggregateReplayStats {
27+ readonly eventCount: number;
28+ readonly payloadBytes: number;
29+ /** A creation in this range does not prove that the aggregate still exists. */
30+ readonly hasCreateEvent: boolean;
31+}
32+
1933 /**
2034 * OrchestrationEventStoreShape - Service API for orchestration event persistence.
2135 */
@@ -46,6 +60,19 @@ export interface OrchestrationEventStoreShape { · 24 unmodified lines
4660 limit?: number,
4761 ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError>;
4862
63+ /** Read one aggregate through a captured global head, without decoding other streams. */
64+ readonly readAggregateRange: (
65+ input: OrchestrationAggregateReplayRange & { readonly limit?: number },
66+ ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError>;
67+
68+ /**
69+ * Measure at most maxEvents + 1 rows without decoding payloads. The extra
70+ * row tells the caller to use a snapshot instead of a truncated replay.
71+ */
72+ readonly getAggregateReplayStats: (
73+ input: OrchestrationAggregateReplayRange & { readonly maxEvents: number },
74+ ) => Effect.Effect<OrchestrationAggregateReplayStats, OrchestrationEventStoreError>;
75+
4976 /**
5077 * Read all events from the beginning of the stream.
5178 *
apps/server/src/relay/AgentAwarenessRelay.test.ts+4−0
@@ -459,6 +459,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { · 458 unmodified lines
459459
460460 const orchestrationEngine = {
461461 readEvents: () => Stream.empty,
462+ readThreadEvents: () => Stream.empty,
463+ getThreadReplayStats: () => Effect.die("unused thread replay stats"),
462464 dispatch: () => Effect.succeed({ sequence: 1 }),
463465 streamDomainEvents: Stream.fromQueue(events),
464466 subscribeDomainEvents: Effect.succeed(Stream.fromQueue(events)),
@@ -653,6 +655,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { · 188 unmodified lines
653655 }),
654656 Layer.succeed(OrchestrationEngineService, {
655657 readEvents: () => Stream.empty,
658+ readThreadEvents: () => Stream.empty,
659+ getThreadReplayStats: () => Effect.die("unused thread replay stats"),
656660 dispatch: () => Effect.succeed({ sequence: 1 }),
657661 streamDomainEvents: Stream.fromQueue(events),
658662 subscribeDomainEvents: Effect.succeed(Stream.fromQueue(events)),
apps/server/src/server.test.ts+337−68
@@ -115,6 +115,8 @@ import { · 114 unmodified lines
115115 import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
116116 import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts";
117117 import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
118+import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore.ts";
119+import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts";
118120 import { PersistenceSqlError } from "./persistence/Errors.ts";
119121 import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
120122 import * as ProviderService from "./provider/Services/ProviderService.ts";
@@ -918,6 +920,13 @@ const buildAppUnderTest = (options?: { · 797 unmodified lines
918920 Layer.mergeAll(
919921 Layer.mock(OrchestrationEngine.OrchestrationEngineService)({
920922 readEvents: () => Stream.empty,
923+ readThreadEvents: () => Stream.empty,
924+ getThreadReplayStats: () =>
925+ Effect.succeed({
926+ eventCount: 0,
927+ payloadBytes: 0,
928+ hasCreateEvent: false,
929+ }),
921930 dispatch: () => Effect.succeed({ sequence: 0 }),
922931 streamDomainEvents: Stream.empty,
923932 latestSequence: Effect.succeed(0),
@@ -8047,50 +8056,57 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 7123 unmodified lines
80478056 }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
80488057 );
80498058
8050− it.effect("subscribeThread sends a fresh snapshot instead of replaying a large gap", () =>
8051− Effect.gen(function* () {
8052− let readEventsCalls = 0;
8053− const thread = makeDefaultOrchestrationReadModel().threads[0]!;
8059+ it.effect(
8060+ "subscribeThread sends a fresh snapshot when its event count exceeds the replay limit",
8061+ () =>
8062+ Effect.gen(function* () {
8063+ let readEventsCalls = 0;
8064+ const thread = makeDefaultOrchestrationReadModel().threads[0]!;
80548065
8055− yield* buildAppUnderTest({
8056− layers: {
8057− orchestrationEngine: {
8058− // Head is far ahead of the client's afterSequence (gap > 1000).
8059− latestSequence: Effect.succeed(100_000),
8060− readEvents: () =>
8061− Stream.sync(() => {
8062− readEventsCalls += 1;
8063− return {} as OrchestrationEvent;
8064− }),
8065− },
8066− projectionSnapshotQuery: {
8067− getThreadDetailSnapshot: () =>
8068− Effect.succeed(Option.some({ snapshotSequence: 100_000, thread })),
8066+ yield* buildAppUnderTest({
8067+ layers: {
8068+ orchestrationEngine: {
8069+ latestSequence: Effect.succeed(100_000),
8070+ getThreadReplayStats: () =>
8071+ Effect.succeed({
8072+ eventCount: 1_001,
8073+ payloadBytes: 1_000,
8074+ hasCreateEvent: false,
8075+ }),
8076+ readThreadEvents: () =>
8077+ Stream.sync(() => {
8078+ readEventsCalls += 1;
8079+ return {} as OrchestrationEvent;
8080+ }),
8081+ },
8082+ projectionSnapshotQuery: {
8083+ getThreadDetailSnapshot: () =>
8084+ Effect.succeed(Option.some({ snapshotSequence: 100_000, thread })),
8085+ },
80698086 },
8070− },
8071− });
8087+ });
80728088
8073− const wsUrl = yield* getWsServerUrl("/ws");
8074− const items = yield* Effect.scoped(
8075− withWsRpcClient(wsUrl, (client) =>
8076− client[ORCHESTRATION_WS_METHODS.subscribeThread]({
8077− threadId: defaultThreadId,
8078− afterSequence: 5,
8079− requestCompletionMarker: true,
8080− }).pipe(Stream.take(2), Stream.runCollect),
8081− ),
8082− );
8089+ const wsUrl = yield* getWsServerUrl("/ws");
8090+ const items = yield* Effect.scoped(
8091+ withWsRpcClient(wsUrl, (client) =>
8092+ client[ORCHESTRATION_WS_METHODS.subscribeThread]({
8093+ threadId: defaultThreadId,
8094+ afterSequence: 5,
8095+ requestCompletionMarker: true,
8096+ }).pipe(Stream.take(2), Stream.runCollect),
8097+ ),
8098+ );
80838099
8084− const [first, second] = Array.from(items);
8085− // Large gap => fresh thread snapshot, and the global replay never starts.
8086− assert.equal(first?.kind, "snapshot");
8087− if (first?.kind === "snapshot") {
8088− assert.equal(first.snapshot.thread.id, defaultThreadId);
8089− assert.equal(first.snapshot.snapshotSequence, 100_000);
8090− }
8091− assert.equal(second?.kind, "synchronized");
8092− assert.equal(readEventsCalls, 0);
8093− }).pipe(Effect.provide(NodeHttpServer.layerTest)),
8100+ const [first, second] = Array.from(items);
8101+ // Never truncate a thread's replay at the event limit.
8102+ assert.equal(first?.kind, "snapshot");
8103+ if (first?.kind === "snapshot") {
8104+ assert.equal(first.snapshot.thread.id, defaultThreadId);
8105+ assert.equal(first.snapshot.snapshotSequence, 100_000);
8106+ }
8107+ assert.equal(second?.kind, "synchronized");
8108+ assert.equal(readEventsCalls, 0);
8109+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
80948110 );
80958111
80968112 it.effect(
@@ -8104,7 +8120,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 7 unmodified lines
81048120 const releaseAck = yield* Deferred.make<void>();
81058121 const firstApplied = yield* Deferred.make<void>();
81068122 const replayStarted = yield* Deferred.make<void>();
8107− const replayCalls: Array<{ afterSequence: number; limit: number | undefined }> = [];
8123+ const replayCalls: Array<{ afterSequence: number; headSequence: number }> = [];
81088124 let headSequence = 0;
81098125 let snapshotCalls = 0;
81108126 const message = (sequence: number, text: string) =>
@@ -8148,24 +8164,21 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 37 unmodified lines
81488164 return Stream.fromSubscription(subscription);
81498165 }),
81508166 ).pipe(Stream.ensuring(Deferred.succeed(detached, undefined))),
8151− readEvents: (afterSequence, limit) => {
8152− replayCalls.push({ afterSequence, limit });
8167+ readThreadEvents: ({ fromSequenceExclusive, toSequenceInclusive }) => {
8168+ replayCalls.push({
8169+ afterSequence: fromSequenceExclusive,
8170+ headSequence: toSequenceInclusive,
8171+ });
81538172 const range = events.filter(
8154− (event) => event.sequence > afterSequence && event.sequence <= headSequence,
8173+ (event) =>
8174+ event.sequence > fromSequenceExclusive && event.sequence <= toSequenceInclusive,
81558175 );
81568176 return Stream.concat(
81578177 Stream.fromEffect(Deferred.succeed(replayStarted, undefined)).pipe(Stream.drain),
81588178 Stream.fromIterable(range),
81598179 );
81608180 },
8161− },
8162− projectionSnapshotQuery: {
8163− getThreadDetailSnapshot: () =>
8164− Effect.sync(() => {
8165− snapshotCalls += 1;
8166− return Option.none();
8167− }),
8168− getEventReplayStats: ({ fromSequenceExclusive, toSequenceInclusive }) => {
8181+ getThreadReplayStats: ({ fromSequenceExclusive, toSequenceInclusive }) => {
81698182 const range = events.filter(
81708183 (event) =>
81718184 event.sequence > fromSequenceExclusive && event.sequence <= toSequenceInclusive,
@@ -8176,9 +8189,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 4 unmodified lines
81768189 (bytes, event) => bytes + Buffer.byteLength(jsonRequestBody(event.payload)),
81778190 0,
81788191 ),
8192+ hasCreateEvent: false,
81798193 });
81808194 },
81818195 },
8196+ projectionSnapshotQuery: {
8197+ getThreadDetailSnapshot: () =>
8198+ Effect.sync(() => {
8199+ snapshotCalls += 1;
8200+ return Option.none();
8201+ }),
8202+ },
81828203 },
81838204 });
81848205
@@ -8242,8 +8263,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 57 unmodified lines
82428263 );
82438264 assert.equal(completed.event.payload.activity.kind, "tool.completed");
82448265 assert.deepEqual(replayCalls, [
8245− { afterSequence: 0, limit: 0 },
8246− { afterSequence: 1, limit: 3 },
8266+ { afterSequence: 0, headSequence: 0 },
8267+ { afterSequence: 1, headSequence: 4 },
82478268 ]);
82488269 assert.equal(snapshotCalls, 0);
82498270 }),
@@ -8369,7 +8390,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 119 unmodified lines
83698390 layers: {
83708391 orchestrationEngine: {
83718392 latestSequence: Effect.succeed(5),
8372− readEvents: () =>
8393+ getThreadReplayStats: () =>
8394+ Effect.die("An invalid cursor must not start a replay query"),
8395+ readThreadEvents: () =>
83738396 Stream.sync(() => {
83748397 readEventsCalls += 1;
83758398 return {} as OrchestrationEvent;
@@ -8397,9 +8420,225 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 21 unmodified lines
83978420 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
83988421 );
83998422
8423+ it.effect("subscribeThread replays a small thread range across a large global gap", () =>
8424+ Effect.gen(function* () {
8425+ const event = makeLiveToolActivityEvent(99_999, "tool.completed");
8426+ yield* buildAppUnderTest({
8427+ layers: {
8428+ orchestrationEngine: {
8429+ latestSequence: Effect.succeed(100_000),
8430+ getThreadReplayStats: () =>
8431+ Effect.succeed({
8432+ eventCount: 1,
8433+ payloadBytes: Buffer.byteLength(jsonRequestBody(event.payload)),
8434+ hasCreateEvent: false,
8435+ }),
8436+ readThreadEvents: () => Stream.make(event),
8437+ readEvents: () => Stream.die("Thread replay must not read the global log"),
8438+ },
8439+ projectionSnapshotQuery: {
8440+ getEventReplayStats: () => Effect.die("Thread replay must not measure the global log"),
8441+ getThreadDetailSnapshot: () =>
8442+ Effect.die("Unrelated activity must not force a snapshot"),
8443+ },
8444+ },
8445+ });
8446+ const wsUrl = yield* getWsServerUrl("/ws");
8447+ const items = yield* Effect.scoped(
8448+ withWsRpcClient(wsUrl, (client) =>
8449+ client[ORCHESTRATION_WS_METHODS.subscribeThread]({
8450+ threadId: defaultThreadId,
8451+ afterSequence: 5,
8452+ requestCompletionMarker: true,
8453+ }).pipe(
8454+ Stream.takeUntil((item) => item.kind === "synchronized"),
8455+ Stream.runCollect,
8456+ ),
8457+ ),
8458+ );
8459+ assert.deepEqual(
8460+ items.map((item) => (item.kind === "event" ? item.event.sequence : item.kind)),
8461+ [99_999, "synchronized"],
8462+ );
8463+ const first = items[0];
8464+ assertTrue(first?.kind === "event" && first.event.type === "thread.activity-appended");
8465+ assert.equal(first.event.payload.activity.kind, "tool.completed");
8466+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
8467+ );
8468+
8469+ it.effect("subscribeThread resets cached history when its ID is created again", () =>
8470+ Effect.gen(function* () {
8471+ const thread = {
8472+ ...makeDefaultOrchestrationReadModel().threads[0]!,
8473+ title: "Recreated thread",
8474+ };
8475+ let requestedTurnLimit: number | undefined;
8476+ yield* buildAppUnderTest({
8477+ layers: {
8478+ orchestrationEngine: {
8479+ latestSequence: Effect.succeed(5),
8480+ getThreadReplayStats: () =>
8481+ Effect.succeed({
8482+ eventCount: 3,
8483+ payloadBytes: 1_000,
8484+ hasCreateEvent: true,
8485+ }),
8486+ readThreadEvents: () => Stream.die("A recreated thread must not reuse cached history"),
8487+ },
8488+ projectionSnapshotQuery: {
8489+ getThreadDetailSnapshot: (_threadId, options) => {
8490+ requestedTurnLimit = options?.turnLimit;
8491+ return Effect.succeed(Option.some({ snapshotSequence: 5, thread }));
8492+ },
8493+ },
8494+ },
8495+ });
8496+ const wsUrl = yield* getWsServerUrl("/ws");
8497+ const items = yield* Effect.scoped(
8498+ withWsRpcClient(wsUrl, (client) =>
8499+ client[ORCHESTRATION_WS_METHODS.subscribeThread]({
8500+ threadId: defaultThreadId,
8501+ afterSequence: 2,
8502+ turnLimit: 1,
8503+ requestCompletionMarker: true,
8504+ }).pipe(
8505+ Stream.takeUntil((item) => item.kind === "synchronized"),
8506+ Stream.runCollect,
8507+ ),
8508+ ),
8509+ );
8510+ const first = items[0];
8511+ assertTrue(first?.kind === "snapshot");
8512+ assert.equal(first.snapshot.thread.title, "Recreated thread");
8513+ assert.equal(first.snapshot.snapshotSequence, 5);
8514+ assert.equal(requestedTurnLimit, 1);
8515+ assert.deepEqual(items[1], { kind: "synchronized" });
8516+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
8517+ );
8518+
8519+ for (const { createBeforeDelete, oversized } of [
8520+ { createBeforeDelete: false, oversized: false },
8521+ { createBeforeDelete: true, oversized: false },
8522+ { createBeforeDelete: true, oversized: true },
8523+ ]) {
8524+ it.effect(
8525+ oversized
8526+ ? "keeps the missing-snapshot error when an absent thread exceeds the replay limit"
8527+ : `synchronizes an absent thread and removes its shell after ${createBeforeDelete ? "creation and deletion" : "deletion"}`,
8528+ () =>
8529+ Effect.gen(function* () {
8530+ const store = yield* OrchestrationEventStore;
8531+ const base = makeLiveToolActivityEvent(0, "tool.completed");
8532+ if (createBeforeDelete) {
8533+ const thread = makeDefaultOrchestrationReadModel().threads[0]!;
8534+ yield* store.append({
8535+ ...base,
8536+ eventId: EventId.make(`create-before-final-delete-${oversized}`),
8537+ type: "thread.created",
8538+ payload: {
8539+ threadId: defaultThreadId,
8540+ projectId: thread.projectId,
8541+ title: thread.title,
8542+ modelSelection: thread.modelSelection,
8543+ runtimeMode: thread.runtimeMode,
8544+ interactionMode: thread.interactionMode,
8545+ branch: thread.branch,
8546+ worktreePath: thread.worktreePath,
8547+ createdAt: thread.createdAt,
8548+ updatedAt: thread.updatedAt,
8549+ },
8550+ });
8551+ }
8552+ if (oversized) {
8553+ yield* Effect.forEach(
8554+ Array.from({ length: 1_000 }, (_, index) => index + 1),
8555+ (sequence) => store.append(makeLiveToolActivityEvent(sequence, "tool.completed")),
8556+ { discard: true },
8557+ );
8558+ }
8559+ const deleted = yield* store.append({
8560+ ...base,
8561+ eventId: EventId.make(`deleted-replay-${createBeforeDelete}-${oversized}`),
8562+ type: "thread.deleted",
8563+ payload: { threadId: defaultThreadId, deletedAt: base.occurredAt },
8564+ });
8565+ yield* buildAppUnderTest({
8566+ layers: {
8567+ orchestrationEngine: {
8568+ latestSequence: Effect.succeed(deleted.sequence),
8569+ getThreadReplayStats: ({ threadId, ...range }) =>
8570+ store.getAggregateReplayStats({
8571+ ...range,
8572+ aggregateKind: "thread",
8573+ aggregateId: threadId,
8574+ }),
8575+ readThreadEvents: ({ threadId, ...range }) =>
8576+ store.readAggregateRange({
8577+ ...range,
8578+ aggregateKind: "thread",
8579+ aggregateId: threadId,
8580+ }),
8581+ readEvents: store.readFromSequence,
8582+ },
8583+ projectionSnapshotQuery: {
8584+ getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
8585+ },
8586+ },
8587+ });
8588+ const wsUrl = yield* getWsServerUrl("/ws");
8589+ yield* Effect.scoped(
8590+ withWsRpcClient(wsUrl, (client) =>
8591+ Effect.gen(function* () {
8592+ const threadResult = yield* client[ORCHESTRATION_WS_METHODS.subscribeThread]({
8593+ threadId: defaultThreadId,
8594+ afterSequence: 0,
8595+ requestCompletionMarker: true,
8596+ }).pipe(
8597+ Stream.takeUntil((item) => item.kind === "synchronized"),
8598+ Stream.runCollect,
8599+ Effect.result,
8600+ );
8601+ if (oversized) {
8602+ assertTrue(threadResult._tag === "Failure");
8603+ assert.equal(threadResult.failure._tag, "OrchestrationGetSnapshotError");
8604+ assert.equal(
8605+ threadResult.failure.message,
8606+ `Thread ${defaultThreadId} was not found`,
8607+ );
8608+ return;
8609+ }
8610+ assertTrue(threadResult._tag === "Success");
8611+ assert.deepEqual(threadResult.success, [{ kind: "synchronized" }]);
8612+ const shellItems = yield* client[ORCHESTRATION_WS_METHODS.subscribeShell]({
8613+ afterSequence: 0,
8614+ requestCompletionMarker: true,
8615+ }).pipe(
8616+ Stream.takeUntil((item) => item.kind === "synchronized"),
8617+ Stream.runCollect,
8618+ );
8619+ assert.deepEqual(shellItems, [
8620+ { kind: "thread-removed", sequence: deleted.sequence, threadId: defaultThreadId },
8621+ { kind: "synchronized" },
8622+ ]);
8623+ }),
8624+ ),
8625+ );
8626+ }).pipe(
8627+ Effect.provide(
8628+ Layer.mergeAll(
8629+ OrchestrationEventStoreLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
8630+ NodeHttpServer.layerTest,
8631+ ),
8632+ ),
8633+ ),
8634+ );
8635+ }
8636+
84008637 it.effect("subscribeThread bounds catch-up replay to the captured head", () =>
84018638 Effect.gen(function* () {
8402− let replayLimit: number | undefined;
8639+ let replayHead: number | undefined;
8640+ let headSequence = 50;
8641+ const liveEvents = yield* PubSub.unbounded<OrchestrationEvent>();
84038642 const now = "2026-01-01T00:00:00.000Z";
84048643 const messageEvent = {
84058644 sequence: 3,
@@ -8427,10 +8666,26 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 21 unmodified lines
84278666 yield* buildAppUnderTest({
84288667 layers: {
84298668 orchestrationEngine: {
8430− latestSequence: Effect.succeed(50),
8431− readEvents: (_afterSequence, limit) => {
8432− replayLimit = limit;
8433− return Stream.make(messageEvent);
8669+ latestSequence: Effect.sync(() => headSequence),
8670+ streamDomainEvents: Stream.fromPubSub(liveEvents),
8671+ getThreadReplayStats: () =>
8672+ Effect.sync(() => {
8673+ headSequence = 100;
8674+ return { eventCount: 1, payloadBytes: 100, hasCreateEvent: false };
8675+ }),
8676+ readThreadEvents: ({ toSequenceInclusive }) => {
8677+ replayHead = toSequenceInclusive;
8678+ return Stream.fromEffect(
8679+ PubSub.publish(liveEvents, {
8680+ ...messageEvent,
8681+ sequence: 51,
8682+ eventId: EventId.make("event-live-after-head"),
8683+ payload: {
8684+ ...messageEvent.payload,
8685+ messageId: MessageId.make("message-live-after-head"),
8686+ },
8687+ }),
8688+ ).pipe(Stream.flatMap(() => Stream.make(messageEvent)));
84348689 },
84358690 },
84368691 },
@@ -8443,17 +8698,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 6 unmodified lines
84438698 threadId: defaultThreadId,
84448699 afterSequence: 0,
84458700 requestCompletionMarker: true,
8446− }).pipe(Stream.take(2), Stream.runCollect),
8701+ }).pipe(
8702+ Stream.takeUntil((item) => item.kind === "synchronized"),
8703+ Stream.runCollect,
8704+ ),
84478705 ),
84488706 );
84498707
8450− const [first, second] = Array.from(items);
8451− assert.equal(first?.kind, "event");
8452− assert.equal(first?.kind === "event" ? first.event.sequence : null, 3);
8453− assert.equal(second?.kind, "synchronized");
8454− // The replay is bounded to the head captured before the read, not
8455− // Number.MAX_SAFE_INTEGER.
8456− assert.equal(replayLimit, 50);
8708+ assert.deepEqual(
8709+ items.map((item) => (item.kind === "event" ? item.event.sequence : item.kind)),
8710+ [3, 51, "synchronized"],
8711+ );
8712+ assert.equal(replayHead, 50);
84578713 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
84588714 );
84598715
@@ -8530,6 +8786,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 70 unmodified lines
85308786 layers: {
85318787 orchestrationEngine: {
85328788 latestSequence: Effect.succeed(5),
8789+ getThreadReplayStats: () =>
8790+ Effect.sync(() => {
8791+ replayStatsCalls += 1;
8792+ return {
8793+ eventCount: 5,
8794+ payloadBytes: 8 * 1024 * 1024 + 1,
8795+ hasCreateEvent: false,
8796+ };
8797+ }),
8798+ readThreadEvents: () => {
8799+ readEventsCalls += 1;
8800+ return Stream.empty;
8801+ },
85338802 readEvents: () =>
85348803 Stream.sync(() => {
85358804 readEventsCalls += 1;
apps/server/src/serverRuntimeStartup.reconcile.test.ts+4−0
@@ -90,6 +90,8 @@ const runReconciliation = (input: { · 89 unmodified lines
9090 Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory),
9191 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
9292 readEvents: () => Stream.empty,
93+ readThreadEvents: () => Stream.empty,
94+ getThreadReplayStats: () => Effect.die("unused thread replay stats"),
9395 dispatch: input.dispatch,
9496 streamDomainEvents: Stream.empty,
9597 subscribeDomainEvents: Effect.succeed(Stream.empty),
@@ -653,6 +655,8 @@ it.effect("does not fail startup when the live provider session inventory cannot · 557 unmodified lines
653655 }),
654656 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
655657 readEvents: () => Stream.empty,
658+ readThreadEvents: () => Stream.empty,
659+ getThreadReplayStats: () => Effect.die("unused thread replay stats"),
656660 dispatch: () => Effect.die("unused"),
657661 streamDomainEvents: Stream.empty,
658662 subscribeDomainEvents: Effect.succeed(Stream.empty),
apps/server/src/serverRuntimeStartup.test.ts+6−0
@@ -220,6 +220,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa · 219 unmodified lines
220220 }),
221221 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
222222 readEvents: () => Stream.empty,
223+ readThreadEvents: () => Stream.empty,
224+ getThreadReplayStats: () => Effect.die("unused thread replay stats"),
223225 dispatch: (command) =>
224226 Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(
225227 Effect.as({ sequence: 1 }),
@@ -275,6 +277,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when · 49 unmodified lines
275277 }),
276278 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
277279 readEvents: () => Stream.empty,
280+ readThreadEvents: () => Stream.empty,
281+ getThreadReplayStats: () => Effect.die("unused thread replay stats"),
278282 dispatch: (command) =>
279283 Ref.update(dispatchCalls, (calls) => [...calls, command]).pipe(
280284 Effect.as({ sequence: 1 }),
@@ -339,6 +343,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa · 58 unmodified lines
339343 }),
340344 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
341345 readEvents: () => Stream.empty,
346+ readThreadEvents: () => Stream.empty,
347+ getThreadReplayStats: () => Effect.die("unused thread replay stats"),
342348 dispatch: (command) =>
343349 Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe(
344350 Effect.as({ sequence: 1 }),
apps/server/src/ws.ts+46−22
@@ -333,12 +333,9 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200; · 332 unmodified lines
333333 // Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT).
334334 const SHELL_RESUME_MAX_GAP = 1_000;
335335
336−// Same bound for thread resume. The replay reads the *global* event range and
337−// filters per-thread afterwards, so a stale cursor far behind the head would
338−// otherwise decode every intervening event's payload — reconnects with cursors
339−// hundreds of thousands of events behind have OOM-killed servers on large
340−// databases. Past this gap the client is reset with a fresh thread snapshot.
341−const THREAD_RESUME_MAX_GAP = 1_000;
336+// Thread replay counts only this thread's rows. Busy or pruned unrelated
337+// streams must not force a full thread snapshot.
338+const THREAD_RESUME_MAX_EVENTS = 1_000;
342339 // Row count alone does not bound replay memory: a few events with large tool
343340 // payloads can decode to gigabytes. Before replaying, sum the serialized
344341 // payload bytes of the range in SQL and reset with a snapshot past this budget.
@@ -1594,6 +1591,7 @@ const makeWsRpcLayer = ( · 1249 unmodified lines
15941591 { startImmediately: true },
15951592 );
15961593 const bufferedLiveStream = liveBuffer.stream;
1594+ let replayOnMissingSnapshot: typeof bufferedLiveStream | undefined;
15971595
15981596 // When the client already loaded the snapshot over HTTP it passes
15991597 // that snapshot's sequence, and we resume the live subscription by
@@ -1608,24 +1606,41 @@ const makeWsRpcLayer = ( · 8 unmodified lines
16081606 // catch-up followed by the buffered/ongoing live events. Overlapping
16091607 // events are deduped by sequence on the client.
16101608 //
1611− // The replay is bounded to the projection head captured below. The
1612− // catch-up range is normally tiny (a fresh HTTP snapshot sequence),
1613− // but a stale cached cursor can sit hundreds of thousands of global
1614− // events behind — replaying that decodes every intervening event
1615− // (including every other thread's tool payloads) only to discard
1616− // almost all of them, which has OOM-killed servers on large
1617− // databases. A truncated replay would silently drop this thread's
1618− // events, so past the gap cap we reset the client with a fresh
1619− // thread snapshot instead, exactly like subscribeShell above.
1609+ // Measure only this thread's rows. Global sequence gaps can
1610+ // contain unrelated or pruned streams. Keep an explicit upper
1611+ // bound so events after the captured head stay in the live tail.
16201612 if (input.afterSequence !== undefined) {
16211613 const afterSequence = input.afterSequence;
16221614 const headSequence = yield* orchestrationEngine.latestSequence;
1623− const replayGap = headSequence - afterSequence;
1615+ const range = {
1616+ threadId: input.threadId,
1617+ fromSequenceExclusive: afterSequence,
1618+ toSequenceInclusive: headSequence,
1619+ };
1620+ const replayStats =
1621+ afterSequence > headSequence
1622+ ? null
1623+ : yield* orchestrationEngine
1624+ .getThreadReplayStats({
1625+ ...range,
1626+ maxEvents: THREAD_RESUME_MAX_EVENTS,
1627+ })
1628+ .pipe(
1629+ Effect.mapError(
1630+ (cause) =>
1631+ new OrchestrationGetSnapshotError({
1632+ message: `Failed to measure thread ${input.threadId} replay range`,
1633+ cause,
1634+ }),
1635+ ),
1636+ );
16241637 if (
1625− yield* canReplayPersistedRange(afterSequence, headSequence, THREAD_RESUME_MAX_GAP)
1638+ replayStats !== null &&
1639+ replayStats.eventCount <= THREAD_RESUME_MAX_EVENTS &&
1640+ replayStats.payloadBytes <= ORCHESTRATION_REPLAY_PAYLOAD_BUDGET_BYTES
16261641 ) {
16271642 const catchUpStream = orchestrationEngine
1628− .readEvents(afterSequence, replayGap)
1643+ .readThreadEvents({ ...range, limit: THREAD_RESUME_MAX_EVENTS })
16291644 .pipe(
16301645 Stream.filter(isThisThreadDetailEvent),
16311646 Stream.map((event) => ({
@@ -1648,11 +1663,14 @@ const makeWsRpcLayer = ( · 16 unmodified lines
16481663 .pipe(Effect.as(bufferedLiveStream)),
16491664 )
16501665 : bufferedLiveStream;
1651− return Stream.concat(catchUpStream, afterCatchUp);
1666+ const replay = Stream.concat(catchUpStream, afterCatchUp);
1667+ if (!replayStats.hasCreateEvent) {
1668+ return replay;
1669+ }
1670+ replayOnMissingSnapshot = replay;
16521671 }
1653− // Gap too large (or cursor ahead of authoritative state): fall
1654− // through to the snapshot path so the client converges from a
1655− // fresh thread detail instead of an unbounded replay.
1672+ // A recreated thread needs a fresh snapshot if it still exists.
1673+ // Oversized replays and invalid cursors also use the snapshot path.
16561674 }
16571675
16581676 const snapshot = yield* projectionSnapshotQuery
@@ -1675,6 +1693,12 @@ const makeWsRpcLayer = ( · 16 unmodified lines
16751693 );
16761694
16771695 if (Option.isNone(snapshot)) {
1696+ // The recreated thread can already be deleted. Preserve the
1697+ // bounded replay and shell removal instead of retrying a
1698+ // snapshot that cannot exist. Oversized ranges still fail.
1699+ if (replayOnMissingSnapshot !== undefined) {
1700+ return replayOnMissingSnapshot;
1701+ }
16781702 return yield* new OrchestrationGetSnapshotError({
16791703 message: `Thread ${input.threadId} was not found`,
16801704 cause: input.threadId,