History

fix: reconcile main's round-18 features after the rebase

Adopts the round's main features into the v2 architecture: the #9023 media rework (streamed videos, media-file assets, protocol-relative links), #9098 shared live-activity row folded into the v2 working and thinking rows, the #9084/#9078 Claude model catalog for v2 consumers, a native #9005 OpenCode child-session abort in the v2 adapter, #9013's landed LegendList patch, and per-environment sidebar provider entries. For #8600 the server-side pieces land, but auto-settle evaluation stays client-side (reading the new server-owned settings) until the v2 orchestrator grows its own settlement reactor; main's v1-only reactor and coalescer additions are dropped with the rest of the v1 path.

Julius Marminge committed 2 months ago

Commit 7b13bca1 parent 786a25847 files · +1855 −2616Browse files

Showing the changes to apps/server/src (17 of 47 files). Show all

Unified · Split

17 files
  1. apps/server/src/claudeModelOptions.ts +14 −11
  2. apps/server/src/http.test.ts +177 −3
  3. apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +7 −2
  4. apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +30 −0
  5. apps/server/src/orchestration/Layers/OrchestrationEngine.ts +0 −33
  6. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +10 −23
  7. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +70 −258
  8. apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +0 −4
  9. apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts +0 −250
  10. apps/server/src/orchestration/ThreadLiveEventCoalescer.ts +0 −241
  11. apps/server/src/orchestration/ThreadSettlementReactor.test.ts +0 −858
  12. apps/server/src/orchestration/ThreadSettlementReactor.ts +0 −249
  13. apps/server/src/orchestration/decider.ts +1 −0
  14. apps/server/src/persistence/Layers/OrchestrationEventStore.ts +11 −10
  15. apps/server/src/persistence/Services/OrchestrationEventStore.ts +3 −2
  16. apps/server/src/provider/Drivers/ClaudeDriver.ts +24 −19
  17. apps/server/src/provider/acp/AcpNativeLogging.ts +44 −6
apps/server/src/claudeModelOptions.ts+14−11
@@ -7,12 +7,14 @@ import { · 6 unmodified lines
77 } from "@t3tools/shared/model";
88
99 import {
10− getClaudeModelCapabilities,
11− isClaudeUltracodeEffort,
12− normalizeClaudeCliEffort,
13− resolveClaudeApiModelId,
14− resolveClaudeEffort,
15−} from "./provider/Layers/ClaudeProvider.ts";
10+ BUNDLED_CLAUDE_MODEL_CATALOG,
11+ getClaudeCatalogModelCapabilities,
12+ isClaudeCatalogUltracodeEffort,
13+ normalizeClaudeCatalogEffort,
14+ resolveClaudeCatalogApiModelId,
15+ resolveClaudeCatalogEffort,
16+ type ClaudeModelCatalog,
17+} from "./provider/ClaudeModelCatalog.ts";
1618
1719 export interface CompiledClaudeModelSelection {
1820 readonly apiModelId: string;
@@ -25,14 +27,15 @@ export interface CompiledClaudeModelSelection { · 6 unmodified lines
2527 /** Compile every Claude model option at the provider boundary. */
2628 export function compileClaudeModelSelection(
2729 selection: ModelSelection,
30+ catalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG,
2831 ): CompiledClaudeModelSelection {
29− const capabilities = getClaudeModelCapabilities(selection.model);
32+ const capabilities = getClaudeCatalogModelCapabilities(catalog, selection.model);
3033 const descriptors = getProviderOptionDescriptors({ caps: capabilities });
3134 const supportsBoolean = (id: string) =>
3235 descriptors.some((descriptor) => descriptor.type === "boolean" && descriptor.id === id);
3336 const rawEffort = getModelSelectionStringOptionValue(selection, "effort");
34− const resolvedEffort = resolveClaudeEffort(capabilities, rawEffort);
35− const effort = normalizeClaudeCliEffort(resolvedEffort, selection.model);
37+ const resolvedEffort = resolveClaudeCatalogEffort(catalog, selection.model, rawEffort);
38+ const effort = normalizeClaudeCatalogEffort(catalog, resolvedEffort, selection.model);
3639 const fastMode = supportsBoolean("fastMode")
3740 ? getModelSelectionBooleanOptionValue(selection, "fastMode")
3841 : undefined;
@@ -42,9 +45,9 @@ export function compileClaudeModelSelection( · 3 unmodified lines
4245 const settings = {
4346 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
4447 ...(typeof fastMode === "boolean" ? { fastMode } : {}),
45− ...(isClaudeUltracodeEffort(resolvedEffort) ? { ultracode: true } : {}),
48+ ...(isClaudeCatalogUltracodeEffort(resolvedEffort) ? { ultracode: true } : {}),
4649 };
47− const apiModelId = resolveClaudeApiModelId(selection);
50+ const apiModelId = resolveClaudeCatalogApiModelId(catalog, selection);
4851 const promptEffort = resolvePromptInjectedEffort(capabilities, rawEffort) ?? undefined;
4952 return {
5053 apiModelId,
apps/server/src/http.test.ts+177−3
@@ -1,12 +1,13 @@
11 import { expect, it } from "@effect/vitest";
2+import { describe, vi } from "vite-plus/test";
3+import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
4+import * as NodeServices from "@effect/platform-node/NodeServices";
25 import * as Effect from "effect/Effect";
36 import * as FileSystem from "effect/FileSystem";
47 import * as Layer from "effect/Layer";
58 import * as Path from "effect/Path";
69 import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
7−import { describe } from "vite-plus/test";
8−import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
9−import * as NodeServices from "@effect/platform-node/NodeServices";
10+import { openMediaFile } from "./assets/MediaFile.ts";
1011
1112 import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts";
1213
@@ -70,6 +71,179 @@ describe("browser API CORS", () => { · 57 unmodified lines
7071 const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer);
7172
7273 describe("video asset byte ranges", () => {
74+ it.effect("uses current descriptor metadata after an in-place truncate or extension", () =>
75+ Effect.gen(function* () {
76+ const fs = yield* FileSystem.FileSystem;
77+ const path = yield* Path.Path;
78+ const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-current-stat-" });
79+ const filePath = path.join(directory, "clip.mp4");
80+ for (const [contents, range, method, expected, status, contentRange] of [
81+ ["1234", undefined, "GET", "1234", 200, null],
82+ ["0123456789abcdef", undefined, "GET", "0123456789abcdef", 200, null],
83+ ["1234", "bytes=4-", "GET", "", 416, "bytes */4"],
84+ ["1234", "bytes=1-20", "GET", "234", 206, "bytes 1-3/4"],
85+ ["0123456789abcdef", "bytes=10-", "GET", "abcdef", 206, "bytes 10-15/16"],
86+ ["0123456789abcdef", undefined, "HEAD", "", 200, null],
87+ ["", undefined, "GET", "", 200, null],
88+ ["", "bytes=0-1", "GET", "", 416, "bytes */0"],
89+ ] as const) {
90+ yield* fs.writeFileString(filePath, "0123456789");
91+ const canonicalPath = yield* fs.realPath(filePath);
92+ const file = yield* openMediaFile(canonicalPath);
93+ if (!file) throw new Error("Expected an opened media file");
94+ yield* fs.writeFileString(filePath, contents);
95+ const response = HttpServerResponse.toWeb(
96+ yield* assetFileResponse(
97+ { path: canonicalPath, file, mimeType: "video/mp4" },
98+ range,
99+ undefined,
100+ method,
101+ ),
102+ );
103+ expect(response.status).toBe(status);
104+ expect(response.headers.get("content-range")).toBe(contentRange);
105+ if (status !== 416) {
106+ expect(response.headers.get("content-length")).toBe(
107+ String(method === "HEAD" ? contents.length : expected.length),
108+ );
109+ }
110+ expect(yield* Effect.promise(() => response.text())).toBe(expected);
111+ }
112+ }).pipe(Effect.provide(fileResponseLayer)),
113+ );
114+
115+ it.effect(
116+ "rejects unaddressable ranges before streaming and preserves small ranges on large files",
117+ () =>
118+ Effect.gen(function* () {
119+ const fs = yield* FileSystem.FileSystem;
120+ const path = yield* Path.Path;
121+ const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-offset-limit-" });
122+ const filePath = path.join(directory, "clip.mp4");
123+ yield* fs.writeFileString(filePath, "0123456789");
124+ const canonicalPath = yield* fs.realPath(filePath);
125+ const unsafeOffset = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
126+ const size = unsafeOffset + 32n;
127+ for (const [range, status] of [
128+ [`bytes=${unsafeOffset}-${unsafeOffset}`, 416],
129+ [`bytes=0-${unsafeOffset}`, 416],
130+ ["bytes=-1", 416],
131+ [undefined, 413],
132+ ["bytes=0-1", 206],
133+ ] as const) {
134+ const file = yield* openMediaFile(canonicalPath);
135+ if (!file) throw new Error("Expected an opened media file");
136+ // Model a sparse file beyond the native stream's numeric addressing limit.
137+ const info = yield* Effect.promise(() => file.handle.stat({ bigint: true }));
138+ info.size = size;
139+ const statSpy = vi.spyOn(file.handle, "stat").mockResolvedValue(info);
140+ yield* Effect.addFinalizer(() => Effect.sync(() => statSpy.mockRestore()));
141+ const response = HttpServerResponse.toWeb(
142+ yield* assetFileResponse({ path: canonicalPath, file, mimeType: "video/mp4" }, range),
143+ );
144+ expect(response.status).toBe(status);
145+ if (status === 416) {
146+ expect(response.headers.get("content-range")).toBe(`bytes */${size}`);
147+ expect(yield* Effect.promise(() => response.text())).toBe("");
148+ } else if (status === 206) {
149+ expect(response.headers.get("content-range")).toBe(`bytes 0-1/${size}`);
150+ expect(yield* Effect.promise(() => response.text())).toBe("01");
151+ } else {
152+ expect(yield* Effect.promise(() => response.text())).toBe(
153+ "File is too large to preview.",
154+ );
155+ }
156+ }
157+ }).pipe(Effect.provide(fileResponseLayer)),
158+ );
159+
160+ it.effect("streams guarded file ranges, including suffixes and conditional requests", () =>
161+ Effect.gen(function* () {
162+ const fs = yield* FileSystem.FileSystem;
163+ const path = yield* Path.Path;
164+ const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-range-" });
165+ const filePath = path.join(directory, "clip.mp4");
166+ yield* fs.writeFileString(filePath, "0123456789");
167+ const canonicalPath = yield* fs.realPath(filePath);
168+ for (const [range, ifRange, expected, status, contentRange] of [
169+ [undefined, undefined, "0123456789", 200, null],
170+ ["bytes=0-1", undefined, "01", 206, "bytes 0-1/10"],
171+ ["bytes=4-", undefined, "456789", 206, "bytes 4-9/10"],
172+ ["bytes=-3", undefined, "789", 206, "bytes 7-9/10"],
173+ ["bytes=-999999999999999999999999", undefined, "0123456789", 206, "bytes 0-9/10"],
174+ ["bytes=10-", undefined, "", 416, "bytes */10"],
175+ ["bytes=0-1", '"old-etag"', "0123456789", 200, null],
176+ ["bytes=0-1", "", "0123456789", 200, null],
177+ ] as const) {
178+ const file = yield* openMediaFile(canonicalPath);
179+ if (!file) throw new Error("Expected an opened media file");
180+ const response = HttpServerResponse.toWeb(
181+ yield* assetFileResponse(
182+ { path: canonicalPath, file, mimeType: "video/mp4" },
183+ range,
184+ ifRange,
185+ ),
186+ );
187+ expect(response.status).toBe(status);
188+ expect(response.headers.get("accept-ranges")).toBe("bytes");
189+ expect(response.headers.get("content-range")).toBe(contentRange);
190+ expect(response.headers.get("cache-control")).toBe("private, no-store");
191+ expect(response.headers.get("etag")).toBeNull();
192+ expect(response.headers.get("last-modified")).toBeNull();
193+ if (status !== 416)
194+ expect(response.headers.get("content-length")).toBe(String(expected.length));
195+ expect(yield* Effect.promise(() => response.text())).toBe(expected);
196+ }
197+ }).pipe(Effect.provide(fileResponseLayer)),
198+ );
199+
200+ it.effect("closes guarded descriptors after full, HEAD, rejected, and cancelled responses", () =>
201+ Effect.gen(function* () {
202+ const fs = yield* FileSystem.FileSystem;
203+ const path = yield* Path.Path;
204+ const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-cleanup-" });
205+ const filePath = path.join(directory, "clip.mp4");
206+ const bytes = new Uint8Array(1024 * 1024).fill(42);
207+ yield* fs.writeFile(filePath, bytes);
208+ const canonicalPath = yield* fs.realPath(filePath);
209+ for (const mode of ["full", "HEAD", "rejected", "cancelled"] as const) {
210+ const file = yield* Effect.scoped(
211+ Effect.gen(function* () {
212+ const file = yield* openMediaFile(canonicalPath);
213+ if (!file) throw new Error("Expected an opened media file");
214+ const response = HttpServerResponse.toWeb(
215+ yield* assetFileResponse(
216+ { path: canonicalPath, file, mimeType: "video/mp4" },
217+ mode === "rejected" ? `bytes=${bytes.length}-` : "bytes=0-",
218+ undefined,
219+ mode === "HEAD" ? "HEAD" : "GET",
220+ ),
221+ );
222+ if (mode === "HEAD") {
223+ expect(response.status).toBe(200);
224+ expect(response.headers.get("content-length")).toBe(String(bytes.length));
225+ expect(response.headers.get("content-range")).toBeNull();
226+ expect(yield* Effect.promise(() => response.text())).toBe("");
227+ } else if (mode === "rejected") {
228+ expect(response.status).toBe(416);
229+ expect(yield* Effect.promise(() => response.text())).toBe("");
230+ } else if (mode === "cancelled") {
231+ const reader = response.body!.getReader();
232+ const first = yield* Effect.promise(() => reader.read());
233+ expect(first.done).toBe(false);
234+ expect(first.value!.byteLength).toBeLessThan(bytes.length);
235+ yield* Effect.promise(() => reader.cancel());
236+ } else {
237+ expect(yield* Effect.promise(() => response.arrayBuffer())).toEqual(bytes.buffer);
238+ }
239+ return file;
240+ }),
241+ );
242+ expect(file.handle.fd).toBe(-1);
243+ }
244+ }).pipe(Effect.provide(fileResponseLayer)),
245+ );
246+
73247 it.effect("streams exactly the requested bytes and leaves full downloads intact", () =>
74248 Effect.gen(function* () {
75249 const fs = yield* FileSystem.FileSystem;
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+7−2
@@ -77,7 +77,10 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; · 76 unmodified lines
7777 import { compileClaudeModelSelection } from "../../claudeModelOptions.ts";
7878 import { ServerConfig } from "../../config.ts";
7979 import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts";
80−import { resolveClaudeContextWindow } from "../../provider/Layers/ClaudeProvider.ts";
80+import {
81+ BUNDLED_CLAUDE_MODEL_CATALOG,
82+ resolveClaudeCatalogContextWindow,
83+} from "../../provider/ClaudeModelCatalog.ts";
8184 import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";
8285 import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts";
8386 import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
@@ -134,7 +137,9 @@ function claudeContextWindow(modelSelection: ModelSelection): number | null { · 50 unmodified lines
134137 if (modelSelection.model === "claude-opus-4-6" || modelSelection.model === "claude-opus-4-7") {
135138 return 1_000_000;
136139 }
137− return resolveClaudeContextWindow(modelSelection) === "1m" ? 1_000_000 : 200_000;
140+ return resolveClaudeCatalogContextWindow(BUNDLED_CLAUDE_MODEL_CATALOG, modelSelection) === "1m"
141+ ? 1_000_000
142+ : 200_000;
138143 }
139144
140145 export function claudeProviderTurnTokenUsage(
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts+30−0
@@ -3027,6 +3027,36 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 3026 unmodified lines
30273027 }),
30283028 ),
30293029 );
3030+ // Child agents run in their own sessions and outlive a root
3031+ // abort (#9005). Best-effort: walk session.children and abort
3032+ // each descendant; the root abort above already settled the
3033+ // interrupt contract.
3034+ yield* Effect.gen(function* () {
3035+ const visited = new Set([sessionId]);
3036+ const abortDescendants = (parentId: string): Effect.Effect<void> =>
3037+ Effect.gen(function* () {
3038+ const children = yield* sdkCall(
3039+ "session.children",
3040+ { sessionID: parentId },
3041+ () => client.session.children({ sessionID: parentId }),
3042+ ).pipe(Effect.option);
3043+ const rows = Option.isSome(children) ? (children.value.data ?? []) : [];
3044+ const fresh = rows.filter((child) => {
3045+ if (visited.has(child.id)) return false;
3046+ visited.add(child.id);
3047+ return true;
3048+ });
3049+ yield* Effect.forEach(
3050+ fresh,
3051+ (child) =>
3052+ sdkCall("session.abort", { sessionID: child.id }, () =>
3053+ client.session.abort({ sessionID: child.id }),
3054+ ).pipe(Effect.ignore, Effect.andThen(abortDescendants(child.id))),
3055+ { concurrency: 8, discard: true },
3056+ );
3057+ });
3058+ yield* abortDescendants(sessionId);
3059+ }).pipe(Effect.timeout("15 seconds"), Effect.ignore);
30303060 }).pipe(
30313061 Effect.mapError(
30323062 (cause) =>
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+0−33
@@ -166,42 +166,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 165 unmodified lines
166166 });
167167 }
168168
169− if (
170− envelope.command.type === "thread.auto-settle" &&
171− (yield* eventStore.hasEventAfter({
172− aggregateKind: "thread",
173− aggregateId: envelope.command.threadId,
174− sequenceExclusive: envelope.command.snapshotSequence,
175− }))
176− ) {
177− return yield* new OrchestrationCommandInvariantError({
178− commandType: envelope.command.type,
179− detail: `thread ${envelope.command.threadId} changed before automatic settlement`,
180− });
181− }
182−
183− if (
184− envelope.command.type === "thread.auto-settle" &&
185− threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null
186− ) {
187− return yield* new OrchestrationCommandInvariantError({
188− commandType: envelope.command.type,
189− detail: `thread ${envelope.command.threadId} has live background work`,
190− });
191− }
192−
193− // Command snapshots omit activities at startup and cap them while running.
194− // Read this request's durable state before deciding how to send the answer.
195− const userInputActivity =
196− envelope.command.type === "thread.user-input.respond"
197− ? yield* projectionSnapshotQuery.getUserInputActivity(envelope.command)
198− : Option.none();
199169 const eventBase = yield* decideOrchestrationCommand({
200170 command: envelope.command,
201171 readModel: commandReadModel,
202− ...(Option.isSome(userInputActivity)
203− ? { userInputActivity: userInputActivity.value }
204− : {}),
205172 }).pipe(
206173 Effect.provideService(Crypto.Crypto, crypto),
207174 Effect.mapError((cause) =>
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+10−23
@@ -1018,34 +1018,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 1017 unmodified lines
10181018 return;
10191019
10201020 case "thread.message-sent": {
1021− if (event.payload.streaming) {
1022− const attachments =
1023− event.payload.attachments !== undefined
1024− ? yield* materializeAttachmentsForProjection({
1025− attachments: event.payload.attachments,
1026− })
1027− : undefined;
1028− yield* projectionThreadMessageRepository.appendStreaming({
1029− messageId: event.payload.messageId,
1030− threadId: event.payload.threadId,
1031− turnId: event.payload.turnId,
1032− role: event.payload.role,
1033− text: event.payload.text,
1034− ...(attachments !== undefined ? { attachments: [...attachments] } : {}),
1035− createdAt: event.payload.createdAt,
1036− updatedAt: event.payload.updatedAt,
1037− });
1038− return;
1039− }
1040−
10411021 const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({
10421022 messageId: event.payload.messageId,
10431023 });
10441024 const previousMessage = Option.getOrUndefined(existingMessage);
10451025 const nextText = Option.match(existingMessage, {
10461026 onNone: () => event.payload.text,
1047− onSome: (message) =>
1048− event.payload.text.length === 0 ? message.text : event.payload.text,
1027+ onSome: (message) => {
1028+ if (event.payload.streaming) {
1029+ return `${message.text}${event.payload.text}`;
1030+ }
1031+ if (event.payload.text.length === 0) {
1032+ return message.text;
1033+ }
1034+ return event.payload.text;
1035+ },
10491036 });
10501037 const nextAttachments =
10511038 event.payload.attachments !== undefined
@@ -1060,7 +1047,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 8 unmodified lines
10601047 role: event.payload.role,
10611048 text: nextText,
10621049 ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}),
1063− isStreaming: false,
1050+ isStreaming: event.payload.streaming,
10641051 createdAt: previousMessage?.createdAt ?? event.payload.createdAt,
10651052 updatedAt: event.payload.updatedAt,
10661053 });
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+70−258
@@ -77,9 +77,6 @@ const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); · 76 unmodified lines
7777 // activity window. Applying the limit in SQL avoids decoding an unbounded
7878 // payload_json set before the projector can enforce that invariant.
7979 const THREAD_DETAIL_ACTIVITY_LIMIT = 500;
80−// Snapshot payloads are decoded and projected in small sequential batches so
81−// one client read does not retain the raw payloads for the full activity window.
82−const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25;
8380 const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
8481 Struct.assign({
8582 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
@@ -104,9 +101,6 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( · 18 unmodified lines
104101 sequence: Schema.NullOr(NonNegativeInt),
105102 }),
106103 );
107−const ProjectionThreadActivityIdRowSchema = Schema.Struct({
108− activityId: ProjectionThreadActivity.fields.activityId,
109−});
110104 const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;
111105 const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({
112106 id: ThreadId,
@@ -166,9 +160,6 @@ const ThreadActivityKindsLookupInput = Schema.Struct({ · 53 unmodified lines
166160 threadId: ThreadId,
167161 activityKinds: Schema.Array(Schema.String),
168162 });
169−const ThreadActivityIdsLookupInput = Schema.Struct({
170− activityIds: Schema.Array(ProjectionThreadActivity.fields.activityId),
171−});
172163 // Windowed reads order turns by the stable keyset (anchor, turn key), where
173164 // anchor is requested_at and turn key is
174165 // COALESCE(turn_id, ''). Both are event-derived, so cursors survive the
@@ -372,21 +363,6 @@ function mapProposedPlanRow( · 197 unmodified lines
372363 };
373364 }
374365
375−function mapThreadActivityRow(
376− row: Schema.Schema.Type<typeof ProjectionThreadActivityDbRowSchema>,
377−): OrchestrationThreadActivity {
378− return {
379− id: row.activityId,
380− tone: row.tone,
381− kind: row.kind,
382− summary: row.summary,
383− payload: row.payload,
384− turnId: row.turnId,
385− createdAt: row.createdAt,
386− ...(row.sequence !== null ? { sequence: row.sequence } : {}),
387− };
388−}
389−
390366 function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
391367 return (cause: unknown): ProjectionRepositoryError =>
392368 Schema.isSchemaError(cause)
@@ -1187,44 +1163,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 794 unmodified lines
11871163 `,
11881164 });
11891165
1190− const listThreadActivityIdsByThread = SqlSchema.findAll({
1191− Request: ThreadIdLookupInput,
1192− Result: ProjectionThreadActivityIdRowSchema,
1193− execute: ({ threadId }) =>
1194− sql`
1195− SELECT activity_id AS "activityId"
1196− FROM projection_thread_activities
1197− WHERE thread_id = ${threadId}
1198− ORDER BY
1199− sequence DESC,
1200− created_at DESC,
1201− activity_id DESC
1202− LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT}
1203− `,
1204− });
1205−
1206− const listThreadActivityRowsByIds = SqlSchema.findAll({
1207− Request: ThreadActivityIdsLookupInput,
1208− Result: ProjectionThreadActivityDbRowSchema,
1209− execute: ({ activityIds }) =>
1210− sql`
1211− SELECT
1212− activity_id AS "activityId",
1213− thread_id AS "threadId",
1214− turn_id AS "turnId",
1215− tone,
1216− kind,
1217− summary,
1218− payload_json AS "payload",
1219− sequence,
1220− created_at AS "createdAt"
1221− FROM projection_thread_activities
1222− -- The selectors already scoped these globally unique ids to the
1223− -- thread inside this transaction. Keep this as a primary-key lookup.
1224− WHERE ${sql.in("activity_id", activityIds)}
1225− `,
1226− });
1227−
12281166 const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({
12291167 Request: ThreadActivityKindsLookupInput,
12301168 Result: ProjectionThreadActivityDbRowSchema,
@@ -1475,8 +1413,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 244 unmodified lines
14751413 `,
14761414 });
14771415
1478− const pinnedThreadActivityIdsCte = (threadId: string) => sql`
1479−pending_approval_requests AS (
1416+ // Blocking request payloads must remain available even if they predate the
1417+ // recent activity window. Each CTE returns at most one unresolved row per
1418+ // request, so the merge below stays bounded by actionable work.
1419+ const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({
1420+ Request: ThreadIdLookupInput,
1421+ Result: ProjectionThreadActivityDbRowSchema,
1422+ execute: ({ threadId }) =>
1423+ sql`
1424+ WITH pending_approval_requests AS (
14801425 SELECT request_id, thread_id
14811426 FROM projection_pending_approvals
14821427 WHERE thread_id = ${threadId}
@@ -1540,17 +1485,6 @@ pending_approval_requests AS ( · 57 unmodified lines
15401485 WHERE request_order = 1
15411486 AND kind = 'user-input.requested'
15421487 )
1543− `;
1544−
1545− // Blocking request payloads must remain available even if they predate the
1546− // recent activity window. Each CTE returns at most one unresolved row per
1547− // request, so the merge below stays bounded by actionable work.
1548− const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({
1549− Request: ThreadIdLookupInput,
1550− Result: ProjectionThreadActivityDbRowSchema,
1551− execute: ({ threadId }) =>
1552− sql`
1553− WITH ${pinnedThreadActivityIdsCte(threadId)}
15541488 SELECT
15551489 activity.activity_id AS "activityId",
15561490 activity.thread_id AS "threadId",
@@ -1568,17 +1502,6 @@ pending_approval_requests AS ( · 11 unmodified lines
15681502 `,
15691503 });
15701504
1571− const listPinnedThreadActivityIdsByThread = SqlSchema.findAll({
1572− Request: ThreadIdLookupInput,
1573− Result: ProjectionThreadActivityIdRowSchema,
1574− execute: ({ threadId }) =>
1575− sql`
1576− WITH ${pinnedThreadActivityIdsCte(threadId)}
1577− SELECT activity_id AS "activityId"
1578− FROM pinned_activity_ids
1579− `,
1580− });
1581−
15821505 const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({
15831506 Request: ThreadTurnRangeLookupInput,
15841507 Result: ProjectionThreadActivityDbRowSchema,
@@ -1646,48 +1569,6 @@ pending_approval_requests AS ( · 61 unmodified lines
16461569 `,
16471570 });
16481571
1649− const listThreadActivityIdsByThreadWindow = SqlSchema.findAll({
1650− Request: ThreadTurnRangeLookupInput,
1651− Result: ProjectionThreadActivityIdRowSchema,
1652− execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) =>
1653− sql`
1654− SELECT activity_id AS "activityId"
1655− FROM projection_thread_activities
1656− WHERE thread_id = ${threadId}
1657− AND (
1658− turn_id IN (
1659− SELECT turn_id FROM projection_turns
1660− WHERE thread_id = ${threadId}
1661− AND turn_id IS NOT NULL
1662− AND (
1663− requested_at > ${minAnchorAt}
1664− OR (
1665− requested_at = ${minAnchorAt}
1666− AND turn_id >= ${minTurnKey}
1667− )
1668− )
1669− AND (
1670− requested_at < ${beforeAnchorAt}
1671− OR (
1672− requested_at = ${beforeAnchorAt}
1673− AND turn_id < ${beforeTurnKey}
1674− )
1675− )
1676− )
1677− OR (
1678− turn_id IS NULL
1679− AND created_at >= ${minAnchorAt}
1680− AND created_at < ${beforeAnchorAt}
1681− )
1682− )
1683− ORDER BY
1684− sequence DESC,
1685− created_at DESC,
1686− activity_id DESC
1687− LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT}
1688− `,
1689− });
1690−
16911572 const getFullThreadDiffContextRow = SqlSchema.findOneOption({
16921573 Request: FullThreadDiffContextLookupInput,
16931574 Result: ProjectionFullThreadDiffContextRowSchema,
@@ -2832,136 +2713,49 @@ pending_approval_requests AS ( · 1138 unmodified lines
28322713 readonly beforeTurnKey: string;
28332714 }
28342715
2835− type ThreadDetailActivityRead =
2836− | {
2837− readonly mode: "raw";
2838− readonly query?: ProjectionThreadDetailQuery;
2839− }
2840− | {
2841− readonly mode: "client";
2842− };
2843−
2844− const listProjectedThreadActivities = Effect.fn(
2845− "ProjectionSnapshotQuery.listProjectedThreadActivities",
2846− )(function* (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) {
2847− const [activityIdRows, pinnedActivityIdRows] = yield* Effect.all([
2848− (bounds === undefined
2849− ? listThreadActivityIdsByThread({ threadId })
2850− : listThreadActivityIdsByThreadWindow({ threadId, ...bounds })
2716+ const getThreadDetailByIdBounded = (
2717+ threadId: ThreadId,
2718+ bounds: ThreadDetailBounds | undefined,
2719+ query: ProjectionThreadDetailQuery | undefined = undefined,
2720+ ) =>
2721+ Effect.gen(function* () {
2722+ const activityRowsEffect = (
2723+ query?.activityKinds === undefined
2724+ ? bounds === undefined
2725+ ? listThreadActivityRowsByThread({ threadId })
2726+ : listThreadActivityRowsByThreadWindow({ threadId, ...bounds })
2727+ : query.activityKinds.length === 0
2728+ ? Effect.succeed([])
2729+ : listThreadActivityRowsByThreadAndKinds({
2730+ threadId,
2731+ activityKinds: query.activityKinds,
2732+ })
28512733 ).pipe(
28522734 Effect.mapError(
28532735 toPersistenceSqlOrDecodeError(
2854− "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:query",
2855− "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:decodeRows",
2736+ "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query",
2737+ "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows",
28562738 ),
28572739 ),
2858− ),
2859− listPinnedThreadActivityIdsByThread({ threadId }).pipe(
2860− Effect.mapError(
2861− toPersistenceSqlOrDecodeError(
2862− "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:query",
2863− "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:decodeRows",
2864− ),
2865− ),
2866− ),
2867− ]);
2868− const activityIds = [
2869− ...new Set([...activityIdRows, ...pinnedActivityIdRows].map(({ activityId }) => activityId)),
2870− ];
2871− const activities: OrchestrationThreadActivity[] = [];
2872−
2873− for (
2874− let offset = 0;
2875− offset < activityIds.length;
2876− offset += THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE
2877− ) {
2878− const batchIds = activityIds.slice(
2879− offset,
2880− offset + THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE,
28812740 );
2882− const batchRows = yield* listThreadActivityRowsByIds({ activityIds: batchIds }).pipe(
2883− Effect.mapError(
2884− toPersistenceSqlOrDecodeError(
2885− "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:query",
2886− "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:decodeRows",
2887− ),
2888− ),
2889− );
2890− for (const row of batchRows) {
2891− activities.push(projectActivityPayload(mapThreadActivityRow(row)));
2892− }
2893− }
2894−
2895− return activities.toSorted(
2896− (left, right) =>
2897− (left.sequence ?? -1) - (right.sequence ?? -1) ||
2898− left.createdAt.localeCompare(right.createdAt) ||
2899− left.id.localeCompare(right.id),
2900− );
2901− });
2902−
2903− const getThreadDetailByIdBounded = (
2904− threadId: ThreadId,
2905− bounds: ThreadDetailBounds | undefined,
2906− activityRead: ThreadDetailActivityRead = { mode: "raw" },
2907− ) =>
2908− Effect.gen(function* () {
2909− const activitiesEffect =
2910− activityRead.mode === "client"
2911− ? listProjectedThreadActivities(threadId, bounds)
2912− : Effect.all([
2913− (activityRead.query?.activityKinds === undefined
2914− ? bounds === undefined
2915− ? listThreadActivityRowsByThread({ threadId })
2916− : listThreadActivityRowsByThreadWindow({ threadId, ...bounds })
2917− : activityRead.query.activityKinds.length === 0
2918− ? Effect.succeed([])
2919− : listThreadActivityRowsByThreadAndKinds({
2920− threadId,
2921− activityKinds: activityRead.query.activityKinds,
2922− })
2923− ).pipe(
2924− Effect.mapError(
2925− toPersistenceSqlOrDecodeError(
2926− "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query",
2927− "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows",
2928− ),
2741+ const pinnedActivityRowsEffect =
2742+ query?.activityKinds === undefined
2743+ ? listPinnedThreadActivityRowsByThread({ threadId }).pipe(
2744+ Effect.mapError(
2745+ toPersistenceSqlOrDecodeError(
2746+ "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query",
2747+ "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows",
29292748 ),
29302749 ),
2931− activityRead.query?.activityKinds === undefined
2932− ? listPinnedThreadActivityRowsByThread({ threadId }).pipe(
2933− Effect.mapError(
2934− toPersistenceSqlOrDecodeError(
2935− "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query",
2936− "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows",
2937− ),
2938− ),
2939− )
2940− : Effect.succeed([]),
2941− ]).pipe(
2942− Effect.map(([activityRows, pinnedActivityRows]) =>
2943− [
2944− ...new Map(
2945− [...activityRows, ...pinnedActivityRows].map(
2946− (row) => [row.activityId, row] as const,
2947− ),
2948− ).values(),
2949− ]
2950− .toSorted(
2951− (left, right) =>
2952− (left.sequence ?? -1) - (right.sequence ?? -1) ||
2953− left.createdAt.localeCompare(right.createdAt) ||
2954− left.activityId.localeCompare(right.activityId),
2955− )
2956− .map(mapThreadActivityRow),
2957− ),
2958− );
2750+ )
2751+ : Effect.succeed([]);
29592752
29602753 const [
29612754 threadRow,
29622755 messageRows,
29632756 proposedPlanRows,
2964− activities,
2757+ activityRows,
2758+ pinnedActivityRows,
29652759 checkpointRows,
29662760 latestTurnRow,
29672761 sessionRow,
@@ -2993,7 +2787,8 @@ pending_approval_requests AS ( · 25 unmodified lines
29932787 ),
29942788 ),
29952789 ),
2996− activitiesEffect,
2790+ activityRowsEffect,
2791+ pinnedActivityRowsEffect,
29972792 listCheckpointRowsByThread({ threadId }).pipe(
29982793 Effect.mapError(
29992794 toPersistenceSqlOrDecodeError(
@@ -3024,6 +2819,17 @@ pending_approval_requests AS ( · 24 unmodified lines
30242819 return Option.none<OrchestrationThread>();
30252820 }
30262821
2822+ const selectedActivityRows = [
2823+ ...new Map(
2824+ [...activityRows, ...pinnedActivityRows].map((row) => [row.activityId, row] as const),
2825+ ).values(),
2826+ ].toSorted(
2827+ (left, right) =>
2828+ (left.sequence ?? -1) - (right.sequence ?? -1) ||
2829+ left.createdAt.localeCompare(right.createdAt) ||
2830+ left.activityId.localeCompare(right.activityId),
2831+ );
2832+
30272833 const thread = {
30282834 id: threadRow.value.threadId,
30292835 projectId: threadRow.value.projectId,
@@ -3061,7 +2867,21 @@ pending_approval_requests AS ( · 31 unmodified lines
30612867 return message;
30622868 }),
30632869 proposedPlans: proposedPlanRows.map(mapProposedPlanRow),
3064− activities,
2870+ activities: selectedActivityRows.map((row) => {
2871+ const activity = {
2872+ id: row.activityId,
2873+ tone: row.tone,
2874+ kind: row.kind,
2875+ summary: row.summary,
2876+ payload: row.payload,
2877+ turnId: row.turnId,
2878+ createdAt: row.createdAt,
2879+ };
2880+ if (row.sequence !== null) {
2881+ return Object.assign(activity, { sequence: row.sequence });
2882+ }
2883+ return activity;
2884+ }),
30652885 checkpoints: checkpointRows.map((row) => ({
30662886 turnId: row.turnId,
30672887 checkpointTurnCount: row.checkpointTurnCount,
@@ -3086,11 +2906,7 @@ pending_approval_requests AS ( · 18 unmodified lines
30862906 const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (
30872907 threadId,
30882908 query,
3089− ) =>
3090− getThreadDetailByIdBounded(threadId, undefined, {
3091− mode: "raw",
3092− ...(query === undefined ? {} : { query }),
3093− });
2909+ ) => getThreadDetailByIdBounded(threadId, undefined, query);
30942910
30952911 // Bounds pathological fan-out: one user turn that spawned hundreds of
30962912 // subagent turns still pages in bounded chunks, at the cost of splitting the
@@ -3114,9 +2930,7 @@ pending_approval_requests AS ( · 17 unmodified lines
31142930 .withTransaction(
31152931 Effect.gen(function* () {
31162932 if (window?.turnLimit === undefined) {
3117− const thread = yield* getThreadDetailByIdBounded(threadId, undefined, {
3118− mode: "client",
3119− });
2933+ const thread = yield* getThreadDetailById(threadId);
31202934 if (Option.isNone(thread)) {
31212935 return Option.none<OrchestrationThreadDetailSnapshot>();
31222936 }
@@ -3169,9 +2983,7 @@ pending_approval_requests AS ( · 46 unmodified lines
31692983 ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" }
31702984 : undefined;
31712985
3172− const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds, {
3173− mode: "client",
3174− });
2986+ const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds);
31752987 if (Option.isNone(thread)) {
31762988 return Option.none<OrchestrationThreadDetailSnapshot>();
31772989 }
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+0−4
@@ -227,10 +227,6 @@ export interface ProjectionSnapshotQueryShape { · 226 unmodified lines
227227 * response carries `page` metadata (see `OrchestrationThreadDetailWindow`).
228228 * Without a window the full thread is returned with no `page` field —
229229 * pagination is strictly opt-in.
230− *
231− * Activity payloads are projected for clients as they are read in small
232− * sequential batches. Callers still apply the full snapshot projector for
233− * collection-level activity pruning.
234230 */
235231 readonly getThreadDetailSnapshot: (
236232 threadId: ThreadId,
apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts+0−250
@@ -1,250 +0,0 @@
1−import {
2− EventId,
3− MessageId,
4− ThreadId,
5− TurnId,
6− OrchestrationEvent,
7− type OrchestrationThreadActivity,
8−} from "@t3tools/contracts";
9−import { it } from "@effect/vitest";
10−import * as Clock from "effect/Clock";
11−import * as Effect from "effect/Effect";
12−import * as Schema from "effect/Schema";
13−import * as Stream from "effect/Stream";
14−import * as TestClock from "effect/testing/TestClock";
15−import { describe, expect } from "vite-plus/test";
16−
17−import {
18− coalesceLiveToolUpdatedEvents,
19− makeThreadLiveEventCoalescer,
20−} from "./ThreadLiveEventCoalescer.ts";
21−
22−const threadId = ThreadId.make("thread-coalescer-test");
23−const turnId = TurnId.make("turn-coalescer-test");
24−const encodeEvent = Schema.encodeSync(Schema.fromJsonString(OrchestrationEvent));
25−
26−function makeToolActivity(
27− sequence: number,
28− options: {
29− readonly kind?: "tool.updated" | "tool.completed";
30− readonly toolCallId?: string;
31− readonly turnId?: TurnId;
32− } = {},
33−): OrchestrationEvent {
34− const {
35− kind = "tool.updated",
36− toolCallId = "call-edit",
37− turnId: activityTurnId = turnId,
38− } = options;
39− const activity: OrchestrationThreadActivity = {
40− id: EventId.make(`activity-${sequence}`),
41− tone: "tool",
42− kind,
43− summary: "Editing app.ts",
44− payload: {
45− itemType: "file_change",
46− title: "Editing app.ts",
47− data: toolCallId ? { toolCallId } : {},
48− },
49− turnId: activityTurnId,
50− createdAt: "2026-01-01T00:00:01.000Z",
51− };
52− return {
53− sequence,
54− eventId: EventId.make(`event-${sequence}`),
55− aggregateKind: "thread",
56− aggregateId: threadId,
57− occurredAt: "2026-01-01T00:00:01.000Z",
58− commandId: null,
59− causationEventId: null,
60− correlationId: null,
61− metadata: {},
62− type: "thread.activity-appended",
63− payload: { threadId, activity },
64− };
65−}
66−
67−function makeMessage(sequence: number, text = "Still working"): OrchestrationEvent {
68− return {
69− sequence,
70− eventId: EventId.make(`event-${sequence}`),
71− aggregateKind: "thread",
72− aggregateId: threadId,
73− occurredAt: "2026-01-01T00:00:02.000Z",
74− commandId: null,
75− causationEventId: null,
76− correlationId: null,
77− metadata: {},
78− type: "thread.message-sent",
79− payload: {
80− threadId,
81− messageId: MessageId.make(`message-${sequence}`),
82− role: "assistant",
83− text,
84− turnId,
85− streaming: false,
86− createdAt: "2026-01-01T00:00:02.000Z",
87− updatedAt: "2026-01-01T00:00:02.000Z",
88− },
89− };
90−}
91−
92−describe("ThreadLiveEventCoalescer", () => {
93− it("coalesces only calls with a stable toolCallId", () => {
94− const events = [
95− makeToolActivity(1, { toolCallId: "call-a" }),
96− makeToolActivity(2, { toolCallId: "call-b" }),
97− makeToolActivity(3, { toolCallId: "call-a" }),
98− ];
99−
100− expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3]);
101− });
102−
103− it("preserves parallel same-label calls without a stable toolCallId", () => {
104− const events = [
105− makeToolActivity(1, { toolCallId: "" }),
106− makeToolActivity(2, { toolCallId: "" }),
107− makeToolActivity(3, { kind: "tool.completed", toolCallId: "" }),
108− ];
109−
110− expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2, 3]);
111− });
112−
113− it("does not coalesce stable tool calls across turns", () => {
114− const events = [
115− makeToolActivity(1, { turnId: TurnId.make("turn-old") }),
116− makeToolActivity(2, { turnId: TurnId.make("turn-new") }),
117− ];
118−
119− expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2]);
120− });
121−
122− it("flushes a stable update run before a completion boundary", () => {
123− const events = [
124− makeToolActivity(1),
125− makeToolActivity(2),
126− makeToolActivity(3, { kind: "tool.completed" }),
127− makeToolActivity(4),
128− ];
129−
130− expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3, 4]);
131− });
132−
133− it.effect("flushes pending tool updates as soon as an unrelated event arrives", () =>
134− Effect.scoped(
135− Effect.gen(function* () {
136− const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" });
137− const startedAt = yield* Clock.currentTimeMillis;
138− yield* Effect.forEach(
139− Array.from({ length: 10 }, (_, index) => index + 2),
140− (sequence) => coalescer.offer({ kind: "event", event: makeToolActivity(sequence) }),
141− { discard: true },
142− );
143− yield* coalescer.offer({ kind: "event", event: makeMessage(12) });
144−
145− expect(yield* Clock.currentTimeMillis).toBe(startedAt);
146− expect(
147− (yield* coalescer.stream.pipe(Stream.take(2), Stream.runCollect)).map((item) =>
148− item.kind === "event" ? item.event.sequence : item.kind,
149− ),
150− ).toEqual([11, 12]);
151− }),
152− ).pipe(Effect.provide(TestClock.layer())),
153− );
154−
155− it.effect("flushes pending tool updates as soon as a synchronization marker arrives", () =>
156− Effect.scoped(
157− Effect.gen(function* () {
158− const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" });
159− const startedAt = yield* Clock.currentTimeMillis;
160− yield* coalescer.offer({ kind: "event", event: makeToolActivity(2) });
161− yield* coalescer.offer({ kind: "event", event: makeToolActivity(3) });
162− yield* coalescer.offer({ kind: "synchronized" });
163−
164− expect(yield* Clock.currentTimeMillis).toBe(startedAt);
165− expect(
166− (yield* coalescer.stream.pipe(Stream.take(2), Stream.runCollect)).map((item) =>
167− item.kind === "event" ? item.event.sequence : item.kind,
168− ),
169− ).toEqual([3, "synchronized"]);
170− }),
171− ).pipe(Effect.provide(TestClock.layer())),
172− );
173−
174− it.effect("fails and clears pending updates when their serialized payload fills the budget", () =>
175− Effect.scoped(
176− Effect.gen(function* () {
177− const first = makeToolActivity(1);
178− const coalescer = yield* makeThreadLiveEventCoalescer({
179− coalesceWindow: "500 millis",
180− maxSerializedBytes: Buffer.byteLength(encodeEvent(first)),
181− });
182− yield* coalescer.offer({ kind: "event", event: first });
183− expect(yield* coalescer.usage).toEqual({
184− retainedItems: 1,
185− retainedSerializedBytes: Buffer.byteLength(encodeEvent(first)),
186− });
187−
188− const overflow = yield* coalescer
189− .offer({ kind: "event", event: makeToolActivity(2) })
190− .pipe(Effect.result);
191− expect(overflow._tag).toBe("Failure");
192− yield* coalescer.closed;
193− expect(yield* coalescer.usage).toEqual({ retainedItems: 0, retainedSerializedBytes: 0 });
194− const marker = yield* coalescer.offer({ kind: "synchronized" }).pipe(Effect.result);
195− expect(marker._tag).toBe("Failure");
196− const delivered = yield* coalescer.stream.pipe(Stream.runCollect, Effect.result);
197− expect(delivered._tag).toBe("Failure");
198− }),
199− ),
200− );
201−
202− it.effect("keeps the flush timer alive when an offer's shorter scope closes", () =>
203− Effect.scoped(
204− Effect.gen(function* () {
205− const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "50 millis" });
206− yield* Effect.scoped(coalescer.offer({ kind: "event", event: makeToolActivity(1) }));
207− yield* TestClock.adjust("50 millis");
208− const items = yield* coalescer.stream.pipe(Stream.take(1), Stream.runCollect);
209− expect(
210− items.map((item) => (item.kind === "event" ? item.event.sequence : item.kind)),
211− ).toEqual([1]);
212− }),
213− ),
214− );
215−
216− it.effect("keeps an unacknowledged batch charged and clears later events on overflow", () =>
217− Effect.scoped(
218− Effect.gen(function* () {
219− const coalescer = yield* makeThreadLiveEventCoalescer({ maxItems: 3 });
220− const first = makeMessage(1, "é".repeat(1_024));
221− yield* coalescer.offer({ kind: "event", event: first });
222−
223− yield* Effect.scoped(
224− Effect.gen(function* () {
225− const pull = yield* Stream.toPull(coalescer.stream);
226− const batch = yield* pull;
227− expect(
228− batch.map((item) => (item.kind === "event" ? item.event.sequence : null)),
229− ).toEqual([1]);
230− yield* coalescer.offer({ kind: "event", event: makeMessage(2) });
231− yield* coalescer.offer({ kind: "event", event: makeToolActivity(3) });
232− expect((yield* coalescer.usage).retainedItems).toBe(3);
233−
234− const overflow = yield* coalescer
235− .offer({ kind: "event", event: makeToolActivity(4, { kind: "tool.completed" }) })
236− .pipe(Effect.result);
237− expect(overflow._tag).toBe("Failure");
238− // Do not pull or acknowledge the batch. Cleanup must still finish.
239− yield* coalescer.closed;
240− expect(yield* coalescer.usage).toEqual({
241− retainedItems: 1,
242− retainedSerializedBytes: Buffer.byteLength(encodeEvent(first)),
243− });
244− }),
245− );
246− expect(yield* coalescer.usage).toEqual({ retainedItems: 0, retainedSerializedBytes: 0 });
247− }),
248− ),
249− );
250−});
apps/server/src/orchestration/ThreadLiveEventCoalescer.ts+0−241
@@ -1,241 +0,0 @@
1−import type {
2− OrchestrationEvent,
3− OrchestrationGetSnapshotError,
4− OrchestrationThreadStreamItem,
5−} from "@t3tools/contracts";
6−import * as Deferred from "effect/Deferred";
7−import * as Duration from "effect/Duration";
8−import * as Effect from "effect/Effect";
9−import * as Fiber from "effect/Fiber";
10−import * as Predicate from "effect/Predicate";
11−import * as Queue from "effect/Queue";
12−import * as Semaphore from "effect/Semaphore";
13−import * as Stream from "effect/Stream";
14−
15−import { projectActivityEvent } from "./ActivityPayloadProjection.ts";
16−import { makeLiveStreamBudget, type RetainedLiveItem } from "./LiveStreamBudget.ts";
17−
18−const COALESCE_WINDOW = Duration.millis(50);
19−const MAX_PENDING_UPDATES = 512;
20−
21−export type ThreadLiveInput =
22− | { readonly kind: "event"; readonly event: OrchestrationEvent }
23− | { readonly kind: "synchronized" };
24−
25−function isToolUpdated(event: OrchestrationEvent): boolean {
26− return (
27− event.type === "thread.activity-appended" && event.payload.activity.kind === "tool.updated"
28− );
29−}
30−
31−function asTrimmedString(value: unknown): string | null {
32− if (!Predicate.isString(value)) {
33− return null;
34− }
35− const trimmed = value.trim();
36− return trimmed.length > 0 ? trimmed : null;
37−}
38−
39−function stableToolCallIdentity(event: OrchestrationEvent): string | null {
40− if (event.type !== "thread.activity-appended") {
41− return null;
42− }
43− const payload = event.payload.activity.payload;
44− if (!Predicate.isObject(payload)) {
45− return null;
46− }
47− const data = Predicate.isObject(payload.data) ? payload.data : null;
48− return asTrimmedString(payload.toolCallId) ?? asTrimmedString(data?.toolCallId);
49−}
50−
51−/**
52− * Retain only the latest in-flight update for each stable tool-call id in a
53− * live run. Anonymous calls pass through because labels are not unique when
54− * tools execute in parallel. Survivors remain in sequence order.
55− */
56−export function coalesceLiveToolUpdatedEvents(
57− events: ReadonlyArray<OrchestrationEvent>,
58−): ReadonlyArray<OrchestrationEvent> {
59− const survivors: Array<OrchestrationEvent> = [];
60− let pendingUpdates: Array<OrchestrationEvent> = [];
61−
62− const flushUpdates = () => {
63− const seen = new Set<string>();
64− const latestUpdates: Array<OrchestrationEvent> = [];
65− for (let index = pendingUpdates.length - 1; index >= 0; index -= 1) {
66− const event = pendingUpdates[index]!;
67− const identity = stableToolCallIdentity(event);
68− const activity =
69− event.type === "thread.activity-appended" ? event.payload.activity : undefined;
70− const key = identity ? `${activity?.turnId ?? ""}\u0000${identity}` : null;
71− if (key && seen.has(key)) {
72− continue;
73− }
74− if (key) {
75− seen.add(key);
76− }
77− latestUpdates.push(event);
78− }
79− latestUpdates.reverse();
80− survivors.push(...latestUpdates);
81− pendingUpdates = [];
82− };
83−
84− for (const event of events) {
85− if (isToolUpdated(event)) {
86− pendingUpdates.push(event);
87− continue;
88− }
89− flushUpdates();
90− survivors.push(event);
91− }
92− flushUpdates();
93− return survivors;
94−}
95−
96−export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoalescer")(
97− function* (options?: {
98− readonly coalesceWindow?: Duration.Input;
99− readonly maxItems?: number;
100− readonly maxSerializedBytes?: number;
101− }) {
102− const coalescerScope = yield* Effect.scope;
103− const budget = yield* makeLiveStreamBudget(options);
104− const cleanupComplete = yield* Deferred.make<void>();
105− const output = yield* Queue.unbounded<
106− RetainedLiveItem<OrchestrationThreadStreamItem>,
107− OrchestrationGetSnapshotError
108− >();
109− const mutex = yield* Semaphore.make(1);
110− const coalesceWindow = options?.coalesceWindow ?? COALESCE_WINDOW;
111− let pendingUpdates: Array<RetainedLiveItem<OrchestrationEvent>> = [];
112− let windowGeneration = 0;
113− let windowFiber: Fiber.Fiber<void, never> | null = null;
114− let closed = false;
115−
116− const cancelWindow = Effect.fn("ThreadLiveEventCoalescer.cancelWindow")(function* () {
117− const fiber = windowFiber;
118− if (!fiber) {
119− return;
120− }
121− windowFiber = null;
122− yield* Fiber.interrupt(fiber);
123− });
124−
125− const flushPending = Effect.fn("ThreadLiveEventCoalescer.flushPending")(function* () {
126− if (pendingUpdates.length === 0) {
127− return;
128− }
129− const items = yield* budget.replace(
130− pendingUpdates,
131− coalesceLiveToolUpdatedEvents(pendingUpdates.map((item) => item.value)).map((event) => ({
132− kind: "event" as const,
133− event,
134− })),
135− (item) => item.event,
136− );
137− pendingUpdates = [];
138− yield* Queue.offerAll(output, items);
139− }, Effect.uninterruptible);
140−
141− const flushWindow = (generation: number) =>
142− Effect.sleep(coalesceWindow).pipe(
143− Effect.andThen(
144− mutex.withPermits(1)(
145− Effect.suspend(() => (generation === windowGeneration ? flushPending() : Effect.void)),
146− ),
147− ),
148− Effect.ensuring(
149− Effect.sync(() => {
150− if (generation === windowGeneration) {
151− windowFiber = null;
152− }
153− }),
154− ),
155− Effect.catchTags({ OrchestrationGetSnapshotError: () => Effect.void }),
156− );
157−
158− // Keep each source batch together so a synchronization marker cannot pass
159− // events already pulled from PubSub but still being coalesced.
160− const offerAll = Effect.fn("ThreadLiveEventCoalescer.offerAll")(function* (
161− inputs: ReadonlyArray<ThreadLiveInput>,
162− ) {
163− yield* mutex.withPermits(1)(
164− Effect.forEach(
165− inputs,
166− (input) =>
167− Effect.gen(function* () {
168− yield* budget.check;
169− if (input.kind === "event") {
170− // Retain only the client payload, not full persisted tool output.
171− yield* budget.retain(projectActivityEvent(input.event)).pipe(
172− Effect.tap((item) => Effect.sync(() => pendingUpdates.push(item))),
173− Effect.uninterruptible,
174− );
175− }
176− if (input.kind === "event" && isToolUpdated(input.event)) {
177− if (pendingUpdates.length === 1) {
178− const generation = ++windowGeneration;
179− windowFiber = yield* Effect.forkIn(flushWindow(generation), coalescerScope);
180− }
181− if (pendingUpdates.length >= MAX_PENDING_UPDATES) {
182− yield* cancelWindow();
183− windowGeneration += 1;
184− yield* flushPending();
185− }
186− return;
187− }
188−
189− yield* cancelWindow();
190− windowGeneration += 1;
191− // A non-update event closes the run immediately. The coalescer keeps
192− // that boundary after the final update from the run.
193− yield* flushPending();
194− if (input.kind === "synchronized") {
195− yield* budget.retain({ kind: "synchronized" as const }).pipe(
196− Effect.flatMap((marker) => Queue.offer(output, marker)),
197− Effect.uninterruptible,
198− );
199− }
200− }),
201− { discard: true },
202− ),
203− );
204− });
205−
206− const close = (error?: OrchestrationGetSnapshotError) =>
207− mutex.withPermits(1)(
208− Effect.gen(function* () {
209− if (closed) {
210− return;
211− }
212− closed = true;
213− windowGeneration += 1;
214− yield* cancelWindow();
215− budget.release(pendingUpdates);
216− pendingUpdates = [];
217− budget.release(yield* Queue.clear(output).pipe(Effect.orDie));
218− if (error) {
219− yield* Queue.fail(output, error);
220− }
221− yield* Queue.shutdown(output);
222− yield* Deferred.succeed(cleanupComplete, undefined);
223− }),
224− );
225−
226− yield* Effect.addFinalizer(() => close());
227− yield* budget.failed.pipe(
228− Effect.catchTags({ OrchestrationGetSnapshotError: close }),
229− Effect.forkScoped,
230− );
231−
232− return {
233− offer: (input: ThreadLiveInput) => offerAll([input]),
234− offerAll,
235− stream: budget.deliver(Stream.fromQueue(output)),
236− failed: budget.failed,
237− closed: Deferred.await(cleanupComplete),
238− usage: budget.usage,
239− } as const;
240− },
241−);
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+0−858
@@ -1,858 +0,0 @@
1−import {
2− DEFAULT_SERVER_SETTINGS,
3− ProjectId,
4− ProviderInstanceId,
5− PullRequestOperationError,
6− ThreadId,
7− type OrchestrationCommand,
8− type OrchestrationProjectShell,
9− type OrchestrationShellSnapshot,
10− type OrchestrationThreadShell,
11− type PullRequestSummary,
12− type ServerSettings,
13− type ServerSettingsPatch,
14−} from "@t3tools/contracts";
15−import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
16−import { assert, describe, it } from "@effect/vitest";
17−import * as Crypto from "effect/Crypto";
18−import * as Deferred from "effect/Deferred";
19−import * as Effect from "effect/Effect";
20−import * as FileSystem from "effect/FileSystem";
21−import * as Layer from "effect/Layer";
22−import * as PubSub from "effect/PubSub";
23−import * as Queue from "effect/Queue";
24−import * as Ref from "effect/Ref";
25−import * as Stream from "effect/Stream";
26−import { TestClock } from "effect/testing";
27−
28−import { GitManager } from "../git/GitManager.ts";
29−import {
30− PullRequestService,
31− type PullRequestMergeEvent,
32−} from "../pullRequest/PullRequestService.ts";
33−import { ServerActivation } from "../serverActivation.ts";
34−import { ServerSettingsService } from "../serverSettings.ts";
35−import { OrchestrationCommandInvariantError } from "./Errors.ts";
36−import {
37− OrchestrationEngineService,
38− type OrchestrationEngineShape,
39−} from "./Services/OrchestrationEngine.ts";
40−import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
41−import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts";
42−
43−const NOW = "2026-08-28T12:00:00.000Z";
44−const PROJECT_ID = ProjectId.make("settlement-project");
45−const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project");
46−
47−type AutoSettleCommand = Extract<OrchestrationCommand, { readonly type: "thread.auto-settle" }>;
48−
49−const testCrypto = Crypto.make({
50− randomBytes: (size) => new Uint8Array(size).fill(1),
51− digest: (_algorithm, data) => Effect.succeed(data),
52−});
53−
54−function makeProject(
55− id: ProjectId = PROJECT_ID,
56− workspaceRoot = "/workspace/project",
57−): OrchestrationProjectShell {
58− return {
59− id,
60− title: `Project ${id}`,
61− workspaceRoot,
62− defaultModelSelection: null,
63− scripts: [],
64− createdAt: "2026-08-01T00:00:00.000Z",
65− updatedAt: NOW,
66− };
67−}
68−
69−function makeThread(
70− id: string,
71− overrides: Partial<OrchestrationThreadShell> = {},
72−): OrchestrationThreadShell {
73− return {
74− id: ThreadId.make(id),
75− projectId: PROJECT_ID,
76− title: id,
77− modelSelection: {
78− instanceId: ProviderInstanceId.make("codex"),
79− model: "gpt-5",
80− },
81− runtimeMode: "full-access",
82− interactionMode: "default",
83− branch: null,
84− worktreePath: null,
85− latestTurn: null,
86− createdAt: "2026-08-01T00:00:00.000Z",
87− updatedAt: "2026-08-20T00:00:00.000Z",
88− archivedAt: null,
89− settledOverride: null,
90− settledAt: null,
91− session: null,
92− latestUserMessageAt: "2026-08-20T00:00:00.000Z",
93− hasPendingApprovals: false,
94− hasPendingUserInput: false,
95− hasActionableProposedPlan: false,
96− ...overrides,
97− };
98−}
99−
100−function makeSnapshot(
101− threads: ReadonlyArray<OrchestrationThreadShell>,
102− projects: ReadonlyArray<OrchestrationProjectShell> = [makeProject()],
103−): OrchestrationShellSnapshot {
104− return {
105− snapshotSequence: 1,
106− projects,
107− threads,
108− updatedAt: NOW,
109− };
110−}
111−
112−function makePullRequestSummary(input: {
113− readonly projectId: ProjectId;
114− readonly repository: string;
115− readonly number: number;
116− readonly state: "open" | "closed" | "merged";
117− readonly updatedAt?: string;
118−}): PullRequestSummary {
119− return {
120− provider: "github",
121− projectId: input.projectId,
122− repository: input.repository,
123− number: input.number,
124− title: "Pull request",
125− url: `https://example.test/${input.repository}/pull/${input.number}`,
126− state: input.state,
127− headBranch: "feature",
128− baseBranch: "main",
129− updatedAt: input.updatedAt ?? NOW,
130− };
131−}
132−
133−interface HarnessOptions {
134− readonly snapshot: OrchestrationShellSnapshot;
135− readonly settings?: ServerSettings;
136− readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"];
137− readonly pullRequestSummary?: PullRequestService["Service"]["summary"];
138− readonly existingWorktreePaths?: ReadonlyArray<string>;
139− readonly onDispatch?: (
140− command: AutoSettleCommand,
141− ) => Effect.Effect<void, OrchestrationCommandInvariantError>;
142−}
143−
144−const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: HarnessOptions) {
145− const activation = yield* Deferred.make<void>();
146− const snapshots = yield* Ref.make(options.snapshot);
147− const snapshotReadCount = yield* Ref.make(0);
148− const snapshotReads = yield* Queue.unbounded<number>();
149− const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS);
150− const settingsChanges = yield* PubSub.unbounded<ServerSettings>();
151− const mergedPullRequests = yield* PubSub.unbounded<PullRequestMergeEvent>();
152− const commands = yield* Ref.make<ReadonlyArray<AutoSettleCommand>>([]);
153− const branchCalls = yield* Ref.make<
154− ReadonlyArray<{ readonly cwd: string; readonly branch: string }>
155− >([]);
156− const summaryCalls = yield* Ref.make<
157− ReadonlyArray<{
158− readonly projectId: ProjectId;
159− readonly repository: string;
160− readonly number: number;
161− }>
162− >([]);
163− const summaryRecovery = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]);
164− const invalidatedCwds = yield* Ref.make<ReadonlyArray<string>>([]);
165−
166− const updateSettings = (patch: ServerSettingsPatch) =>
167− Effect.gen(function* () {
168− const next = applyServerSettingsPatch(yield* Ref.get(settings), patch);
169− yield* Ref.set(settings, next);
170− yield* PubSub.publish(settingsChanges, next);
171− return next;
172− });
173−
174− const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) =>
175− Ref.update(branchCalls, (calls) => [...calls, input]).pipe(
176− Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)),
177− );
178− const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) =>
179− Effect.gen(function* () {
180− yield* Ref.update(summaryCalls, (calls) => [...calls, input]);
181− yield* Ref.update(summaryRecovery, (values) => [
182− ...values,
183− readOptions?.recoverTransientFailure,
184− ]);
185− return yield* (
186− options.pullRequestSummary?.(input, readOptions) ??
187− Effect.succeed(
188− makePullRequestSummary({
189− ...input,
190− state: "open",
191− }),
192− )
193− );
194− });
195−
196− const dispatch: OrchestrationEngineShape["dispatch"] = (command) => {
197− if (command.type !== "thread.auto-settle") {
198− return Effect.die(new Error(`Unexpected command: ${command.type}`));
199− }
200− return Ref.update(commands, (recorded) => [...recorded, command]).pipe(
201− Effect.andThen(options.onDispatch?.(command) ?? Effect.void),
202− Effect.as({ sequence: 1 }),
203− );
204− };
205−
206− const serverSettings = ServerSettingsService.of({
207− start: Effect.void,
208− ready: Effect.void,
209− getSettings: Ref.get(settings),
210− updateSettings,
211− streamChanges: Stream.fromPubSub(settingsChanges),
212− subscribeChanges: PubSub.subscribe(settingsChanges).pipe(
213− Effect.map((subscription) => Stream.fromSubscription(subscription)),
214− ),
215− });
216−
217− const dependencies = Layer.mergeAll(
218− Layer.mock(ProjectionSnapshotQuery)({
219− getShellSnapshot: () =>
220− Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe(
221− Effect.tap((count) => Queue.offer(snapshotReads, count)),
222− Effect.andThen(Ref.get(snapshots)),
223− ),
224− }),
225− Layer.mock(GitManager)({
226− branchPullRequest,
227− invalidateStatus: (cwd) => Ref.update(invalidatedCwds, (cwds) => [...cwds, cwd]),
228− }),
229− Layer.mock(PullRequestService)({
230− summary: pullRequestSummary,
231− subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
232− Effect.map((subscription) => Stream.fromSubscription(subscription)),
233− ),
234− }),
235− Layer.mock(OrchestrationEngineService)({
236− readEvents: () => Stream.empty,
237− dispatch,
238− streamDomainEvents: Stream.empty,
239− latestSequence: Effect.succeed(0),
240− }),
241− Layer.succeed(ServerSettingsService, serverSettings),
242− Layer.succeed(ServerActivation, Deferred.await(activation)),
243− Layer.succeed(Crypto.Crypto, testCrypto),
244− FileSystem.layerNoop({
245− exists: (path) => Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false),
246− }),
247− );
248−
249− return {
250− activation,
251− snapshots,
252− snapshotReadCount,
253− snapshotReads,
254− commands,
255− branchCalls,
256− summaryCalls,
257− summaryRecovery,
258− invalidatedCwds,
259− updateSettings,
260− publishMerge: PubSub.publish(mergedPullRequests, {
261− projectId: PROJECT_ID,
262− repository: "owner/repository",
263− number: 42,
264− mergedAt: NOW,
265− }),
266− layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)),
267− };
268−});
269−
270−const startHarness = Effect.fn("startThreadSettlementHarness")(function* (
271− reactor: ThreadSettlementReactor.ThreadSettlementReactor["Service"],
272− activation: Deferred.Deferred<void>,
273− snapshotReads: Queue.Queue<number>,
274−) {
275− yield* reactor.start();
276− yield* Deferred.succeed(activation, undefined);
277− yield* Queue.take(snapshotReads);
278− yield* reactor.drain;
279−});
280−
281−describe("ThreadSettlementReactor", () => {
282− it.effect("starts without clients and skips protected threads before pull request lookup", () =>
283− Effect.scoped(
284− Effect.gen(function* () {
285− yield* TestClock.setTime(Date.parse(NOW));
286− const linkedPullRequest = {
287− projectId: LINKED_PROJECT_ID,
288− repository: "owner/repository",
289− number: 42,
290− url: "https://example.test/owner/repository/pull/42",
291− } as const;
292− const skipped = [
293− makeThread("pending-approval", {
294− branch: "skip-approval",
295− hasPendingApprovals: true,
296− }),
297− makeThread("snoozed", {
298− branch: "skip-snoozed",
299− snoozedUntil: "2026-08-29T00:00:00.000Z",
300− }),
301− ];
302− const fixture = yield* makeHarness({
303− snapshot: makeSnapshot(
304− [
305− makeThread("inactive", { branch: "inactive-feature" }),
306− makeThread("closed-pr", { linkedPullRequest }),
307− ...skipped,
308− ],
309− [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
310− ),
311− branchPullRequest: () => Effect.succeed(null),
312− pullRequestSummary: (input) =>
313− Effect.succeed(makePullRequestSummary({ ...input, state: "closed" })),
314− });
315−
316− yield* Effect.gen(function* () {
317− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
318− yield* reactor.start();
319− assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 0);
320−
321− yield* Deferred.succeed(fixture.activation, undefined);
322− yield* Queue.take(fixture.snapshotReads);
323− yield* reactor.drain;
324−
325− const commands = yield* Ref.get(fixture.commands);
326− assert.deepStrictEqual(
327− commands
328− .map(({ threadId, snapshotSequence, settledAt }) => ({
329− threadId,
330− snapshotSequence,
331− settledAt,
332− }))
333− .sort((left, right) => left.threadId.localeCompare(right.threadId)),
334− [
335− {
336− threadId: ThreadId.make("closed-pr"),
337− snapshotSequence: 1,
338− settledAt: "2026-08-20T00:00:00.000Z",
339− },
340− {
341− threadId: ThreadId.make("inactive"),
342− snapshotSequence: 1,
343− settledAt: "2026-08-20T00:00:00.000Z",
344− },
345− ],
346− );
347− assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [
348− { cwd: "/workspace/project", branch: "inactive-feature" },
349− ]);
350− assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
351− { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 },
352− ]);
353− assert.deepStrictEqual(yield* Ref.get(fixture.summaryRecovery), [false]);
354− }).pipe(Effect.provide(fixture.layer));
355− }),
356− ),
357− );
358−
359− it.effect("reevaluates inactivity and pull request state once per minute", () =>
360− Effect.scoped(
361− Effect.gen(function* () {
362− yield* TestClock.setTime(Date.parse(NOW));
363− const pullRequest = yield* Ref.make<"open" | "merged">("open");
364− const fixture = yield* makeHarness({
365− snapshot: makeSnapshot([
366− makeThread("at-boundary", {
367− latestUserMessageAt: "2026-08-25T12:00:00.000Z",
368− }),
369− makeThread("open-pr", {
370− branch: "saved-feature",
371− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
372− }),
373− ]),
374− branchPullRequest: () =>
375− Ref.get(pullRequest).pipe(Effect.map((state) => ({ state, updatedAt: NOW }))),
376− });
377−
378− yield* Effect.gen(function* () {
379− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
380− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
381− assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
382−
383− yield* Ref.set(pullRequest, "merged");
384− yield* TestClock.adjust("1 minute");
385− yield* Queue.take(fixture.snapshotReads);
386− yield* reactor.drain;
387−
388− assert.deepStrictEqual(
389− (yield* Ref.get(fixture.commands))
390− .map((command) => command.threadId)
391− .sort((left, right) => left.localeCompare(right)),
392− [ThreadId.make("at-boundary"), ThreadId.make("open-pr")],
393− );
394− assert.strictEqual((yield* Ref.get(fixture.branchCalls)).length, 2);
395− }).pipe(Effect.provide(fixture.layer));
396− }),
397− ),
398− );
399−
400− it.effect("reevaluates immediately after a pull request merge", () =>
401− Effect.scoped(
402− Effect.gen(function* () {
403− yield* TestClock.setTime(Date.parse(NOW));
404− const periodicLookupStarted = yield* Deferred.make<void>();
405− const releasePeriodicLookup = yield* Deferred.make<void>();
406− const mergedThreadSettled = yield* Deferred.make<void>();
407− const branchLookupCount = yield* Ref.make(0);
408− const fixture = yield* makeHarness({
409− snapshot: makeSnapshot([
410− makeThread("merged-in-app", {
411− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
412− linkedPullRequest: {
413− projectId: PROJECT_ID,
414− repository: "owner/repository",
415− number: 42,
416− url: "https://example.test/owner/repository/pull/42",
417− },
418− }),
419− makeThread("slow-periodic-lookup", {
420− branch: "another-feature",
421− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
422− }),
423− ]),
424− branchPullRequest: () =>
425− Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe(
426− Effect.flatMap((count) =>
427− count === 1
428− ? Effect.succeed({ state: "open" as const, updatedAt: NOW })
429− : Deferred.succeed(periodicLookupStarted, undefined).pipe(
430− Effect.andThen(Deferred.await(releasePeriodicLookup)),
431− Effect.as({ state: "open" as const, updatedAt: NOW }),
432− ),
433− ),
434− ),
435− onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),
436− });
437−
438− yield* Effect.gen(function* () {
439− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
440− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
441− yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 });
442− yield* Deferred.await(periodicLookupStarted);
443−
444− yield* fixture.publishMerge;
445− yield* Deferred.await(mergedThreadSettled);
446−
447− assert.deepStrictEqual(
448− (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
449− [ThreadId.make("merged-in-app")],
450− );
451− yield* Deferred.succeed(releasePeriodicLookup, undefined);
452− yield* reactor.drain;
453− }).pipe(Effect.provide(fixture.layer));
454− }),
455− ),
456− );
457−
458− it.effect(
459− "settles branch threads on a pull request merge without waiting for the next sweep",
460− () =>
461− Effect.scoped(
462− Effect.gen(function* () {
463− yield* TestClock.setTime(Date.parse(NOW));
464− const state = yield* Ref.make<"open" | "merged">("open");
465− const mergedThreadSettled = yield* Deferred.make<void>();
466− const fixture = yield* makeHarness({
467− snapshot: makeSnapshot([
468− makeThread("branch-thread", {
469− branch: "saved-feature",
470− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
471− }),
472− ]),
473− branchPullRequest: () =>
474− Ref.get(state).pipe(
475− Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })),
476− ),
477− onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),
478− });
479−
480− yield* Effect.gen(function* () {
481− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
482− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
483− assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
484− assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), []);
485−
486− yield* Ref.set(state, "merged");
487− yield* fixture.publishMerge;
488− yield* Deferred.await(mergedThreadSettled);
489−
490− assert.deepStrictEqual(
491− (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
492− [ThreadId.make("branch-thread")],
493− );
494− assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), ["/workspace/project"]);
495− yield* reactor.drain;
496− }).pipe(Effect.provide(fixture.layer));
497− }),
498− ),
499− );
500−
501− it.effect("a merge does not settle threads linked to an unrelated pull request", () =>
502− Effect.scoped(
503− Effect.gen(function* () {
504− yield* TestClock.setTime(Date.parse(NOW));
505− const mergedThreadSettled = yield* Deferred.make<void>();
506− const mergeLookupStarted = yield* Deferred.make<void>();
507− const releaseMergeLookup = yield* Deferred.make<void>();
508− const lookupCount = yield* Ref.make(0);
509− const fixture = yield* makeHarness({
510− snapshot: makeSnapshot([
511− makeThread("merged-in-app", {
512− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
513− linkedPullRequest: {
514− projectId: PROJECT_ID,
515− repository: "owner/repository",
516− number: 42,
517− url: "https://example.test/owner/repository/pull/42",
518− },
519− }),
520− makeThread("unrelated-linked", {
521− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
522− linkedPullRequest: {
523− projectId: PROJECT_ID,
524− repository: "owner/repository",
525− number: 99,
526− url: "https://example.test/owner/repository/pull/99",
527− },
528− }),
529− ]),
530− pullRequestSummary: (input) =>
531− Ref.updateAndGet(lookupCount, (count) => count + 1).pipe(
532− // The initial sweep looks up both linked threads; the merge
533− // sweep only looks up the unrelated one, since the merged
534− // thread settles from the event itself.
535− Effect.tap((count) =>
536− count === 3 ? Deferred.succeed(mergeLookupStarted, undefined) : Effect.void,
537− ),
538− Effect.tap((count) =>
539− count === 3 ? Deferred.await(releaseMergeLookup) : Effect.void,
540− ),
541− Effect.map(() => makePullRequestSummary({ ...input, state: "open" })),
542− ),
543− onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined),
544− });
545−
546− yield* Effect.gen(function* () {
547− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
548− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
549− assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
550−
551− yield* fixture.publishMerge;
552− yield* Deferred.await(mergeLookupStarted);
553− yield* Deferred.await(mergedThreadSettled);
554− yield* Deferred.succeed(releaseMergeLookup, undefined);
555− yield* reactor.drain;
556−
557− assert.deepStrictEqual(
558− (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
559− [ThreadId.make("merged-in-app")],
560− );
561− assert.deepStrictEqual(
562− (yield* Ref.get(fixture.summaryCalls))
563− .map((call) => call.number)
564− .toSorted((left, right) => left - right),
565− [42, 99, 99],
566− );
567− }).pipe(Effect.provide(fixture.layer));
568− }),
569− ),
570− );
571−
572− it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () =>
573− Effect.scoped(
574− Effect.gen(function* () {
575− yield* TestClock.setTime(Date.parse(NOW));
576− const state = yield* Ref.make<"merged" | "closed">("merged");
577− const firstLookupStarted = yield* Deferred.make<void>();
578− const releaseFirstLookup = yield* Deferred.make<void>();
579− const laterLookupStarted = yield* Deferred.make<void>();
580− const releaseLaterLookup = yield* Deferred.make<void>();
581− const lookupCount = yield* Ref.make(0);
582− const fixture = yield* makeHarness({
583− snapshot: makeSnapshot([makeThread("settings-thread", { branch: "saved-feature" })]),
584− settings: {
585− ...DEFAULT_SERVER_SETTINGS,
586− sidebarAutoSettleAfterDays: null,
587− sidebarAutoSettleOnMerge: true,
588− },
589− branchPullRequest: () =>
590− Ref.updateAndGet(lookupCount, (count) => count + 1).pipe(
591− Effect.tap((count) =>
592− count === 1
593− ? Deferred.succeed(firstLookupStarted, undefined)
594− : count === 3
595− ? Deferred.succeed(laterLookupStarted, undefined)
596− : Effect.void,
597− ),
598− Effect.tap((count) =>
599− count === 1
600− ? Deferred.await(releaseFirstLookup)
601− : count === 3
602− ? Deferred.await(releaseLaterLookup)
603− : Effect.void,
604− ),
605− Effect.andThen(Ref.get(state)),
606− Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })),
607− ),
608− });
609−
610− yield* Effect.gen(function* () {
611− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
612− yield* reactor.start();
613− yield* Deferred.succeed(fixture.activation, undefined);
614− yield* Queue.take(fixture.snapshotReads);
615− yield* Deferred.await(firstLookupStarted);
616−
617− yield* fixture.updateSettings({ sidebarAutoSettleOnMerge: false });
618− yield* Deferred.succeed(releaseFirstLookup, undefined);
619− yield* Queue.take(fixture.snapshotReads);
620− yield* reactor.drain;
621− assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
622− assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 2);
623−
624− yield* Ref.set(state, "closed");
625− yield* fixture.updateSettings({ enableAgentBrowserAccess: false });
626− yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 1 });
627− yield* Deferred.await(laterLookupStarted);
628− yield* Deferred.succeed(releaseLaterLookup, undefined);
629− yield* reactor.drain;
630−
631− assert.strictEqual(yield* Ref.get(fixture.snapshotReadCount), 3);
632− assert.strictEqual(yield* Ref.get(lookupCount), 3);
633− assert.deepStrictEqual(
634− (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
635− [ThreadId.make("settings-thread")],
636− );
637− }).pipe(Effect.provide(fixture.layer));
638− }),
639− ),
640− );
641−
642− it.effect("keeps an unknown pull request active and continues with other candidates", () =>
643− Effect.scoped(
644− Effect.gen(function* () {
645− yield* TestClock.setTime(Date.parse(NOW));
646− const fixture = yield* makeHarness({
647− snapshot: makeSnapshot(
648− [
649− makeThread("lookup-failed", {
650− linkedPullRequest: {
651− projectId: LINKED_PROJECT_ID,
652− repository: "owner/repository",
653− number: 9,
654− url: "https://example.test/owner/repository/pull/9",
655− },
656− }),
657− makeThread("inactive-without-pr"),
658− ],
659− [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
660− ),
661− pullRequestSummary: () =>
662− Effect.fail(
663− new PullRequestOperationError({
664− operation: "summary",
665− detail: "host unavailable",
666− }),
667− ),
668− });
669−
670− yield* Effect.gen(function* () {
671− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
672− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
673−
674− assert.deepStrictEqual(
675− (yield* Ref.get(fixture.commands)).map((command) => command.threadId),
676− [ThreadId.make("inactive-without-pr")],
677− );
678− assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
679− }).pipe(Effect.provide(fixture.layer));
680− }),
681− ),
682− );
683−
684− it.effect("keeps threads active when their pull request project is unavailable", () =>
685− Effect.scoped(
686− Effect.gen(function* () {
687− yield* TestClock.setTime(Date.parse(NOW));
688− const linkedPullRequest = {
689− projectId: LINKED_PROJECT_ID,
690− repository: "owner/repository",
691− number: 10,
692− url: "https://example.test/owner/repository/pull/10",
693− } as const;
694− const fixture = yield* makeHarness({
695− snapshot: makeSnapshot(
696− [
697− makeThread("missing-own-project", {
698− latestUserMessageAt: "2026-08-27T00:00:00.000Z",
699− linkedPullRequest,
700− }),
701− makeThread("missing-branch-project", { branch: "saved-feature" }),
702− ],
703− [makeProject(LINKED_PROJECT_ID, "/workspace/linked")],
704− ),
705− pullRequestSummary: (input) =>
706− Effect.succeed(makePullRequestSummary({ ...input, state: "open" })),
707− });
708−
709− yield* Effect.gen(function* () {
710− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
711− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
712−
713− assert.deepStrictEqual(yield* Ref.get(fixture.commands), []);
714− assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
715− { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 10 },
716− ]);
717− assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []);
718− }).pipe(Effect.provide(fixture.layer));
719− }),
720− ),
721− );
722−
723− it.effect("deduplicates saved-branch and linked pull request lookups within a sweep", () =>
724− Effect.scoped(
725− Effect.gen(function* () {
726− yield* TestClock.setTime(Date.parse(NOW));
727− const linkedPullRequest = {
728− projectId: LINKED_PROJECT_ID,
729− repository: "owner/repository",
730− number: 77,
731− url: "https://example.test/owner/repository/pull/77",
732− } as const;
733− const fixture = yield* makeHarness({
734− snapshot: makeSnapshot(
735− [
736− makeThread("branch-one", {
737− branch: "saved-feature",
738− worktreePath: "/deleted/worktree-one",
739− }),
740− makeThread("branch-two", {
741− branch: "saved-feature",
742− worktreePath: "/deleted/worktree-two",
743− }),
744− makeThread("linked-one", { linkedPullRequest }),
745− makeThread("linked-two", { linkedPullRequest }),
746− ],
747− [
748− makeProject(PROJECT_ID, "/workspace/project-root"),
749− makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"),
750− ],
751− ),
752− branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }),
753− pullRequestSummary: (input) =>
754− Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })),
755− });
756−
757− yield* Effect.gen(function* () {
758− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
759− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
760−
761− assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [
762− { cwd: "/workspace/project-root", branch: "saved-feature" },
763− ]);
764− assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [
765− { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 77 },
766− ]);
767− assert.deepStrictEqual(
768− new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)),
769− new Set([
770− ThreadId.make("branch-one"),
771− ThreadId.make("branch-two"),
772− ThreadId.make("linked-one"),
773− ThreadId.make("linked-two"),
774− ]),
775− );
776− }).pipe(Effect.provide(fixture.layer));
777− }),
778− ),
779− );
780−
781− it.effect("looks up the branch pull request from a thread's live worktree", () =>
782− Effect.scoped(
783− Effect.gen(function* () {
784− yield* TestClock.setTime(Date.parse(NOW));
785− const fixture = yield* makeHarness({
786− snapshot: makeSnapshot(
787− [
788− makeThread("live-worktree", {
789− branch: "feature/live",
790− worktreePath: "/workspace/project-root/.worktrees/live",
791− }),
792− makeThread("deleted-worktree", {
793− branch: "feature/deleted",
794− worktreePath: "/workspace/project-root/.worktrees/deleted",
795− }),
796− ],
797− [makeProject(PROJECT_ID, "/workspace/project-root")],
798− ),
799− existingWorktreePaths: ["/workspace/project-root/.worktrees/live"],
800− });
801−
802− yield* Effect.gen(function* () {
803− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
804− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
805−
806− assert.deepStrictEqual(
807− new Set(yield* Ref.get(fixture.branchCalls)),
808− new Set([
809− { cwd: "/workspace/project-root/.worktrees/live", branch: "feature/live" },
810− { cwd: "/workspace/project-root", branch: "feature/deleted" },
811− ]),
812− );
813− }).pipe(Effect.provide(fixture.layer));
814− }),
815− ),
816− );
817−
818− it.effect("carries the snapshot guard and survives a stale dispatch rejection", () =>
819− Effect.scoped(
820− Effect.gen(function* () {
821− yield* TestClock.setTime(Date.parse(NOW));
822− const fixture = yield* makeHarness({
823− snapshot: makeSnapshot([makeThread("stale"), makeThread("next-candidate")]),
824− onDispatch: (command) =>
825− command.threadId === ThreadId.make("stale")
826− ? Effect.fail(
827− new OrchestrationCommandInvariantError({
828− commandType: command.type,
829− detail: "thread changed after settlement evaluation",
830− }),
831− )
832− : Effect.void,
833− });
834−
835− yield* Effect.gen(function* () {
836− const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
837− yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
838−
839− const firstSweep = yield* Ref.get(fixture.commands);
840− assert.strictEqual(
841− firstSweep.find((command) => command.threadId === ThreadId.make("stale"))
842− ?.snapshotSequence,
843− 1,
844− );
845− assert.strictEqual(
846− firstSweep.some((command) => command.threadId === ThreadId.make("next-candidate")),
847− true,
848− );
849−
850− yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 });
851− yield* Queue.take(fixture.snapshotReads);
852− yield* reactor.drain;
853− assert.strictEqual((yield* Ref.get(fixture.commands)).length, 4);
854− }).pipe(Effect.provide(fixture.layer));
855− }),
856− ),
857− );
858−});
apps/server/src/orchestration/ThreadSettlementReactor.ts+0−249
@@ -1,249 +0,0 @@
1−import { CommandId } from "@t3tools/contracts";
2−import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
3−import * as Cause from "effect/Cause";
4−import * as Context from "effect/Context";
5−import * as Crypto from "effect/Crypto";
6−import * as DateTime from "effect/DateTime";
7−import * as Effect from "effect/Effect";
8−import * as FileSystem from "effect/FileSystem";
9−import * as Layer from "effect/Layer";
10−import * as Schedule from "effect/Schedule";
11−import type * as Scope from "effect/Scope";
12−import * as Stream from "effect/Stream";
13−
14−import * as GitManager from "../git/GitManager.ts";
15−import * as PullRequestService from "../pullRequest/PullRequestService.ts";
16−import * as ServerSettings from "../serverSettings.ts";
17−import { forkParked } from "../serverActivation.ts";
18−import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
19−import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
20−import {
21− isAutoSettlementCandidate,
22− resolveAutoSettlementAt,
23− type SettlementPullRequest,
24−} from "./ThreadSettlementPolicy.ts";
25−
26−export class ThreadSettlementReactor extends Context.Service<
27− ThreadSettlementReactor,
28− {
29− readonly start: () => Effect.Effect<void, never, Scope.Scope>;
30− readonly drain: Effect.Effect<void>;
31− }
32−>()("t3/orchestration/ThreadSettlementReactor") {}
33−
34−export const make = Effect.gen(function* () {
35− const engine = yield* OrchestrationEngine.OrchestrationEngineService;
36− const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
37− const settingsService = yield* ServerSettings.ServerSettingsService;
38− const git = yield* GitManager.GitManager;
39− const pullRequests = yield* PullRequestService.PullRequestService;
40− const crypto = yield* Crypto.Crypto;
41− const fileSystem = yield* FileSystem.FileSystem;
42−
43− const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* (
44− mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
45− ) {
46− const snapshot = yield* snapshots.getShellSnapshot();
47− const now = DateTime.formatIso(yield* DateTime.now);
48− const projects = new Map(snapshot.projects.map((project) => [project.id, project]));
49− // A merge event re-sweeps every candidate, not just the threads linked to
50− // the merged pull request: most threads carry no link and settle from
51− // their branch lookup, which would otherwise wait for the next minute's
52− // sweep on a possibly stale cached answer.
53− const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now));
54− // Use the same cwd as the sidebar so both paths share GitManager's PR cache.
55− const lookupCwdByThreadId = new Map<string, string>();
56− yield* Effect.forEach(
57− candidates,
58− (thread) =>
59− Effect.gen(function* () {
60− const project = projects.get(thread.projectId);
61− if (project === undefined || thread.linkedPullRequest != null) return;
62− const worktreeExists =
63− thread.worktreePath !== null &&
64− (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false)));
65− lookupCwdByThreadId.set(
66− thread.id,
67− worktreeExists && thread.worktreePath !== null
68− ? thread.worktreePath
69− : project.workspaceRoot,
70− );
71− }),
72− { concurrency: 8, discard: true },
73− );
74− if (mergedPullRequest !== null) {
75− // The merge just confirmed a terminal state the lookup caches can still
76− // call open (branch answers live two minutes, the sweep runs every
77− // minute). Drop the swept checkouts' cached answers so the merge settles
78− // its branch threads now instead of on a later sweep. Threads linked to
79− // the merged pull request settle from the event itself below and need no
80− // lookup, so they are absent from this map by construction.
81− const cwds = [...new Set(lookupCwdByThreadId.values())];
82− yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), {
83− concurrency: 8,
84− discard: true,
85− });
86− }
87− const lookupKey = (thread: (typeof candidates)[number]) => {
88− if (thread.linkedPullRequest != null) {
89− return JSON.stringify([
90− "linked",
91− thread.linkedPullRequest.projectId,
92− thread.linkedPullRequest.repository,
93− thread.linkedPullRequest.number,
94− ]);
95− }
96− if (thread.branch === null) return JSON.stringify(["none", thread.id]);
97− const cwd = lookupCwdByThreadId.get(thread.id);
98− return JSON.stringify(
99− cwd === undefined ? ["missing-project", thread.id] : ["branch", cwd, thread.branch],
100− );
101− };
102− const groups = Map.groupBy(candidates, lookupKey);
103−
104− const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* (
105− thread: (typeof candidates)[number],
106− ) {
107− if (thread.linkedPullRequest != null) {
108− // The event carries the merged state, so only the threads linked to
109− // that exact pull request settle from it. Every other linked thread
110− // falls through to a fresh summary lookup below: the merge sweep
111− // covers all candidates, and an unrelated merge must never settle
112− // them.
113− if (
114− mergedPullRequest !== null &&
115− thread.linkedPullRequest.projectId === mergedPullRequest.projectId &&
116− thread.linkedPullRequest.repository.toLowerCase() ===
117− mergedPullRequest.repository.toLowerCase() &&
118− thread.linkedPullRequest.number === mergedPullRequest.number
119− ) {
120− return {
121− state: "merged",
122− updatedAt: mergedPullRequest.mergedAt,
123− } satisfies SettlementPullRequest;
124− }
125− if (!projects.has(thread.linkedPullRequest.projectId)) {
126− return yield* Effect.die(new Error("linked pull request project not found"));
127− }
128− const summary = yield* pullRequests.summary(
129− {
130− projectId: thread.linkedPullRequest.projectId,
131− repository: thread.linkedPullRequest.repository,
132− number: thread.linkedPullRequest.number,
133− },
134− { recoverTransientFailure: false },
135− );
136− return {
137− state: summary.state,
138− updatedAt: summary.updatedAt,
139− } satisfies SettlementPullRequest;
140− }
141− if (thread.branch === null) return null;
142− const cwd = lookupCwdByThreadId.get(thread.id);
143− if (cwd === undefined) {
144− return yield* Effect.die(new Error("thread project not found"));
145− }
146− return yield* git.branchPullRequest({ cwd, branch: thread.branch });
147− });
148−
149− yield* Effect.forEach(
150− groups.values(),
151− (group) =>
152− Effect.gen(function* () {
153− const pullRequest = yield* pullRequestFor(group[0]!);
154− yield* Effect.forEach(
155− group,
156− (thread) =>
157− Effect.gen(function* () {
158− const settings = yield* settingsService.getSettings;
159− const decisionNow = DateTime.formatIso(yield* DateTime.now);
160− const settledAt = resolveAutoSettlementAt({
161− thread,
162− pullRequest,
163− now: decisionNow,
164− autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,
165− autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,
166− });
167− if (settledAt === null) {
168− return;
169− }
170− const uuid = yield* crypto.randomUUIDv4;
171− yield* engine.dispatch({
172− type: "thread.auto-settle",
173− commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`),
174− threadId: thread.id,
175− snapshotSequence: snapshot.snapshotSequence,
176− settledAt,
177− });
178− }).pipe(
179− Effect.catchCause((cause) =>
180− Cause.hasInterruptsOnly(cause)
181− ? Effect.failCause(cause)
182− : Effect.logWarning("automatic thread settlement skipped", {
183− threadId: thread.id,
184− cause: Cause.pretty(cause),
185− }),
186− ),
187− ),
188− { discard: true },
189− );
190− }).pipe(
191− Effect.catchCause((cause) =>
192− Cause.hasInterruptsOnly(cause)
193− ? Effect.failCause(cause)
194− : Effect.logWarning("automatic thread settlement skipped", {
195− threadIds: group.map((thread) => thread.id),
196− cause: Cause.pretty(cause),
197− }),
198− ),
199− ),
200− { concurrency: 8, discard: true },
201− );
202− });
203−
204− const runSweep = (mergedPullRequest: PullRequestService.PullRequestMergeEvent | null) =>
205− sweep(mergedPullRequest).pipe(
206− Effect.catchCause((cause) =>
207− Cause.hasInterruptsOnly(cause)
208− ? Effect.failCause(cause)
209− : Effect.logWarning("automatic thread settlement sweep failed", {
210− cause: Cause.pretty(cause),
211− }),
212− ),
213− );
214− const worker = yield* makeDrainableWorker(() => runSweep(null));
215−
216− const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn(
217− "ThreadSettlementReactor.start",
218− )(function* () {
219− const settingsChanges = yield* settingsService.subscribeChanges;
220− const mergedPullRequests = yield* pullRequests.subscribeMerges;
221− const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);
222− let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays;
223− let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge;
224− yield* forkParked(
225− Effect.gen(function* () {
226− yield* worker.enqueue(undefined);
227− yield* worker.drain;
228− }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid),
229− );
230− yield* forkParked(
231− Stream.runForEach(settingsChanges, (settings) => {
232− if (
233− settings.sidebarAutoSettleAfterDays === lastAfterDays &&
234− settings.sidebarAutoSettleOnMerge === lastOnMerge
235− ) {
236− return Effect.void;
237− }
238− lastAfterDays = settings.sidebarAutoSettleAfterDays;
239− lastOnMerge = settings.sidebarAutoSettleOnMerge;
240− return worker.enqueue(undefined);
241− }),
242− );
243− yield* forkParked(Stream.runForEach(mergedPullRequests, runSweep));
244− });
245−
246− return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"];
247−});
248−
249−export const layer = Layer.effect(ThreadSettlementReactor, make);
apps/server/src/orchestration/decider.ts+1−0
@@ -3,6 +3,7 @@ import { · 2 unmodified lines
33 type OrchestrationCommand,
44 type OrchestrationEvent,
55 type OrchestrationReadModel,
6+ type OrchestrationThread,
67 } from "@t3tools/contracts/legacy-orchestration";
78 import * as DateTime from "effect/DateTime";
89 import * as Crypto from "effect/Crypto";
apps/server/src/persistence/Layers/OrchestrationEventStore.ts+11−10
@@ -647,16 +647,17 @@ const makeEventStore = Effect.gen(function* () { · 646 unmodified lines
647647 const findEventAfter = SqlSchema.findOneOption({
648648 Request: HasEventAfterRequestSchema,
649649 Result: Schema.Struct({ sequence: Schema.Number }),
650− execute: (request) =>
651− sql`
652− SELECT sequence
653− FROM orchestration_events
654− WHERE aggregate_kind = ${request.aggregateKind}
655− AND stream_id = ${request.aggregateId}
656− AND event_type = ${request.type}
657− AND sequence > ${request.sequenceExclusive}
658− LIMIT 1
659− `,
650+ execute: (request) => sql`
651+ SELECT sequence
652+ FROM orchestration_events
653+ WHERE aggregate_kind = ${request.aggregateKind}
654+ AND stream_id = ${request.aggregateId}
655+ AND ${sql.and([
656+ sql`sequence > ${request.sequenceExclusive}`,
657+ ...(request.type === undefined ? [] : [sql`event_type = ${request.type}`]),
658+ ])}
659+ LIMIT 1
660+ `,
660661 });
661662
662663 const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) =>
apps/server/src/persistence/Services/OrchestrationEventStore.ts+3−2
@@ -123,7 +123,8 @@ export interface OrchestrationEventStoreShape { · 122 unmodified lines
123123 readonly afterSequence?: number;
124124 }) => Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError>;
125125 /**
126− * Check whether an aggregate has an event of the given type after a sequence.
126+ * Check whether an aggregate has an event after a sequence, optionally
127+ * restricted to one event type.
127128 *
128129 * Used during replay to tell whether a later event supersedes the one being
129130 * applied, without streaming the rest of the log.
@@ -131,7 +132,7 @@ export interface OrchestrationEventStoreShape { · 1 unmodified line
131132 readonly hasEventAfter: (input: {
132133 readonly aggregateKind: OrchestrationEvent["aggregateKind"];
133134 readonly aggregateId: string;
134− readonly type: OrchestrationEvent["type"];
135+ readonly type?: OrchestrationEvent["type"];
135136 readonly sequenceExclusive: number;
136137 }) => Effect.Effect<boolean, OrchestrationEventStoreError>;
137138 }
apps/server/src/provider/Drivers/ClaudeDriver.ts+24−19
@@ -40,6 +40,7 @@ import { · 39 unmodified lines
4040 } from "../Layers/ClaudeProvider.ts";
4141 import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
4242 import * as ModelManifest from "../ModelManifest.ts";
43+import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts";
4344 import {
4445 defaultProviderContinuationIdentity,
4546 type ProviderDriver,
@@ -114,6 +115,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 68 unmodified lines
114115 const serverSettings = yield* ServerSettingsService;
115116 const eventLoggers = yield* ProviderEventLoggers;
116117 const modelManifest = yield* ModelManifest.ModelManifest;
118+ const modelCatalog = modelManifest.current.pipe(Effect.map(resolveClaudeModelCatalog));
117119 const processEnv = mergeProviderInstanceEnvironment(environment);
118120 const fallbackContinuationIdentity = defaultProviderContinuationIdentity({
119121 driverKind: DRIVER_KIND,
@@ -157,7 +159,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 37 unmodified lines
157159 }),
158160 ),
159161 );
160− const textGeneration = yield* makeClaudeTextGeneration(effectiveConfig, processEnv);
162+ const textGeneration = yield* makeClaudeTextGeneration(
163+ effectiveConfig,
164+ processEnv,
165+ modelCatalog,
166+ );
161167
162168 // Per-instance capabilities cache: keyed on binary + resolved HOME so
163169 // account-specific probes never share auth metadata across instances.
@@ -171,22 +177,21 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 7 unmodified lines
171177 });
172178 const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd);
173179
174− // Kick the TTL-gated manifest refresh in the background and classify
175− // with the in-memory manifest, so a slow or hung fetch never delays the
176− // provider check. A refresh that lands mid-probe applies on the next one.
180+ // Start the TTL-gated refresh without delaying provider readiness. The
181+ // next check observes a remote manifest after the background fetch lands.
177182 const checkProvider = modelManifest.refreshInBackground.pipe(
178183 Effect.andThen(
179− Effect.zipWith(
180− checkClaudeProviderStatus(
181− effectiveConfig,
182− () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
183− processEnv,
184− cwd,
184+ modelManifest.current.pipe(
185+ Effect.flatMap((manifest) =>
186+ checkClaudeProviderStatus(
187+ effectiveConfig,
188+ () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
189+ processEnv,
190+ cwd,
191+ resolveClaudeModelCatalog(manifest),
192+ ),
185193 ),
186− modelManifest.current,
187− (draft, manifest) =>
188− stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)),
189− { concurrent: true },
194+ Effect.map(stampIdentity),
190195 ),
191196 ),
192197 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
@@ -201,11 +206,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 8 unmodified lines
201206 streamSettings: snapshotSettings.streamSettings,
202207 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
203208 initialSnapshot: (settings) =>
204− Effect.zipWith(
205− makePendingClaudeProvider(settings.provider),
206− modelManifest.current,
207− (draft, manifest) =>
208− stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)),
209+ modelManifest.current.pipe(
210+ Effect.flatMap((manifest) =>
211+ makePendingClaudeProvider(settings.provider, resolveClaudeModelCatalog(manifest)),
212+ ),
213+ Effect.map(stampIdentity),
209214 ),
210215 checkProvider,
211216 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
apps/server/src/provider/acp/AcpNativeLogging.ts+44−6
@@ -1,5 +1,5 @@
11 import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts";
2−import { causeErrorTag } from "@t3tools/shared/observability";
2+import { causeErrorTag, errorTag } from "@t3tools/shared/observability";
33 import * as Cause from "effect/Cause";
44 import * as Crypto from "effect/Crypto";
55 import * as DateTime from "effect/DateTime";
@@ -13,6 +13,40 @@ import { · 7 unmodified lines
1313 } from "../NativeProtocolLogging.ts";
1414 import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
1515
16+const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]);
17+
18+function structuralMethod(value: string): string {
19+ return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown";
20+}
21+
22+function summarizePayload(payload: unknown): Readonly<Record<string, unknown>> {
23+ if (payload === null) return { valueType: "null" };
24+ if (typeof payload === "string") {
25+ return { valueType: "string", byteLength: new TextEncoder().encode(payload).byteLength };
26+ }
27+ if (payload instanceof Uint8Array) {
28+ return { valueType: "bytes", byteLength: payload.byteLength };
29+ }
30+ if (Array.isArray(payload)) {
31+ return { valueType: "array", itemCount: payload.length };
32+ }
33+ if (typeof payload !== "object") {
34+ return { valueType: typeof payload };
35+ }
36+
37+ try {
38+ const record = payload as Record<string, unknown>;
39+ return {
40+ valueType: "object",
41+ fieldCount: Object.keys(record).length,
42+ ...(typeof record._tag === "string" ? { messageTag: errorTag(record) } : {}),
43+ ...(typeof record.tag === "string" ? { method: structuralMethod(record.tag) } : {}),
44+ };
45+ } catch {
46+ return { valueType: "object" };
47+ }
48+}
49+
1650 function formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEvent) {
1751 return {
1852 method: structuralProtocolMethod(event.method),
@@ -137,11 +171,15 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" · 118 unmodified lines
137171 protocolLogging: {
138172 logIncoming: true,
139173 logOutgoing: true,
140− logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) =>
141− writeNativeAcpLog({
142− kind: "protocol",
143− payload: formatAcpProtocolLogPayload(event),
144− }),
174+ logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => {
175+ const filtered = filterTransientProtocolLog(event);
176+ return filtered
177+ ? writeNativeAcpLog({
178+ kind: "protocol",
179+ payload: formatAcpProtocolLogPayload(filtered),
180+ })
181+ : Effect.void;
182+ },
145183 } satisfies NonNullable<AcpSessionRuntime.AcpSessionRuntimeOptions["protocolLogging"]>,
146184 }
147185 : {}),