From 896bf0dcb1cb8f0999891d391ad94792a1d6fdf3 Mon Sep 17 00:00:00 2001 From: t3-turbo-bot Date: Tue, 1 Sep 2026 17:20:41 -0400 Subject: [PATCH] perf(server): restore the fork's batched projection bootstrap The 0.0.45 upstream ingest replaced the fork's batched projection bootstrap with upstream's per-event version. The fork code had never been registered as a seam, and upstream's ProjectionPipeline.test.ts asserts exact thread-shell-update counts, so the conflict resolved toward upstream and nobody noticed. Cold start has been paying one sqlite transaction per replayed event, plus one shell-summary recompute per dirtying event, ever since. Re-implemented on top of upstream's current pipeline rather than reverted. The live path is untouched: projectEvent still runs runProjectorForEvent per event, and upstream's shouldRefreshThreadShellSummary gate still decides which events dirty a summary. Bootstrap now pages history in PROJECTION_BOOTSTRAP_BATCH_SIZE (500) chunks, applies the projector for the whole page inside one sql.withTransaction with a ProjectionApplyContext whose deferredThreadShellSummaryIds set collects the threads to refresh, refreshes each collected thread exactly once when the batch closes, and writes a single projection-state row per batch. Registered as seam batched-projection-bootstrap in .t3-turbo/customizations.json and SEAM.md so the next ingest has to resolve it deliberately. ProjectionPipeline.turbo.test.ts pins both halves: 1200 events across three threads bootstrap in ceil(n/500) projection-state commits with at most one shell refresh per thread per batch, and a single live event still refreshes its summary in the same call. Upstream's ProjectionPipeline.test.ts passes unchanged. Audited the rest of the fork's startup and performance edits against the pre-ingest tip f3e8bfd36; everything else survived both ingests. Version line moves to 0.0.48. Co-Authored-By: Claude Fable 5 --- .t3-turbo/customizations.json | 26 ++ SEAM.md | 36 +++ apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- .../Layers/ProjectionPipeline.ts | 126 ++++++++- .../Layers/ProjectionPipeline.turbo.test.ts | 258 ++++++++++++++++++ apps/web/package.json | 2 +- docs/operations/turbo-changelog.md | 8 + packages/contracts/package.json | 2 +- scripts/turbo-customization-manifest.test.ts | 1 + 10 files changed, 447 insertions(+), 16 deletions(-) create mode 100644 apps/server/src/orchestration/Layers/ProjectionPipeline.turbo.test.ts diff --git a/.t3-turbo/customizations.json b/.t3-turbo/customizations.json index 9c860e05971a..1d6eab2e2918 100644 --- a/.t3-turbo/customizations.json +++ b/.t3-turbo/customizations.json @@ -1381,6 +1381,32 @@ ] } ] + }, + { + "id": "batched-projection-bootstrap", + "status": "implemented", + "summary": "Cold start replays projection history in batches instead of one transaction per event. Upstream's ProjectionPipeline runs runProjectorForEvent per replayed event, so a multi-thousand-event history pays one sqlite transaction (and one thread shell-summary recompute per dirtying event) each time. The fork keeps upstream's live path and its shouldRefreshThreadShellSummary gate untouched, and adds a bootstrap-only batch path: PROJECTION_BOOTSTRAP_BATCH_SIZE (500) events per sql.withTransaction, shell-summary refreshes deferred into a per-batch Set and replayed once per thread when the batch closes, and a single projection-state upsert per batch. This was lost once already in the 0.0.45 ingest because it was never registered.", + "checks": [ + { + "path": "apps/server/src/orchestration/Layers/ProjectionPipeline.ts", + "markers": [ + "PROJECTION_BOOTSTRAP_BATCH_SIZE", + "runProjectorBatch", + "refreshOrDeferThreadShellSummary", + "deferredThreadShellSummaryIds", + "ProjectionApplyContext" + ] + }, + { + "path": "apps/server/src/orchestration/Layers/ProjectionPipeline.turbo.test.ts", + "markers": [ + "replays history in batched transactions with deduped shell refreshes", + "the threads projector should commit once per batch", + "shell summaries must refresh at most once per thread per batch", + "the live path must refresh the shell summary without waiting for a batch" + ] + } + ] } ] } diff --git a/SEAM.md b/SEAM.md index 2b9dc39ba7f8..28432a442d51 100644 --- a/SEAM.md +++ b/SEAM.md @@ -464,6 +464,42 @@ On a nightly-sync conflict: keep upstream's `onExit` shape and re-apply the succ the `Effect.catchTags` recovery, and the `Effect.repeat` wrapper. Retire this seam if upstream merges its own durable config subscription — PR pingdotgg/t3code#7233 (issue #7231) is the candidate. +## Batched projection bootstrap (fork perf) + +Upstream's `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` bootstraps every projector by +streaming history through `runProjectorForEvent`, which opens one `sql.withTransaction` per replayed +event and recomputes a thread's shell summary inline for every event that dirties it. Cold start on a +real database therefore pays a commit (and an fsync) per event, plus O(events) shell-summary +recomputes on the same handful of threads. + +The fork leaves upstream's live path alone — `projectEvent` still calls `runProjectorForEvent`, and +upstream's `shouldRefreshThreadShellSummary` gate still decides which events dirty a summary — and +adds a bootstrap-only batch path: + +- `PROJECTION_BOOTSTRAP_BATCH_SIZE = 500`. `bootstrapProjector` reads from `lastAppliedSequence` in + pages of that size and stops when a page comes back empty. +- `runProjectorBatch` runs the whole page inside **one** `sql.withTransaction`, applying the + projector per event with a `ProjectionApplyContext`. +- The context carries `deferredThreadShellSummaryIds`, a `Set`. While it is present, + `refreshOrDeferThreadShellSummary` records the thread instead of recomputing; each recorded thread + is refreshed exactly once at the end of the batch. With no context (the live path) it refreshes + immediately, which is what upstream's `ProjectionPipeline.test.ts` shell-update counts assert. +- One `projectionStateRepository.upsert` per batch, stamped with the batch's last event. Attachment + side-effects still run outside the transaction, exactly as upstream does per event. + +This seam was **lost once already**: the 0.0.45 ingest took upstream's per-event pipeline wholesale +because upstream's `ProjectionPipeline.test.ts` asserts exact shell-update counts and the fork code +was not registered here. It is registered now, and +`apps/server/src/orchestration/Layers/ProjectionPipeline.turbo.test.ts` pins both halves: 1200 events +across three threads bootstrap in `ceil(n / 500)` projection-state commits with at most one shell +refresh per thread per batch, and a single live event still refreshes its summary in the same call. + +On a nightly-sync conflict: take upstream's file, then re-add `ProjectionApplyContext`, the +`context?` parameter on `ProjectorDefinition["apply"]`, `PROJECTION_BOOTSTRAP_BATCH_SIZE`, +`refreshOrDeferThreadShellSummary` (wrapping, never replacing, upstream's +`shouldRefreshThreadShellSummary` gate), `runProjectorBatch`, and the paging `bootstrapProjector`. +Both test files must pass. + ## Nightly sync conflicts Resolve against the new upstream file first, then reapply only the behavior above; never take the diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fe09cf95e724..2c896fa16954 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.47", + "version": "0.0.48", "private": true, "type": "module", "main": "dist-electron/main.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index ff2778b941c7..9dc94bb06500 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.47", + "version": "0.0.48", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 22daeee69365..f1f320a65454 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -98,6 +98,7 @@ interface ProjectorDefinition { readonly apply: ( event: OrchestrationEvent, attachmentSideEffects: AttachmentSideEffects, + context?: ProjectionApplyContext, ) => Effect.Effect; } @@ -106,6 +107,26 @@ interface AttachmentSideEffects { readonly prunedThreadRelativePaths: Map>; } +/** + * Batch-scoped bookkeeping handed to a projector while it replays history. + * + * Present only on the bootstrap path. When `deferredThreadShellSummaryIds` is + * set, a projector records the threads whose shell summary went stale instead + * of recomputing each one inline; `runProjectorBatch` refreshes every recorded + * thread exactly once when the batch closes. The live path passes no context, + * so `projectEvent` keeps refreshing immediately. + */ +interface ProjectionApplyContext { + readonly deferredThreadShellSummaryIds?: Set; +} + +/** + * Events replayed per bootstrap transaction. Replaying one event per + * transaction makes cold start pay an fsync per event; batching turns a + * multi-thousand-event history into a handful of commits. + */ +const PROJECTION_BOOTSTRAP_BATCH_SIZE = 500; + const materializeAttachmentsForProjection = Effect.fn("materializeAttachmentsForProjection")( (input: { readonly attachments: ReadonlyArray }) => Effect.succeed(input.attachments.length === 0 ? [] : input.attachments), @@ -620,9 +641,25 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); }); + /** + * Refresh a thread's shell summary now, or record it for the end of the + * batch when replaying. Deferring collapses the N stale marks a thread + * accumulates inside one batch into a single recompute. + */ + const refreshOrDeferThreadShellSummary = ( + threadId: ThreadId, + context: ProjectionApplyContext | undefined, + ) => { + if (context?.deferredThreadShellSummaryIds !== undefined) { + context.deferredThreadShellSummaryIds.add(threadId); + return Effect.void; + } + return refreshThreadShellSummary(threadId); + }; + const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn( "applyThreadsProjection", - )(function* (event, attachmentSideEffects) { + )(function* (event, attachmentSideEffects, context) { switch (event.type) { case "thread.created": yield* projectionThreadRepository.upsert({ @@ -913,7 +950,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti updatedAt: event.occurredAt, }); if (shouldRefreshThreadShellSummary(event)) { - yield* refreshThreadShellSummary(event.payload.threadId); + yield* refreshOrDeferThreadShellSummary(event.payload.threadId, context); } return; } @@ -931,7 +968,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti latestTurnId: event.payload.session.activeTurnId ?? existingRow.value.latestTurnId, updatedAt: event.occurredAt, }); - yield* refreshThreadShellSummary(event.payload.threadId); + yield* refreshOrDeferThreadShellSummary(event.payload.threadId, context); return; } @@ -947,7 +984,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti latestTurnId: event.payload.turnId, updatedAt: event.occurredAt, }); - yield* refreshThreadShellSummary(event.payload.threadId); + yield* refreshOrDeferThreadShellSummary(event.payload.threadId, context); return; } @@ -985,7 +1022,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti latestTurnId, updatedAt: event.occurredAt, }); - yield* refreshThreadShellSummary(event.payload.threadId); + yield* refreshOrDeferThreadShellSummary(event.payload.threadId, context); return; } @@ -1780,6 +1817,61 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ); }); + /** + * Bootstrap counterpart to `runProjectorForEvent`: one transaction for a + * whole batch, with every thread shell summary the batch dirtied refreshed + * once at the end and a single projection-state upsert for the batch's last + * event. Attachment side-effects still run outside the transaction. + */ + const runProjectorBatch = Effect.fn("runProjectorBatch")(function* ( + projector: ProjectorDefinition, + events: ReadonlyArray, + ) { + const lastEvent = events.at(-1); + if (lastEvent === undefined) { + return; + } + + const attachmentSideEffects: AttachmentSideEffects = { + deletedThreadIds: new Set(), + prunedThreadRelativePaths: new Map>(), + }; + const deferredThreadShellSummaryIds = new Set(); + const context: ProjectionApplyContext = { deferredThreadShellSummaryIds }; + + yield* sql.withTransaction( + Effect.forEach(events, (event) => projector.apply(event, attachmentSideEffects, context), { + concurrency: 1, + discard: true, + }).pipe( + Effect.andThen( + Effect.forEach(deferredThreadShellSummaryIds, refreshThreadShellSummary, { + concurrency: 1, + discard: true, + }), + ), + Effect.andThen( + projectionStateRepository.upsert({ + projector: projector.name, + lastAppliedSequence: lastEvent.sequence, + updatedAt: lastEvent.occurredAt, + }), + ), + ), + ); + + yield* runAttachmentSideEffects(attachmentSideEffects).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to apply projected attachment side-effects", { + projector: projector.name, + sequence: lastEvent.sequence, + eventType: lastEvent.type, + cause, + }), + ), + ); + }); + const bootstrapProjector = (projector: ProjectorDefinition) => projectionStateRepository .getByProjector({ @@ -1787,13 +1879,23 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }) .pipe( Effect.flatMap((stateRow) => - Stream.runForEach( - eventStore.readFromSequence( - Option.isSome(stateRow) ? stateRow.value.lastAppliedSequence : 0, - Number.MAX_SAFE_INTEGER, - ), - (event) => runProjectorForEvent(projector, event), - ), + Effect.gen(function* () { + let cursor = Option.isSome(stateRow) ? stateRow.value.lastAppliedSequence : 0; + while (true) { + const events = yield* eventStore + .readFromSequence(cursor, PROJECTION_BOOTSTRAP_BATCH_SIZE) + .pipe( + Stream.runCollect, + Effect.map((chunk) => Array.from(chunk)), + ); + const lastEvent = events.at(-1); + if (lastEvent === undefined) { + return; + } + yield* runProjectorBatch(projector, events); + cursor = lastEvent.sequence; + } + }), ), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.turbo.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.turbo.test.ts new file mode 100644 index 000000000000..ed056eac9e02 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.turbo.test.ts @@ -0,0 +1,258 @@ +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; +import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; + +/** + * Fork seam: batched projection bootstrap. + * + * Upstream replays cold-start history one event per transaction and refreshes a + * thread's shell summary inline on every event that dirties it. T3 Turbo keeps + * upstream's live path and its `shouldRefreshThreadShellSummary` gate, but + * replays history in batches of `PROJECTION_BOOTSTRAP_BATCH_SIZE` (500): one + * transaction per batch, shell-summary refreshes deferred to the end of the + * batch and deduped per thread, one projection-state upsert per batch. + * + * These tests pin the two properties that make that safe and fast, so a future + * upstream ingest cannot quietly flatten the batching back to per-event. + */ + +const BATCH_SIZE = 500; +const THREAD_COUNT = 3; +const ACTIVITY_EVENT_COUNT = 1200; + +const TurboTestLayer = OrchestrationProjectionPipelineLive.pipe( + Layer.provideMerge(OrchestrationEventStoreLive), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-projection-pipeline-turbo-test-" }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), +); + +const threadIdAt = (index: number) => ThreadId.make(`thread-${index}`); + +/** + * Fixed-width ISO stamps built by hand: the fixture needs a few thousand + * strictly increasing timestamps and the repo's Effect lint bans `Date` here. + * The offset stays well inside one hour, so only seconds and millis move. + */ +const isoAt = (offsetMs: number) => { + const seconds = Math.floor(offsetMs / 1000); + const minutes = Math.floor(seconds / 60); + const pad = (value: number, width: number) => String(value).padStart(width, "0"); + return `2026-01-01T00:${pad(minutes, 2)}:${pad(seconds % 60, 2)}.${pad(offsetMs % 1000, 3)}Z`; +}; + +it.layer(TurboTestLayer)("OrchestrationProjectionPipeline (Turbo batched bootstrap)", (it) => { + it.effect("replays history in batched transactions with deduped shell refreshes", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = isoAt(0); + + yield* eventStore.append({ + type: "project.created", + eventId: EventId.make("evt-project"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-1"), + occurredAt: now, + commandId: CommandId.make("cmd-project"), + causationEventId: null, + correlationId: CommandId.make("cmd-project"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-1"), + title: "Project 1", + workspaceRoot: "/tmp/project-1", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + for (let index = 0; index < THREAD_COUNT; index += 1) { + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.make(`evt-thread-${index}`), + aggregateKind: "thread", + aggregateId: threadIdAt(index), + occurredAt: now, + commandId: CommandId.make(`cmd-thread-${index}`), + causationEventId: null, + correlationId: CommandId.make(`cmd-thread-${index}`), + metadata: {}, + payload: { + threadId: threadIdAt(index), + projectId: ProjectId.make("project-1"), + title: `Thread ${index}`, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + } + + // `approval.requested` is one of the kinds upstream's gate says DOES + // dirty the shell summary, so per-event replay would recompute the + // summary once per event here. Batching must collapse that. + for (let index = 0; index < ACTIVITY_EVENT_COUNT; index += 1) { + const threadId = threadIdAt(index % THREAD_COUNT); + const occurredAt = isoAt(index + 1); + yield* eventStore.append({ + type: "thread.activity-appended", + eventId: EventId.make(`evt-activity-${index}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt, + commandId: CommandId.make(`cmd-activity-${index}`), + causationEventId: null, + correlationId: CommandId.make(`cmd-activity-${index}`), + metadata: {}, + payload: { + threadId, + activity: { + id: EventId.make(`activity-${index}`), + tone: "tool", + kind: "approval.requested", + summary: "Approval requested", + payload: {}, + turnId: null, + createdAt: occurredAt, + }, + }, + }); + } + + const totalEvents = 1 + THREAD_COUNT + ACTIVITY_EVENT_COUNT; + const expectedBatches = Math.ceil(totalEvents / BATCH_SIZE); + assert.isAbove(expectedBatches, 1, "the fixture must span more than one batch"); + + yield* sql`CREATE TABLE turbo_probe (label TEXT NOT NULL)`; + yield* sql` + CREATE TRIGGER turbo_count_threads_state_commits + AFTER INSERT ON projection_state + WHEN NEW.projector = 'projection.threads' + BEGIN + INSERT INTO turbo_probe (label) VALUES ('state'); + END; + `; + yield* sql` + CREATE TRIGGER turbo_count_threads_state_updates + AFTER UPDATE ON projection_state + WHEN NEW.projector = 'projection.threads' + BEGIN + INSERT INTO turbo_probe (label) VALUES ('state'); + END; + `; + yield* sql` + CREATE TRIGGER turbo_count_thread_row_writes + AFTER UPDATE ON projection_threads + BEGIN + INSERT INTO turbo_probe (label) VALUES ('thread'); + END; + `; + + yield* projectionPipeline.bootstrap; + + const countProbe = (label: string) => + sql<{ + readonly count: number; + }>`SELECT COUNT(*) AS "count" FROM turbo_probe WHERE label = ${label}`.pipe( + Effect.map((rows) => rows[0]?.count ?? 0), + ); + + // One projection-state write per batch, not one per event. + const stateWrites = yield* countProbe("state"); + assert.strictEqual( + stateWrites, + expectedBatches, + "the threads projector should commit once per batch", + ); + + // Each activity event still stamps `updatedAt` on its thread row. On top + // of that, batching allows at most one deferred shell refresh per thread + // per batch; per-event replay would add one per event. + const threadRowWrites = yield* countProbe("thread"); + assert.isAtLeast(threadRowWrites, ACTIVITY_EVENT_COUNT); + assert.isAtMost( + threadRowWrites, + ACTIVITY_EVENT_COUNT + expectedBatches * THREAD_COUNT, + "shell summaries must refresh at most once per thread per batch", + ); + + yield* sql`DELETE FROM turbo_probe`; + + // The live path is untouched: a single projected event refreshes the + // shell summary immediately, inside the same call. + const liveOccurredAt = isoAt(ACTIVITY_EVENT_COUNT + 10); + const liveThreadId = threadIdAt(0); + const liveEvent = yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make("evt-live-user-message"), + aggregateKind: "thread", + aggregateId: liveThreadId, + occurredAt: liveOccurredAt, + commandId: CommandId.make("cmd-live-user-message"), + causationEventId: null, + correlationId: CommandId.make("cmd-live-user-message"), + metadata: {}, + payload: { + threadId: liveThreadId, + messageId: MessageId.make("message-live"), + role: "user", + text: "hello", + turnId: null, + streaming: false, + createdAt: liveOccurredAt, + updatedAt: liveOccurredAt, + }, + }); + yield* projectionPipeline.projectEvent(liveEvent); + + // `updatedAt` stamp + the immediate shell refresh. + assert.strictEqual(yield* countProbe("thread"), 2); + const latestUserMessageAt = yield* sql<{ + readonly latestUserMessageAt: string | null; + }>` + SELECT latest_user_message_at AS "latestUserMessageAt" + FROM projection_threads + WHERE thread_id = ${liveThreadId} + `.pipe(Effect.map((rows) => rows[0]?.latestUserMessageAt ?? null)); + assert.strictEqual( + latestUserMessageAt, + liveOccurredAt, + "the live path must refresh the shell summary without waiting for a batch", + ); + + yield* sql`DROP TRIGGER turbo_count_thread_row_writes`; + yield* sql`DROP TRIGGER turbo_count_threads_state_updates`; + yield* sql`DROP TRIGGER turbo_count_threads_state_commits`; + yield* sql`DROP TABLE turbo_probe`; + }), + ); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 8427d2253a41..c14e670f458d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.47", + "version": "0.0.48", "private": true, "type": "module", "scripts": { diff --git a/docs/operations/turbo-changelog.md b/docs/operations/turbo-changelog.md index 40ea997bbdda..0cfada827e70 100644 --- a/docs/operations/turbo-changelog.md +++ b/docs/operations/turbo-changelog.md @@ -8,6 +8,14 @@ per-commit — the ingestion PR entry records the upstream range instead. ## Unreleased — on `turbo`, not yet in a shipped build +- **Cold start replays projection history in batches again (0.0.48).** The fork's batched projection + bootstrap was silently lost in the 0.0.45 ingest, because it had never been registered as a seam + and upstream's `ProjectionPipeline.test.ts` asserts exact shell-update counts. Restored on top of + upstream's current pipeline: the live path and upstream's `shouldRefreshThreadShellSummary` gate + are untouched, while bootstrap replays 500 events per `sql.withTransaction`, defers each thread's + shell-summary refresh to the end of its batch (once per thread, not once per event), and writes + one projection-state row per batch. New seam `batched-projection-bootstrap`, pinned by + `apps/server/src/orchestration/Layers/ProjectionPipeline.turbo.test.ts`. - **Un-settling a thread now sticks, and a merged PR no longer buries live follow-up work.** The keep-active pin survives messages, session starts, and approval/input requests — only an explicit settle spends it (activity still wakes explicitly _settled_ threads). And a merged or diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 5eae3c69b823..a8145483064d 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.47", + "version": "0.0.48", "private": true, "files": [ "dist" diff --git a/scripts/turbo-customization-manifest.test.ts b/scripts/turbo-customization-manifest.test.ts index 34269090287f..a1f35b68f734 100644 --- a/scripts/turbo-customization-manifest.test.ts +++ b/scripts/turbo-customization-manifest.test.ts @@ -142,6 +142,7 @@ it("verifies the checked-in Turbo manifest and tracks the implemented multi-chat ]); assert.deepStrictEqual(result.manifest.seams.map((seam) => seam.id).sort(), [ "agent-docs-operating-model", + "batched-projection-bootstrap", "canonical-icon-pipeline", "changelog-and-runbook", "cheap-message-unpacking",