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.
Showing the changes to apps/server/src (17 of 47 files). Show all
Unified · Split
| @@ -7,12 +7,14 @@ import { · 6 unmodified lines | ||
| 7 | 7 | } from "@t3tools/shared/model"; |
| 8 | 8 | |
| 9 | 9 | 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"; | |
| 16 | 18 | |
| 17 | 19 | export interface CompiledClaudeModelSelection { |
| 18 | 20 | readonly apiModelId: string; |
| @@ -25,14 +27,15 @@ export interface CompiledClaudeModelSelection { · 6 unmodified lines | ||
| 25 | 27 | /** Compile every Claude model option at the provider boundary. */ |
| 26 | 28 | export function compileClaudeModelSelection( |
| 27 | 29 | selection: ModelSelection, |
| 30 | + catalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, | |
| 28 | 31 | ): CompiledClaudeModelSelection { |
| 29 | − const capabilities = getClaudeModelCapabilities(selection.model); | |
| 32 | + const capabilities = getClaudeCatalogModelCapabilities(catalog, selection.model); | |
| 30 | 33 | const descriptors = getProviderOptionDescriptors({ caps: capabilities }); |
| 31 | 34 | const supportsBoolean = (id: string) => |
| 32 | 35 | descriptors.some((descriptor) => descriptor.type === "boolean" && descriptor.id === id); |
| 33 | 36 | 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); | |
| 36 | 39 | const fastMode = supportsBoolean("fastMode") |
| 37 | 40 | ? getModelSelectionBooleanOptionValue(selection, "fastMode") |
| 38 | 41 | : undefined; |
| @@ -42,9 +45,9 @@ export function compileClaudeModelSelection( · 3 unmodified lines | ||
| 42 | 45 | const settings = { |
| 43 | 46 | ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), |
| 44 | 47 | ...(typeof fastMode === "boolean" ? { fastMode } : {}), |
| 45 | − ...(isClaudeUltracodeEffort(resolvedEffort) ? { ultracode: true } : {}), | |
| 48 | + ...(isClaudeCatalogUltracodeEffort(resolvedEffort) ? { ultracode: true } : {}), | |
| 46 | 49 | }; |
| 47 | − const apiModelId = resolveClaudeApiModelId(selection); | |
| 50 | + const apiModelId = resolveClaudeCatalogApiModelId(catalog, selection); | |
| 48 | 51 | const promptEffort = resolvePromptInjectedEffort(capabilities, rawEffort) ?? undefined; |
| 49 | 52 | return { |
| 50 | 53 | apiModelId, |
| @@ -1,12 +1,13 @@ | ||
| 1 | 1 | 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"; | |
| 2 | 5 | import * as Effect from "effect/Effect"; |
| 3 | 6 | import * as FileSystem from "effect/FileSystem"; |
| 4 | 7 | import * as Layer from "effect/Layer"; |
| 5 | 8 | import * as Path from "effect/Path"; |
| 6 | 9 | 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"; | |
| 10 | 11 | |
| 11 | 12 | import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts"; |
| 12 | 13 | |
| @@ -70,6 +71,179 @@ describe("browser API CORS", () => { · 57 unmodified lines | ||
| 70 | 71 | const fileResponseLayer = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer); |
| 71 | 72 | |
| 72 | 73 | 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 | + | |
| 73 | 247 | it.effect("streams exactly the requested bytes and leaves full downloads intact", () => |
| 74 | 248 | Effect.gen(function* () { |
| 75 | 249 | const fs = yield* FileSystem.FileSystem; |
| @@ -77,7 +77,10 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; · 76 unmodified lines | ||
| 77 | 77 | import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; |
| 78 | 78 | import { ServerConfig } from "../../config.ts"; |
| 79 | 79 | 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"; | |
| 81 | 84 | import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts"; |
| 82 | 85 | import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; |
| 83 | 86 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; |
| @@ -134,7 +137,9 @@ function claudeContextWindow(modelSelection: ModelSelection): number | null { · 50 unmodified lines | ||
| 134 | 137 | if (modelSelection.model === "claude-opus-4-6" || modelSelection.model === "claude-opus-4-7") { |
| 135 | 138 | return 1_000_000; |
| 136 | 139 | } |
| 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; | |
| 138 | 143 | } |
| 139 | 144 | |
| 140 | 145 | export function claudeProviderTurnTokenUsage( |
| @@ -3027,6 +3027,36 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid · 3026 unmodified lines | ||
| 3027 | 3027 | }), |
| 3028 | 3028 | ), |
| 3029 | 3029 | ); |
| 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); | |
| 3030 | 3060 | }).pipe( |
| 3031 | 3061 | Effect.mapError( |
| 3032 | 3062 | (cause) => |
| @@ -166,42 +166,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 165 unmodified lines | ||
| 166 | 166 | }); |
| 167 | 167 | } |
| 168 | 168 | |
| 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(); | |
| 199 | 169 | const eventBase = yield* decideOrchestrationCommand({ |
| 200 | 170 | command: envelope.command, |
| 201 | 171 | readModel: commandReadModel, |
| 202 | − ...(Option.isSome(userInputActivity) | |
| 203 | − ? { userInputActivity: userInputActivity.value } | |
| 204 | − : {}), | |
| 205 | 172 | }).pipe( |
| 206 | 173 | Effect.provideService(Crypto.Crypto, crypto), |
| 207 | 174 | Effect.mapError((cause) => |
| @@ -1018,34 +1018,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 1017 unmodified lines | ||
| 1018 | 1018 | return; |
| 1019 | 1019 | |
| 1020 | 1020 | 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 | − | |
| 1041 | 1021 | const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ |
| 1042 | 1022 | messageId: event.payload.messageId, |
| 1043 | 1023 | }); |
| 1044 | 1024 | const previousMessage = Option.getOrUndefined(existingMessage); |
| 1045 | 1025 | const nextText = Option.match(existingMessage, { |
| 1046 | 1026 | 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 | + }, | |
| 1049 | 1036 | }); |
| 1050 | 1037 | const nextAttachments = |
| 1051 | 1038 | event.payload.attachments !== undefined |
| @@ -1060,7 +1047,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 8 unmodified lines | ||
| 1060 | 1047 | role: event.payload.role, |
| 1061 | 1048 | text: nextText, |
| 1062 | 1049 | ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), |
| 1063 | − isStreaming: false, | |
| 1050 | + isStreaming: event.payload.streaming, | |
| 1064 | 1051 | createdAt: previousMessage?.createdAt ?? event.payload.createdAt, |
| 1065 | 1052 | updatedAt: event.payload.updatedAt, |
| 1066 | 1053 | }); |
| @@ -77,9 +77,6 @@ const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); · 76 unmodified lines | ||
| 77 | 77 | // activity window. Applying the limit in SQL avoids decoding an unbounded |
| 78 | 78 | // payload_json set before the projector can enforce that invariant. |
| 79 | 79 | 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; | |
| 83 | 80 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( |
| 84 | 81 | Struct.assign({ |
| 85 | 82 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), |
| @@ -104,9 +101,6 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( · 18 unmodified lines | ||
| 104 | 101 | sequence: Schema.NullOr(NonNegativeInt), |
| 105 | 102 | }), |
| 106 | 103 | ); |
| 107 | −const ProjectionThreadActivityIdRowSchema = Schema.Struct({ | |
| 108 | − activityId: ProjectionThreadActivity.fields.activityId, | |
| 109 | −}); | |
| 110 | 104 | const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; |
| 111 | 105 | const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({ |
| 112 | 106 | id: ThreadId, |
| @@ -166,9 +160,6 @@ const ThreadActivityKindsLookupInput = Schema.Struct({ · 53 unmodified lines | ||
| 166 | 160 | threadId: ThreadId, |
| 167 | 161 | activityKinds: Schema.Array(Schema.String), |
| 168 | 162 | }); |
| 169 | −const ThreadActivityIdsLookupInput = Schema.Struct({ | |
| 170 | − activityIds: Schema.Array(ProjectionThreadActivity.fields.activityId), | |
| 171 | −}); | |
| 172 | 163 | // Windowed reads order turns by the stable keyset (anchor, turn key), where |
| 173 | 164 | // anchor is requested_at and turn key is |
| 174 | 165 | // COALESCE(turn_id, ''). Both are event-derived, so cursors survive the |
| @@ -372,21 +363,6 @@ function mapProposedPlanRow( · 197 unmodified lines | ||
| 372 | 363 | }; |
| 373 | 364 | } |
| 374 | 365 | |
| 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 | − | |
| 390 | 366 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { |
| 391 | 367 | return (cause: unknown): ProjectionRepositoryError => |
| 392 | 368 | Schema.isSchemaError(cause) |
| @@ -1187,44 +1163,6 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 794 unmodified lines | ||
| 1187 | 1163 | `, |
| 1188 | 1164 | }); |
| 1189 | 1165 | |
| 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 | − | |
| 1228 | 1166 | const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({ |
| 1229 | 1167 | Request: ThreadActivityKindsLookupInput, |
| 1230 | 1168 | Result: ProjectionThreadActivityDbRowSchema, |
| @@ -1475,8 +1413,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 244 unmodified lines | ||
| 1475 | 1413 | `, |
| 1476 | 1414 | }); |
| 1477 | 1415 | |
| 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 ( | |
| 1480 | 1425 | SELECT request_id, thread_id |
| 1481 | 1426 | FROM projection_pending_approvals |
| 1482 | 1427 | WHERE thread_id = ${threadId} |
| @@ -1540,17 +1485,6 @@ pending_approval_requests AS ( · 57 unmodified lines | ||
| 1540 | 1485 | WHERE request_order = 1 |
| 1541 | 1486 | AND kind = 'user-input.requested' |
| 1542 | 1487 | ) |
| 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)} | |
| 1554 | 1488 | SELECT |
| 1555 | 1489 | activity.activity_id AS "activityId", |
| 1556 | 1490 | activity.thread_id AS "threadId", |
| @@ -1568,17 +1502,6 @@ pending_approval_requests AS ( · 11 unmodified lines | ||
| 1568 | 1502 | `, |
| 1569 | 1503 | }); |
| 1570 | 1504 | |
| 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 | − | |
| 1582 | 1505 | const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({ |
| 1583 | 1506 | Request: ThreadTurnRangeLookupInput, |
| 1584 | 1507 | Result: ProjectionThreadActivityDbRowSchema, |
| @@ -1646,48 +1569,6 @@ pending_approval_requests AS ( · 61 unmodified lines | ||
| 1646 | 1569 | `, |
| 1647 | 1570 | }); |
| 1648 | 1571 | |
| 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 | − | |
| 1691 | 1572 | const getFullThreadDiffContextRow = SqlSchema.findOneOption({ |
| 1692 | 1573 | Request: FullThreadDiffContextLookupInput, |
| 1693 | 1574 | Result: ProjectionFullThreadDiffContextRowSchema, |
| @@ -2832,136 +2713,49 @@ pending_approval_requests AS ( · 1138 unmodified lines | ||
| 2832 | 2713 | readonly beforeTurnKey: string; |
| 2833 | 2714 | } |
| 2834 | 2715 | |
| 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 | + }) | |
| 2851 | 2733 | ).pipe( |
| 2852 | 2734 | Effect.mapError( |
| 2853 | 2735 | toPersistenceSqlOrDecodeError( |
| 2854 | − "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:query", | |
| 2855 | − "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:decodeRows", | |
| 2736 | + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", | |
| 2737 | + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", | |
| 2856 | 2738 | ), |
| 2857 | 2739 | ), |
| 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, | |
| 2881 | 2740 | ); |
| 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", | |
| 2929 | 2748 | ), |
| 2930 | 2749 | ), |
| 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([]); | |
| 2959 | 2752 | |
| 2960 | 2753 | const [ |
| 2961 | 2754 | threadRow, |
| 2962 | 2755 | messageRows, |
| 2963 | 2756 | proposedPlanRows, |
| 2964 | − activities, | |
| 2757 | + activityRows, | |
| 2758 | + pinnedActivityRows, | |
| 2965 | 2759 | checkpointRows, |
| 2966 | 2760 | latestTurnRow, |
| 2967 | 2761 | sessionRow, |
| @@ -2993,7 +2787,8 @@ pending_approval_requests AS ( · 25 unmodified lines | ||
| 2993 | 2787 | ), |
| 2994 | 2788 | ), |
| 2995 | 2789 | ), |
| 2996 | − activitiesEffect, | |
| 2790 | + activityRowsEffect, | |
| 2791 | + pinnedActivityRowsEffect, | |
| 2997 | 2792 | listCheckpointRowsByThread({ threadId }).pipe( |
| 2998 | 2793 | Effect.mapError( |
| 2999 | 2794 | toPersistenceSqlOrDecodeError( |
| @@ -3024,6 +2819,17 @@ pending_approval_requests AS ( · 24 unmodified lines | ||
| 3024 | 2819 | return Option.none<OrchestrationThread>(); |
| 3025 | 2820 | } |
| 3026 | 2821 | |
| 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 | + | |
| 3027 | 2833 | const thread = { |
| 3028 | 2834 | id: threadRow.value.threadId, |
| 3029 | 2835 | projectId: threadRow.value.projectId, |
| @@ -3061,7 +2867,21 @@ pending_approval_requests AS ( · 31 unmodified lines | ||
| 3061 | 2867 | return message; |
| 3062 | 2868 | }), |
| 3063 | 2869 | 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 | + }), | |
| 3065 | 2885 | checkpoints: checkpointRows.map((row) => ({ |
| 3066 | 2886 | turnId: row.turnId, |
| 3067 | 2887 | checkpointTurnCount: row.checkpointTurnCount, |
| @@ -3086,11 +2906,7 @@ pending_approval_requests AS ( · 18 unmodified lines | ||
| 3086 | 2906 | const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = ( |
| 3087 | 2907 | threadId, |
| 3088 | 2908 | query, |
| 3089 | − ) => | |
| 3090 | − getThreadDetailByIdBounded(threadId, undefined, { | |
| 3091 | − mode: "raw", | |
| 3092 | − ...(query === undefined ? {} : { query }), | |
| 3093 | − }); | |
| 2909 | + ) => getThreadDetailByIdBounded(threadId, undefined, query); | |
| 3094 | 2910 | |
| 3095 | 2911 | // Bounds pathological fan-out: one user turn that spawned hundreds of |
| 3096 | 2912 | // subagent turns still pages in bounded chunks, at the cost of splitting the |
| @@ -3114,9 +2930,7 @@ pending_approval_requests AS ( · 17 unmodified lines | ||
| 3114 | 2930 | .withTransaction( |
| 3115 | 2931 | Effect.gen(function* () { |
| 3116 | 2932 | if (window?.turnLimit === undefined) { |
| 3117 | − const thread = yield* getThreadDetailByIdBounded(threadId, undefined, { | |
| 3118 | − mode: "client", | |
| 3119 | − }); | |
| 2933 | + const thread = yield* getThreadDetailById(threadId); | |
| 3120 | 2934 | if (Option.isNone(thread)) { |
| 3121 | 2935 | return Option.none<OrchestrationThreadDetailSnapshot>(); |
| 3122 | 2936 | } |
| @@ -3169,9 +2983,7 @@ pending_approval_requests AS ( · 46 unmodified lines | ||
| 3169 | 2983 | ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" } |
| 3170 | 2984 | : undefined; |
| 3171 | 2985 | |
| 3172 | − const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds, { | |
| 3173 | − mode: "client", | |
| 3174 | − }); | |
| 2986 | + const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds); | |
| 3175 | 2987 | if (Option.isNone(thread)) { |
| 3176 | 2988 | return Option.none<OrchestrationThreadDetailSnapshot>(); |
| 3177 | 2989 | } |
| @@ -227,10 +227,6 @@ export interface ProjectionSnapshotQueryShape { · 226 unmodified lines | ||
| 227 | 227 | * response carries `page` metadata (see `OrchestrationThreadDetailWindow`). |
| 228 | 228 | * Without a window the full thread is returned with no `page` field — |
| 229 | 229 | * 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. | |
| 234 | 230 | */ |
| 235 | 231 | readonly getThreadDetailSnapshot: ( |
| 236 | 232 | threadId: ThreadId, |
| @@ -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 | −}); | |
| @@ -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 | −); | |
| @@ -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 | −}); | |
| @@ -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); | |
| @@ -3,6 +3,7 @@ import { · 2 unmodified lines | ||
| 3 | 3 | type OrchestrationCommand, |
| 4 | 4 | type OrchestrationEvent, |
| 5 | 5 | type OrchestrationReadModel, |
| 6 | + type OrchestrationThread, | |
| 6 | 7 | } from "@t3tools/contracts/legacy-orchestration"; |
| 7 | 8 | import * as DateTime from "effect/DateTime"; |
| 8 | 9 | import * as Crypto from "effect/Crypto"; |
| @@ -647,16 +647,17 @@ const makeEventStore = Effect.gen(function* () { · 646 unmodified lines | ||
| 647 | 647 | const findEventAfter = SqlSchema.findOneOption({ |
| 648 | 648 | Request: HasEventAfterRequestSchema, |
| 649 | 649 | 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 | + `, | |
| 660 | 661 | }); |
| 661 | 662 | |
| 662 | 663 | const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) => |
| @@ -123,7 +123,8 @@ export interface OrchestrationEventStoreShape { · 122 unmodified lines | ||
| 123 | 123 | readonly afterSequence?: number; |
| 124 | 124 | }) => Stream.Stream<ApplicationStoredEvent, OrchestrationEventStoreError>; |
| 125 | 125 | /** |
| 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. | |
| 127 | 128 | * |
| 128 | 129 | * Used during replay to tell whether a later event supersedes the one being |
| 129 | 130 | * applied, without streaming the rest of the log. |
| @@ -131,7 +132,7 @@ export interface OrchestrationEventStoreShape { · 1 unmodified line | ||
| 131 | 132 | readonly hasEventAfter: (input: { |
| 132 | 133 | readonly aggregateKind: OrchestrationEvent["aggregateKind"]; |
| 133 | 134 | readonly aggregateId: string; |
| 134 | − readonly type: OrchestrationEvent["type"]; | |
| 135 | + readonly type?: OrchestrationEvent["type"]; | |
| 135 | 136 | readonly sequenceExclusive: number; |
| 136 | 137 | }) => Effect.Effect<boolean, OrchestrationEventStoreError>; |
| 137 | 138 | } |
| @@ -40,6 +40,7 @@ import { · 39 unmodified lines | ||
| 40 | 40 | } from "../Layers/ClaudeProvider.ts"; |
| 41 | 41 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 42 | 42 | import * as ModelManifest from "../ModelManifest.ts"; |
| 43 | +import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; | |
| 43 | 44 | import { |
| 44 | 45 | defaultProviderContinuationIdentity, |
| 45 | 46 | type ProviderDriver, |
| @@ -114,6 +115,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 68 unmodified lines | ||
| 114 | 115 | const serverSettings = yield* ServerSettingsService; |
| 115 | 116 | const eventLoggers = yield* ProviderEventLoggers; |
| 116 | 117 | const modelManifest = yield* ModelManifest.ModelManifest; |
| 118 | + const modelCatalog = modelManifest.current.pipe(Effect.map(resolveClaudeModelCatalog)); | |
| 117 | 119 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 118 | 120 | const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ |
| 119 | 121 | driverKind: DRIVER_KIND, |
| @@ -157,7 +159,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 37 unmodified lines | ||
| 157 | 159 | }), |
| 158 | 160 | ), |
| 159 | 161 | ); |
| 160 | − const textGeneration = yield* makeClaudeTextGeneration(effectiveConfig, processEnv); | |
| 162 | + const textGeneration = yield* makeClaudeTextGeneration( | |
| 163 | + effectiveConfig, | |
| 164 | + processEnv, | |
| 165 | + modelCatalog, | |
| 166 | + ); | |
| 161 | 167 | |
| 162 | 168 | // Per-instance capabilities cache: keyed on binary + resolved HOME so |
| 163 | 169 | // account-specific probes never share auth metadata across instances. |
| @@ -171,22 +177,21 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 7 unmodified lines | ||
| 171 | 177 | }); |
| 172 | 178 | const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); |
| 173 | 179 | |
| 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. | |
| 177 | 182 | const checkProvider = modelManifest.refreshInBackground.pipe( |
| 178 | 183 | 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 | + ), | |
| 185 | 193 | ), |
| 186 | − modelManifest.current, | |
| 187 | − (draft, manifest) => | |
| 188 | − stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), | |
| 189 | − { concurrent: true }, | |
| 194 | + Effect.map(stampIdentity), | |
| 190 | 195 | ), |
| 191 | 196 | ), |
| 192 | 197 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| @@ -201,11 +206,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = { · 8 unmodified lines | ||
| 201 | 206 | streamSettings: snapshotSettings.streamSettings, |
| 202 | 207 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, |
| 203 | 208 | 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), | |
| 209 | 214 | ), |
| 210 | 215 | checkProvider, |
| 211 | 216 | enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import type { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; |
| 2 | −import { causeErrorTag } from "@t3tools/shared/observability"; | |
| 2 | +import { causeErrorTag, errorTag } from "@t3tools/shared/observability"; | |
| 3 | 3 | import * as Cause from "effect/Cause"; |
| 4 | 4 | import * as Crypto from "effect/Crypto"; |
| 5 | 5 | import * as DateTime from "effect/DateTime"; |
| @@ -13,6 +13,40 @@ import { · 7 unmodified lines | ||
| 13 | 13 | } from "../NativeProtocolLogging.ts"; |
| 14 | 14 | import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; |
| 15 | 15 | |
| 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 | + | |
| 16 | 50 | function formatRequestLogPayload(event: AcpSessionRuntime.AcpSessionRequestLogEvent) { |
| 17 | 51 | return { |
| 18 | 52 | method: structuralProtocolMethod(event.method), |
| @@ -137,11 +171,15 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" · 118 unmodified lines | ||
| 137 | 171 | protocolLogging: { |
| 138 | 172 | logIncoming: true, |
| 139 | 173 | 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 | + }, | |
| 145 | 183 | } satisfies NonNullable<AcpSessionRuntime.AcpSessionRuntimeOptions["protocolLogging"]>, |
| 146 | 184 | } |
| 147 | 185 | : {}), |