From ede5b6f5bcd38b4a4dfb437b88bbc740ee19beb9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 02:12:35 -0700 Subject: [PATCH 01/15] perf(server): cut idle CPU use and stop provider event leaks (#8187) --- .../Layers/ProviderRuntimeIngestion.test.ts | 23 ++ .../Layers/ProviderRuntimeIngestion.ts | 18 +- .../RepositoryIdentityResolver.test.ts | 84 ++++ .../src/project/RepositoryIdentityResolver.ts | 32 +- .../provider/Layers/EventNdjsonLogger.test.ts | 44 ++- .../src/provider/Layers/EventNdjsonLogger.ts | 61 ++- .../provider/Layers/OpenCodeAdapter.test.ts | 15 + .../src/provider/Layers/OpenCodeAdapter.ts | 6 +- .../src/provider/acp/AcpNativeLogging.test.ts | 169 ++++++++ .../src/provider/acp/AcpNativeLogging.ts | 67 +++- .../NativeTelemetryClient.test.ts | 5 +- .../NativeTelemetryClient.ts | 40 +- apps/server/src/vcs/GitVcsDriverCore.ts | 3 +- docs/internals/resource-telemetry.md | 9 +- native/resource-monitor/src/main.rs | 35 +- packages/effect-acp/src/protocol.test.ts | 35 ++ packages/effect-acp/src/protocol.ts | 18 +- .../src/protocol.test.ts | 360 ++++++++++++++++++ .../effect-codex-app-server/src/protocol.ts | 95 +++-- 19 files changed, 1021 insertions(+), 98 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 4ae7504d..8f08992c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1089,6 +1089,29 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("ignores provider content deltas that cannot change thread state", async () => { + const harness = await createHarness(); + const initial = await harness.readModel(); + + for (const streamKind of ["reasoning_text", "command_output", "file_change_output"] as const) { + harness.emit({ + type: "content.delta", + eventId: asEventId(`evt-ignored-${streamKind}`), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-ignored"), + payload: { + streamKind, + delta: "ignored output", + }, + }); + } + + await harness.drain(); + expect(await harness.readModel()).toEqual(initial); + }); + it("maps canonical content delta/item completed into finalized assistant messages", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 1becee0a..eb67f0d6 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1504,6 +1504,10 @@ const make = Effect.gen(function* () { const processRuntimeEvent = (event: ProviderRuntimeEvent) => Effect.gen(function* () { + if (event.type === "content.delta" && event.payload.streamKind !== "assistant_text") { + return; + } + const thread = yield* resolveThreadShell(event.threadId); if (!thread) return; @@ -1531,9 +1535,17 @@ const make = Effect.gen(function* () { const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; - const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({ - threadId: thread.id, - }); + const pendingTurnStart = + event.type === "session.started" || + event.type === "session.state.changed" || + event.type === "session.exited" || + event.type === "thread.started" || + event.type === "turn.started" || + event.type === "turn.completed" + ? yield* projectionTurnRepository.getPendingTurnStartByThreadId({ + threadId: thread.id, + }) + : Option.none(); const hasPendingTurnStart = Option.isSome(pendingTurnStart) && thread.session?.status === "starting"; diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 61dd8870..a0886978 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { TestClock } from "effect/testing"; import * as ProcessRunner from "../processRunner.ts"; @@ -35,6 +36,89 @@ const makeRepositoryIdentityResolverTestLayer = (options: { ).pipe(Layer.provide(ProcessRunner.layer)); it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { + it.effect("reuses the cached Git root for repeated workspace lookups", () => { + const calls: Array> = []; + const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.sync(() => { + calls.push(input.args); + return { + stdout: input.args.includes("rev-parse") + ? "/repo\n" + : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }); + const resolverLayer = Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make(), + ).pipe(Layer.provide(processRunner)); + + return Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const first = yield* resolver.resolve("/repo/packages/web"); + const second = yield* resolver.resolve("/repo/packages/web"); + + expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(second).toEqual(first); + expect(calls).toEqual([ + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo", "remote", "-v"], + ]); + }).pipe(Effect.provide(resolverLayer)); + }); + + it.effect("retries Git root discovery after a failed lookup", () => { + const calls: Array> = []; + let rootAttempts = 0; + const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.sync(() => { + calls.push(input.args); + const rootLookup = input.args.includes("rev-parse"); + const failed = rootLookup && rootAttempts++ === 0; + return { + stdout: rootLookup + ? failed + ? "" + : "/repo\n" + : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", + stderr: failed ? "temporary Git failure" : "", + code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }); + const resolverLayer = Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make(), + ).pipe(Layer.provide(processRunner)); + + return Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + expect(yield* resolver.resolve("/repo/packages/web")).toBeNull(); + + const recovered = yield* resolver.resolve("/repo/packages/web"); + expect(recovered?.rootPath).toBe("/repo"); + expect(calls).toEqual([ + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo", "remote", "-v"], + ]); + }).pipe(Effect.provide(resolverLayer)); + }); + it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 397262c8..0848d3e8 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -90,7 +90,6 @@ function buildRepositoryIdentity(input: { const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( function* (cwd: string) { const processRunner = yield* ProcessRunner.ProcessRunner; - let cacheKey = cwd; // git is a real executable on every platform β€” no cmd.exe shell mode, which // would split paths containing spaces during cmd's re-tokenization. @@ -102,15 +101,11 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver. }) .pipe(Effect.option); if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { - return cacheKey; + return null; } const candidate = topLevelResult.value.stdout.trim(); - if (candidate.length > 0) { - cacheKey = candidate; - } - - return cacheKey; + return candidate.length > 0 ? candidate : null; }, ); @@ -139,6 +134,22 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( options: RepositoryIdentityResolverOptions = {}, ) { const processRunner = yield* ProcessRunner.ProcessRunner; + const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; + + const repositoryRootCache = yield* Cache.makeWith( + (cwd) => + resolveRepositoryIdentityCacheKey(cwd).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + ), + { + capacity: cacheCapacity, + timeToLive: Exit.match({ + onSuccess: (value) => + value === null ? Duration.zero : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), + onFailure: () => Duration.zero, + }), + }, + ); const repositoryIdentityCache = yield* Cache.makeWith( (cacheKey) => @@ -146,7 +157,7 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), ), { - capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, + capacity: cacheCapacity, timeToLive: Exit.match({ onSuccess: (value) => value === null @@ -160,9 +171,8 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( "RepositoryIdentityResolver.resolve", )(function* (cwd) { - const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - ); + const cacheKey = yield* Cache.get(repositoryRootCache, cwd); + if (cacheKey === null) return null; return yield* Cache.get(repositoryIdentityCache, cacheKey); }); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts index 6f2e4524..f6e00a4a 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -288,7 +288,7 @@ describe("EventNdjsonLogger", () => { }), ); - it.effect("drops transient canonical events before serialization", () => + it.effect("drops transient provider events before serialization", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "helmcode-provider-log-")); const basePath = NodePath.join(tempDir, "events.log"); @@ -304,6 +304,46 @@ describe("EventNdjsonLogger", () => { yield* canonical.write(circularDelta, threadId); yield* canonical.write({ type: "item.completed", id: "final" }, threadId); yield* native.write({ type: "content.delta", id: "native-delta" }, threadId); + yield* native.write( + { method: "item/agentMessage/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { method: "thread/realtime/outputAudio/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { method: "thread/realtime/transcript/delta", payload: circularDelta }, + threadId, + ); + yield* native.write( + { + event: { + method: "claude/stream_event/content_block_delta/text_delta", + payload: circularDelta, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + method: "session/update", + payload: { update: { sessionUpdate: "agent_message_chunk" } }, + }, + }, + threadId, + ); + yield* native.write( + { + event: { + type: "message.part.updated", + payload: { properties: { part: { type: "text" } } }, + }, + }, + threadId, + ); + yield* native.write({ type: "turn.completed", id: "native-final" }, threadId); yield* store.close(); const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-filtered"), "utf8") @@ -315,7 +355,7 @@ describe("EventNdjsonLogger", () => { lines.map(({ stream, payload }) => ({ stream, payload })), [ { stream: "CANON", payload: '{"type":"item.completed","id":"final"}' }, - { stream: "NTIVE", payload: '{"type":"content.delta","id":"native-delta"}' }, + { stream: "NTIVE", payload: '{"type":"turn.completed","id":"native-final"}' }, ], ); } finally { diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts index f08c5eaa..782c24e5 100644 --- a/apps/server/src/provider/Layers/EventNdjsonLogger.ts +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -45,6 +45,17 @@ const transientCanonicalEventTypes = new Set([ "tool.progress", "turn.proposed.delta", ]); +const transientNativeMethods = new Set([ + "item/agentMessage/delta", + "item/commandExecution/outputDelta", + "item/fileChange/outputDelta", + "item/plan/delta", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + "thread/realtime/outputAudio/delta", + "thread/realtime/transcript/delta", +]); +const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); export type EventNdjsonStream = "native" | "canonical" | "orchestration"; @@ -126,7 +137,7 @@ export interface PendingRecord { } interface StoreState { - readonly pending: ReadonlyArray; + readonly pending: Array; readonly pendingBytes: number; readonly sinks: ReadonlyMap; readonly flushScheduled: boolean; @@ -178,12 +189,50 @@ function providerLogPath(directory: string, prefix: string, threadSegment: strin } function shouldPersist(stream: EventNdjsonStream, event: unknown): boolean { - if (stream !== "canonical" || typeof event !== "object" || event === null) { + if (stream === "orchestration" || typeof event !== "object" || event === null) { return true; } try { const type = Reflect.get(event, "type"); - return typeof type !== "string" || !transientCanonicalEventTypes.has(type); + if (typeof type === "string" && transientCanonicalEventTypes.has(type)) { + return false; + } + if (stream !== "native") return true; + + const nested = Reflect.get(event, "event"); + const nativeEvent = typeof nested === "object" && nested !== null ? nested : event; + const method = Reflect.get(nativeEvent, "method"); + if ( + typeof method === "string" && + (transientNativeMethods.has(method) || + method.startsWith("claude/stream_event/content_block_delta/")) + ) { + return false; + } + + const nativeType = Reflect.get(nativeEvent, "type"); + if (nativeType === "message.part.delta") return false; + + const payload = Reflect.get(nativeEvent, "payload"); + if (typeof payload !== "object" || payload === null) return true; + + if (method === "session/update") { + const update = Reflect.get(payload, "update"); + if (typeof update !== "object" || update === null) return true; + const updateType = Reflect.get(update, "sessionUpdate"); + return typeof updateType !== "string" || !transientAcpUpdates.has(updateType); + } + + if (nativeType === "message.part.updated") { + const properties = Reflect.get(payload, "properties"); + if (typeof properties !== "object" || properties === null) return true; + const part = Reflect.get(properties, "part"); + if (typeof part !== "object" || part === null) return true; + const partType = Reflect.get(part, "type"); + return partType !== "text" && partType !== "reasoning"; + } + + return true; } catch { return true; } @@ -566,10 +615,8 @@ export const makeEventNdjsonLogStore = Effect.fnUntraced(function* ( if (state.closed) { return Effect.succeed([{ flush: false }, state] as const); } - const pending = [ - ...state.pending, - { stream, threadSegment: resolveThreadSegment(threadId), line, bytes }, - ]; + const pending = state.pending; + pending.push({ stream, threadSegment: resolveThreadSegment(threadId), line, bytes }); const pendingBytes = state.pendingBytes + bytes; const flush = resolved.batchWindowMs === 0 || diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index ac9b5753..9633e90c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1126,12 +1126,27 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hellolo world"); + const appendedUpdate = mergeOpenCodeAssistantText("Hello", "Hello world"); + const changedUpdate = mergeOpenCodeAssistantText("Hello world", "Hello there"); + const staleUpdate = mergeOpenCodeAssistantText("Hello world", "Hello"); NodeAssert.deepEqual( [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], ["Hello", "lo world", ""], ); NodeAssert.equal(secondUpdate.latestText, "Hellolo world"); + NodeAssert.deepEqual(appendedUpdate, { + latestText: "Hello world", + deltaToEmit: " world", + }); + NodeAssert.deepEqual(changedUpdate, { + latestText: "Hello there", + deltaToEmit: "there", + }); + NodeAssert.deepEqual(staleUpdate, { + latestText: "Hello world", + deltaToEmit: "", + }); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 77f25984..fa4de6b8 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -460,9 +460,13 @@ export function mergeOpenCodeAssistantText( readonly deltaToEmit: string; } { const latestText = resolveLatestAssistantText(previousText, nextText); + const previous = previousText ?? ""; + const prefixLength = latestText.startsWith(previous) + ? previous.length + : commonPrefixLength(previous, latestText); return { latestText, - deltaToEmit: latestText.slice(commonPrefixLength(previousText ?? "", latestText)), + deltaToEmit: latestText.slice(prefixLength), }; } diff --git a/apps/server/src/provider/acp/AcpNativeLogging.test.ts b/apps/server/src/provider/acp/AcpNativeLogging.test.ts index 559b4c50..5ce0482f 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.test.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.test.ts @@ -28,6 +28,7 @@ nodeServicesIt("ACP native logging", (it) => { nativeEventLogger, provider: ProviderDriverKind.make("cursor"), threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, }); const secret = "secret-token-value"; const requestLogger = logger.requestLogger; @@ -67,6 +68,174 @@ nodeServicesIt("ACP native logging", (it) => { }), ); + it.effect("keeps request diagnostics without enabling full protocol logging", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("grok"), + threadId: ThreadId.make("thread-1"), + }); + + assert.isUndefined(logger.protocolLogging); + const requestLogger = logger.requestLogger; + assert.exists(requestLogger); + if (!requestLogger) return; + yield* requestLogger({ + method: "session/prompt", + payload: {}, + status: "started", + }); + assert.lengthOf(records, 1); + }), + ); + + it.effect("drops transient ACP chunks before formatting verbose protocol logs", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + for (const updateType of ["agent_message_chunk", "agent_thought_chunk"] as const) { + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${encodeUnknownJson({ + method: "session/update", + params: { update: { sessionUpdate: updateType } }, + })}\n`, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: updateType } }, + }, + ], + }); + } + + assert.lengthOf(records, 0); + + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: "tool_call" } }, + }, + ], + }); + assert.lengthOf(records, 1); + }), + ); + + it.effect("keeps mixed and incomplete raw diagnostics", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("cursor"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + const transient = encodeUnknownJson({ + method: "session/update", + params: { update: { sessionUpdate: "agent_message_chunk" } }, + }); + const lifecycle = encodeUnknownJson({ method: "session/new", params: {} }); + + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${transient}\n${lifecycle}\n`, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: transient, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "raw", + payload: `${transient}\n{malformed}\n`, + }); + + assert.lengthOf(records, 3); + }), + ); + + it.effect("filters transient entries from mixed decoded batches", () => + Effect.gen(function* () { + const records: Array = []; + const makeLogger = yield* makeAcpNativeLoggerFactory(); + const logger = makeLogger({ + nativeEventLogger: { + filePath: "/tmp/provider-native.ndjson", + write: (event) => Effect.sync(() => void records.push(event)), + close: () => Effect.void, + }, + provider: ProviderDriverKind.make("grok"), + threadId: ThreadId.make("thread-1"), + verboseProtocolLogging: true, + }); + const protocolLogger = logger.protocolLogging?.logger; + assert.exists(protocolLogger); + if (!protocolLogger) return; + + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: [ + { + _tag: "Request", + tag: "session/update", + payload: { update: { sessionUpdate: "agent_thought_chunk" } }, + }, + { + _tag: "Request", + tag: "session/new", + payload: {}, + }, + ], + }); + + assert.lengthOf(records, 1); + assert.include(encodeUnknownJson(records), '"itemCount":1'); + }), + ); + it.effect("logs a structural tag when the native writer defects", () => { const messages: Array = []; const logCapture = Logger.make(({ message }) => { diff --git a/apps/server/src/provider/acp/AcpNativeLogging.ts b/apps/server/src/provider/acp/AcpNativeLogging.ts index 27fc709e..f766e057 100644 --- a/apps/server/src/provider/acp/AcpNativeLogging.ts +++ b/apps/server/src/provider/acp/AcpNativeLogging.ts @@ -9,6 +9,8 @@ import type * as EffectAcpProtocol from "effect-acp/protocol"; import type { EventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; +const transientProtocolUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); + function structuralMethod(value: string): string { return value.length <= 128 && /^[A-Za-z][A-Za-z0-9._:/-]*$/.test(value) ? value : "unknown"; } @@ -64,12 +66,61 @@ function formatProtocolLogPayload(event: EffectAcpProtocol.AcpProtocolLogEvent) }; } +function isTransientProtocolMessage(message: unknown): boolean { + if (typeof message !== "object" || message === null) return false; + const method = Reflect.get(message, "tag") ?? Reflect.get(message, "method"); + if (method !== "session/update") return false; + + const payload = Reflect.get(message, "payload") ?? Reflect.get(message, "params"); + if (typeof payload !== "object" || payload === null) return false; + const update = Reflect.get(payload, "update"); + if (typeof update !== "object" || update === null) return false; + const updateType = Reflect.get(update, "sessionUpdate"); + return typeof updateType === "string" && transientProtocolUpdates.has(updateType); +} + +function rawChunkContainsOnlyTransientMessages(payload: string): boolean { + const lines = payload.split("\n"); + const remainder = lines.pop() ?? ""; + if (remainder.trim().length > 0) return false; + + const messages: Array = []; + for (const line of lines) { + if (line.trim().length === 0) continue; + try { + messages.push(JSON.parse(line)); + } catch { + return false; + } + } + return messages.length > 0 && messages.every(isTransientProtocolMessage); +} + +function filterTransientProtocolLog( + event: EffectAcpProtocol.AcpProtocolLogEvent, +): EffectAcpProtocol.AcpProtocolLogEvent | undefined { + if (event.direction !== "incoming") return event; + + if (event.stage === "raw" && typeof event.payload === "string") { + return rawChunkContainsOnlyTransientMessages(event.payload) ? undefined : event; + } + + if (event.stage !== "decoded") return event; + if (!Array.isArray(event.payload)) { + return isTransientProtocolMessage(event.payload) ? undefined : event; + } + + const payload = event.payload.filter((message) => !isTransientProtocolMessage(message)); + return payload.length === 0 ? undefined : { ...event, payload }; +} + export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory")(function* () { const crypto = yield* Crypto.Crypto; return (input: { readonly nativeEventLogger: EventNdjsonLogger | undefined; readonly provider: ProviderDriverKind; readonly threadId: ThreadId; + readonly verboseProtocolLogging?: boolean; }): Pick => { const writeNativeAcpLog = (logInput: { readonly kind: "request" | "protocol"; @@ -111,16 +162,20 @@ export const makeAcpNativeLoggerFactory = Effect.fn("makeAcpNativeLoggerFactory" kind: "request", payload: formatRequestLogPayload(event), }), - ...(input.nativeEventLogger + ...(input.nativeEventLogger && input.verboseProtocolLogging ? { protocolLogging: { logIncoming: true, logOutgoing: true, - logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => - writeNativeAcpLog({ - kind: "protocol", - payload: formatProtocolLogPayload(event), - }), + logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) => { + const filtered = filterTransientProtocolLog(event); + return filtered + ? writeNativeAcpLog({ + kind: "protocol", + payload: formatProtocolLogPayload(filtered), + }) + : Effect.void; + }, } satisfies NonNullable, } : {}), diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts index f0d0254e..d6030a0d 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts @@ -44,7 +44,7 @@ describe("resolveNativeSampleIntervalMs", () => { expect(resolveNativeSampleIntervalMs({ ...basePower, onBattery: "true" }, 1)).toBe(5_000); }); - it("keeps unknown background telemetry cheap but serves live diagnostics at 1Hz", () => { + it("slows background telemetry and serves live diagnostics at 1Hz", () => { const unknown: HostPowerSnapshot = { ...basePower, source: "unknown", @@ -58,7 +58,8 @@ describe("resolveNativeSampleIntervalMs", () => { 0, ), ).toBe(5_000); - expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(1_000); + expect(resolveNativeSampleIntervalMs(basePower, 0)).toBe(5_000); + expect(resolveNativeSampleIntervalMs(basePower, 1)).toBe(1_000); }); }); diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index 7c05dbb2..eb83f3b4 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -268,7 +268,7 @@ export function resolveNativeSampleIntervalMs( return CONSTRAINED_SAMPLE_INTERVAL_MS; } if (snapshot.onBattery === "true") return BATTERY_SAMPLE_INTERVAL_MS; - return SAMPLE_INTERVAL_MS; + return liveSubscriberCount > 0 ? SAMPLE_INTERVAL_MS : UNKNOWN_BACKGROUND_SAMPLE_INTERVAL_MS; } export function commitCollectionControlUpdate( @@ -462,13 +462,16 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu return Effect.gen(function* () { const nativeSnapshot = { generation, snapshot: event } satisfies NativeTelemetrySnapshot; const sampledAt = DateTime.makeUnsafe(event.sampledAtUnixMs); - yield* Ref.update(state, (current) => ({ - ...current, - status: "healthy" as const, - lastSampleAt: Option.some(sampledAt), - lastError: Option.none(), - })); - yield* publishHealth; + const healthChanged = yield* Ref.modify(state, (current) => [ + current.status !== "healthy" || Option.isSome(current.lastError), + { + ...current, + status: "healthy" as const, + lastSampleAt: Option.some(sampledAt), + lastError: Option.none(), + }, + ]); + if (healthChanged) yield* publishHealth; yield* PubSub.publish(snapshots, nativeSnapshot); if (event.requestId) { const deferred = yield* Ref.modify(pendingSamples, (pending) => { @@ -485,15 +488,18 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu case "historyChunk": return Effect.gen(function* () { const latestSnapshot = event.snapshots.at(-1); - yield* Ref.update(state, (current) => ({ - ...current, - status: "healthy" as const, - lastSampleAt: latestSnapshot - ? Option.some(DateTime.makeUnsafe(latestSnapshot.sampledAtUnixMs)) - : current.lastSampleAt, - lastError: Option.none(), - })); - yield* publishHealth; + const healthChanged = yield* Ref.modify(state, (current) => [ + current.status !== "healthy" || Option.isSome(current.lastError), + { + ...current, + status: "healthy" as const, + lastSampleAt: latestSnapshot + ? Option.some(DateTime.makeUnsafe(latestSnapshot.sampledAtUnixMs)) + : current.lastSampleAt, + lastError: Option.none(), + }, + ]); + if (healthChanged) yield* publishHealth; const completed = yield* Ref.modify(pendingHistories, (pending) => { const request = pending.get(event.requestId); if (!request) return [Option.none(), pending] as const; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 90c4af5a..db5669f8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -462,6 +462,7 @@ function trace2ChildKey(record: Record): string | null { } const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); +const decodeTrace2Record = decodeJsonResult(Trace2Record); const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( input: Pick, @@ -496,7 +497,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( return; } - const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); + const traceRecord = decodeTrace2Record(trimmedLine); if (Result.isFailure(traceRecord)) { yield* Effect.logDebug( `GitVcsDriver.trace2: failed to parse trace line for ${input.operation} in ${input.cwd} (${input.args.length} arguments)`, diff --git a/docs/internals/resource-telemetry.md b/docs/internals/resource-telemetry.md index 63c5ae22..12c42f5a 100644 --- a/docs/internals/resource-telemetry.md +++ b/docs/internals/resource-telemetry.md @@ -103,9 +103,9 @@ power-adaptive interval selected by the server. It collects: - resident and virtual memory; - cumulative process I/O counters. -On Linux, task/thread enumeration is disabled. Command lines are loaded only -when first needed. This avoids the expensive default behavior of walking every -`/proc//task/` directory on each refresh. +On Linux, task/thread enumeration is disabled. Command lines are refreshed with +each sample so process replacements remain visible. Disabling task enumeration +avoids walking every `/proc//task/` directory on each refresh. ### Process-tree selection @@ -142,7 +142,8 @@ The server adjusts native sampling without restarting the sidecar: - suspended, locked, low-power, or serious/critical thermal state: 15 seconds; - battery: 5 seconds; -- normal AC: 1 second; +- normal AC: 5 seconds in the background and 1 second while live diagnostics is + open; - unknown or stale power: 5 seconds in the background and 1 second while live diagnostics is open. diff --git a/native/resource-monitor/src/main.rs b/native/resource-monitor/src/main.rs index 0e5dd663..0596aea9 100644 --- a/native/resource-monitor/src/main.rs +++ b/native/resource-monitor/src/main.rs @@ -250,6 +250,10 @@ impl HistoryRecorder { max_retained_entries: usize, max_retained_bytes: usize, ) { + let clock_moved_backward = self + .snapshots + .back() + .is_some_and(|previous| previous.sampled_at_unix_ms > snapshot.sampled_at_unix_ms); let mut retained = snapshot.clone(); retained.request_id = None; self.retained_entry_count = self @@ -264,6 +268,7 @@ impl HistoryRecorder { max_snapshots, max_retained_entries, max_retained_bytes, + clock_moved_backward, ); } @@ -273,20 +278,24 @@ impl HistoryRecorder { max_snapshots: usize, max_retained_entries: usize, max_retained_bytes: usize, + clock_moved_backward: bool, ) { - let mut future_entry_count = 0usize; - let mut future_bytes = 0usize; - self.snapshots.retain(|snapshot| { - let keep = snapshot.sampled_at_unix_ms <= now_ms; - if !keep { - future_entry_count = - future_entry_count.saturating_add(snapshot.retained_entry_count()); - future_bytes = future_bytes.saturating_add(snapshot.estimated_history_bytes()); - } - keep - }); - self.retained_entry_count = self.retained_entry_count.saturating_sub(future_entry_count); - self.retained_bytes = self.retained_bytes.saturating_sub(future_bytes); + if clock_moved_backward { + let mut future_entry_count = 0usize; + let mut future_bytes = 0usize; + self.snapshots.retain(|snapshot| { + let keep = snapshot.sampled_at_unix_ms <= now_ms; + if !keep { + future_entry_count = + future_entry_count.saturating_add(snapshot.retained_entry_count()); + future_bytes = future_bytes.saturating_add(snapshot.estimated_history_bytes()); + } + keep + }); + self.retained_entry_count = + self.retained_entry_count.saturating_sub(future_entry_count); + self.retained_bytes = self.retained_bytes.saturating_sub(future_bytes); + } while self.snapshots.front().is_some_and(|snapshot| { snapshot.sampled_at_unix_ms < now_ms.saturating_sub(HISTORY_RETENTION_MS) diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index a66cc752..7ca86b06 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -135,6 +135,41 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("keeps only recent raw notifications after their callbacks run", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const handled = yield* Deferred.make(); + let handledCount = 0; + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + onNotification: () => + Effect.sync(() => ++handledCount).pipe( + Effect.flatMap((count) => + count === 64 ? Deferred.succeed(handled, undefined).pipe(Effect.asVoid) : Effect.void, + ), + ), + }); + + const messages = Array.from({ length: 64 }, (_, index) => + encodeUnknownJsonString({ + jsonrpc: "2.0", + method: "x/performance", + params: { index }, + }), + ); + yield* Queue.offer(input, encoder.encode(`${messages.join("\n")}\n`)); + yield* Deferred.await(handled); + + const retained = yield* transport.incoming.pipe(Stream.take(32), Stream.runCollect); + + assert.equal(handledCount, 64); + assert.equal(retained.length, 32); + assert.deepEqual(retained[0]?.params, { index: 32 }); + assert.deepEqual(retained[31]?.params, { index: 63 }); + }), + ); + it.effect("keeps invalid core notification values only in the schema cause", () => Effect.gen(function* () { const secret = "acp-core-notification-secret-sentinel"; diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index d61641fb..44a48bd1 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -76,6 +76,7 @@ const decodeElicitationComplete = Schema.decodeUnknownEffect( AcpSchema.ElicitationCompleteNotification, ); const parserFactory = RpcSerialization.ndJsonRpc(); +const MAX_BUFFERED_RAW_NOTIFICATIONS = 32; export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* ( options: AcpPatchedProtocolOptions, @@ -83,7 +84,9 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const parser = parserFactory.makeUnsafe(); const serverQueue = yield* Queue.unbounded(); const clientQueue = yield* Queue.unbounded(); - const notificationQueue = yield* Queue.unbounded(); + const notificationQueue = yield* Queue.sliding( + MAX_BUFFERED_RAW_NOTIFICATIONS, + ); const disconnects = yield* Queue.unbounded(); const outgoing = yield* Queue.unbounded>(); const nextRequestId = yield* Ref.make(1); @@ -408,11 +411,14 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi yield* options.stdio.stdin.pipe( Stream.runForEach((data) => - logProtocol({ - direction: "incoming", - stage: "raw", - payload: typeof data === "string" ? data : new TextDecoder().decode(data), - }).pipe( + (options.logIncoming + ? logProtocol({ + direction: "incoming", + stage: "raw", + payload: typeof data === "string" ? data : new TextDecoder().decode(data), + }) + : Effect.void + ).pipe( Effect.flatMap(() => Effect.try({ try: () => diff --git a/packages/effect-codex-app-server/src/protocol.test.ts b/packages/effect-codex-app-server/src/protocol.test.ts index a7e0397b..7249afff 100644 --- a/packages/effect-codex-app-server/src/protocol.test.ts +++ b/packages/effect-codex-app-server/src/protocol.test.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Stdio from "effect/Stdio"; import * as Stream from "effect/Stream"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -290,6 +291,365 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { }), ); + it.effect("routes a large notification fragmented across thousands of input chunks", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const notifications: Array = []; + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onNotification: (notification) => + Effect.sync(() => { + notifications.push(notification); + }), + }); + const response = yield* transport.request("thread/read", {}).pipe(Effect.forkScoped); + yield* Queue.take(output); + + const notification = { + method: "turn/diff/updated", + params: { + threadId: "thread-1", + turnId: "turn-1", + diff: "x".repeat(4 * 1024 * 1024), + }, + }; + const bytes = encoder.encode( + `${encodeUnknownJsonString(notification)}\n${encodeUnknownJsonString({ id: 1, result: { ok: true } })}\n`, + ); + for (let offset = 0; offset < bytes.length; offset += 1024) { + yield* Queue.offer(input, bytes.subarray(offset, offset + 1024)); + } + + assert.deepEqual(yield* Fiber.join(response), { ok: true }); + assert.deepEqual(notifications, [notification]); + }), + ); + + it.effect.each([1, 7, 1024])( + "preserves JSONL framing and UTF-8 across %i-byte input chunks", + (chunkSize) => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const notifications: Array = []; + const rawLines: Array = []; + const termination = yield* Deferred.make(); + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + logIncoming: true, + logger: (event) => + Effect.sync(() => { + if (event.stage === "raw") { + rawLines.push(event.payload); + } + }), + onNotification: (notification) => + Effect.sync(() => { + notifications.push(notification); + }), + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + + const firstLine = '{"method":"x/first",\r"params":{"text":"hΓ©πŸ™‚"}}'; + const secondLine = '{"method":"x/second","params":{"value":2}}'; + const finalLine = '{"method":"x/final","params":{"text":"ζœ€εΎŒ"}}\r'; + const bytes = encoder.encode(`\n \t\r\n${firstLine}\r\n\n${secondLine}\n${finalLine}`); + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + yield* Queue.offer(input, bytes.subarray(offset, offset + chunkSize)); + } + yield* Queue.end(input); + + assert.instanceOf( + yield* Deferred.await(termination), + CodexError.CodexAppServerInputStreamEndedError, + ); + assert.deepEqual(notifications, [ + { method: "x/first", params: { text: "hΓ©πŸ™‚" } }, + { method: "x/second", params: { value: 2 } }, + { method: "x/final", params: { text: "ζœ€εΎŒ" } }, + ]); + assert.deepEqual(rawLines, [firstLine, secondLine, finalLine]); + }), + ); + + it.effect("reports a malformed fragmented final line before input stream termination", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const termination = yield* Deferred.make(); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), + }); + const response = yield* transport.request("thread/read", {}).pipe(Effect.forkScoped); + yield* Queue.take(output); + + yield* Queue.offer(input, encoder.encode('{"id":1,')); + yield* Queue.offer(input, encoder.encode('"result":')); + yield* Queue.end(input); + + const error = yield* Deferred.await(termination); + assert.instanceOf(error, CodexError.CodexAppServerProtocolParseError); + assert.equal(error.operation, "decode-wire-message"); + const responseError = yield* Fiber.join(response).pipe( + Effect.match({ + onFailure: (failure) => failure, + onSuccess: () => assert.fail("Expected the malformed response to fail the request"), + }), + ); + assert.strictEqual(responseError, error); + }), + ); + + it.effect("keeps only recent raw notifications after their callbacks run", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const handled = yield* Deferred.make(); + let handledCount = 0; + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onNotification: () => + Effect.sync(() => ++handledCount).pipe( + Effect.flatMap((count) => + count === 64 ? Deferred.succeed(handled, undefined).pipe(Effect.asVoid) : Effect.void, + ), + ), + }); + + const messages = Array.from({ length: 64 }, (_, index) => + encodeUnknownJsonString({ + method: "item/agentMessage/delta", + params: { index }, + }), + ); + yield* Queue.offer(input, encoder.encode(`${messages.join("\n")}\n`)); + yield* Deferred.await(handled); + + const retained = yield* transport.incomingNotifications.pipe( + Stream.take(32), + Stream.runCollect, + ); + + assert.equal(handledCount, 64); + assert.equal(retained.length, 32); + assert.deepEqual(retained[0]?.params, { index: 32 }); + assert.deepEqual(retained[31]?.params, { index: 63 }); + }), + ); + + it.effect("keeps processing protocol messages while an approval is pending", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const approvalStarted = yield* Deferred.make(); + const approvalDecision = yield* Deferred.make<{ readonly decision: string }>(); + const notificationReceived = yield* Deferred.make(); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Deferred.succeed(approvalStarted, undefined).pipe( + Effect.andThen(Deferred.await(approvalDecision)), + ), + onNotification: () => Deferred.succeed(notificationReceived, undefined).pipe(Effect.asVoid), + }); + + const pendingRequest = yield* transport.request("thread/read", {}).pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.offer( + input, + encoder.encode( + `${[ + encodeUnknownJsonString({ id: 7, method: "item/tool/requestUserInput", params: {} }), + encodeUnknownJsonString({ method: "item/agentMessage/delta", params: { delta: "ok" } }), + encodeUnknownJsonString({ id: 1, result: { threadId: "thread-1" } }), + ].join("\n")}\n`, + ), + ); + + yield* Deferred.await(approvalStarted); + yield* Deferred.await(notificationReceived); + assert.deepEqual(yield* Fiber.join(pendingRequest), { threadId: "thread-1" }); + + yield* Deferred.succeed(approvalDecision, { decision: "accept" }); + assert.deepEqual(yield* decodeJson(yield* Queue.take(output)), { + id: 7, + result: { decision: "accept" }, + }); + }), + ); + + it.effect("rejects incoming requests after the active handler limit is reached", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const handlersStarted = yield* Deferred.make(); + const releaseHandlers = yield* Deferred.make(); + let activeHandlers = 0; + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Effect.sync(() => ++activeHandlers).pipe( + Effect.flatMap((count) => + count === 32 + ? Deferred.succeed(handlersStarted, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.andThen(Deferred.await(releaseHandlers)), + Effect.as({ decision: "accept" }), + ), + }); + + const requests = Array.from({ length: 33 }, (_, index) => + encodeUnknownJsonString({ + id: index + 1, + method: "item/tool/requestUserInput", + params: {}, + }), + ); + yield* Queue.offer(input, encoder.encode(`${requests.join("\n")}\n`)); + yield* Deferred.await(handlersStarted); + + assert.deepEqual(yield* decodeJson(yield* Queue.take(output)), { + id: 33, + error: { + code: -32001, + message: "Too many Codex requests are already active.", + }, + }); + assert.equal(activeHandlers, 32); + + yield* Deferred.succeed(releaseHandlers, undefined); + yield* Effect.forEach(Array.from({ length: 32 }), () => Queue.take(output), { + discard: true, + }); + }), + ); + + it.effect("interrupts pending request handlers when the protocol terminates", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const approvalStarted = yield* Deferred.make(); + const approvalInterrupted = yield* Deferred.make(); + const terminated = yield* Deferred.make(); + yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Deferred.succeed(approvalStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(approvalInterrupted, undefined).pipe(Effect.asVoid), + ), + ), + onTermination: () => Deferred.succeed(terminated, undefined).pipe(Effect.asVoid), + }); + + yield* Queue.offer( + input, + encodeJsonl({ id: 7, method: "item/tool/requestUserInput", params: {} }), + ); + yield* Deferred.await(approvalStarted); + yield* Queue.end(input); + + yield* Deferred.await(approvalInterrupted); + yield* Deferred.await(terminated); + }), + ); + + it.effect("rejects outgoing messages after an approval response cannot be encoded", () => + Effect.gen(function* () { + const { stdio: baseStdio, input } = yield* makeInMemoryStdio(); + const terminated = yield* Deferred.make(); + const readerStopped = yield* Deferred.make(); + let notificationCount = 0; + let requestCount = 0; + const stdio = Stdio.make({ + args: baseStdio.args, + stdin: baseStdio.stdin.pipe( + Stream.ensuring(Deferred.succeed(readerStopped, undefined).pipe(Effect.asVoid)), + ), + stdout: baseStdio.stdout, + stderr: baseStdio.stderr, + }); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Effect.sync(() => ++requestCount).pipe( + Effect.map((count) => (count === 1 ? { invalid: 1n } : { ok: true })), + ), + onNotification: () => Effect.sync(() => notificationCount++).pipe(Effect.asVoid), + onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid), + }); + + yield* Queue.offer( + input, + encodeJsonl({ id: 7, method: "item/tool/requestUserInput", params: {} }), + ); + + const failure = yield* Deferred.await(terminated); + assert.instanceOf(failure, CodexError.CodexAppServerProtocolParseError); + const requestFailure = yield* transport.request("thread/read", {}).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected a terminated protocol request to fail"), + }), + ); + const notificationFailure = yield* transport.notify("initialized").pipe(Effect.flip); + assert.strictEqual(requestFailure, failure); + assert.strictEqual(notificationFailure, failure); + yield* Deferred.await(readerStopped); + + yield* Queue.offer( + input, + encoder.encode( + `${[ + encodeUnknownJsonString({ method: "x/late-notification" }), + encodeUnknownJsonString({ id: 8, method: "x/late-request" }), + ].join("\n")}\n`, + ), + ); + + assert.equal(notificationCount, 0); + assert.equal(requestCount, 1); + assert.equal(yield* Queue.size(input), 1); + }), + ); + + it.effect("fails pending requests before interrupted handler cleanup completes", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const handlerStarted = yield* Deferred.make(); + const finalizerStarted = yield* Deferred.make(); + const releaseFinalizer = yield* Deferred.make(); + const terminated = yield* Deferred.make(); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio, + onRequest: () => + Deferred.succeed(handlerStarted, undefined).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => + Deferred.succeed(finalizerStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFinalizer)), + ), + ), + ), + onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid), + }); + const pending = yield* transport.request("thread/read", {}).pipe(Effect.forkScoped); + yield* Queue.take(output); + yield* Queue.offer(input, encodeJsonl({ id: 7, method: "x/approval" })); + yield* Deferred.await(handlerStarted); + yield* Queue.end(input); + + const failure = yield* Deferred.await(terminated); + yield* Deferred.await(finalizerStarted); + const pendingFailure = yield* Fiber.join(pending).pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected the pending request to fail"), + }), + ); + assert.strictEqual(pendingFailure, failure); + + yield* Deferred.succeed(releaseFinalizer, undefined); + }), + ); + it.effect("surfaces JSON encoding failures as protocol parse errors", () => Effect.gen(function* () { const { stdio } = yield* makeInMemoryStdio(); diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts index 17bfaed2..5572e61a 100644 --- a/packages/effect-codex-app-server/src/protocol.ts +++ b/packages/effect-codex-app-server/src/protocol.ts @@ -1,6 +1,8 @@ import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; @@ -13,6 +15,7 @@ import { JsonRpcId, JsonRpcResponseEnvelope } from "./_internal/shared.ts"; const isJsonRpcId = Schema.is(JsonRpcId); const isJsonRpcResponseEnvelope = Schema.is(JsonRpcResponseEnvelope); const isCodexAppServerError = Schema.is(CodexError.CodexAppServerError); +const MAX_BUFFERED_RAW_MESSAGES = 32; export interface CodexAppServerProtocolLogEvent { readonly direction: "incoming" | "outgoing"; @@ -152,13 +155,20 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa function* ( options: CodexAppServerPatchedProtocolOptions, ): Effect.fn.Return { + const protocolScope = yield* Scope.Scope; + const requestHandlerScope = yield* Scope.fork(protocolScope, "parallel"); const outgoing = yield* Queue.unbounded>(); - const incomingNotifications = yield* Queue.unbounded(); - const incomingRequests = yield* Queue.unbounded(); + const incomingNotifications = + yield* Queue.sliding(MAX_BUFFERED_RAW_MESSAGES); + const incomingRequests = + yield* Queue.sliding(MAX_BUFFERED_RAW_MESSAGES); const pending = yield* Ref.make(new Map()); const nextRequestId = yield* Ref.make(1); const remainder = yield* Ref.make(""); const terminationHandled = yield* Ref.make(false); + const terminationFailure = yield* Ref.make(Option.none()); + const terminationSignal = yield* Deferred.make(); + const activeRequestHandlers = yield* Ref.make(0); const logProtocol = (event: CodexAppServerProtocolLogEvent) => { if (event.direction === "incoming" && !options.logIncoming) { @@ -191,8 +201,14 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa return [ Effect.gen(function* () { const error = yield* classify(); + yield* Ref.set(terminationFailure, Option.some(error)); yield* failAllPending(error); yield* Queue.end(outgoing); + yield* Deferred.succeed(terminationSignal, undefined); + yield* Scope.close(requestHandlerScope, Exit.void).pipe( + Effect.forkIn(protocolScope, { startImmediately: true }), + Effect.asVoid, + ); if (options.onTermination) { yield* options.onTermination(error); } @@ -203,6 +219,9 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const offerOutgoing = (message: Record) => Effect.gen(function* () { + const failure = yield* Ref.get(terminationFailure); + if (Option.isSome(failure)) return yield* failure.value; + yield* logProtocol({ direction: "outgoing", stage: "decoded", @@ -214,7 +233,14 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa stage: "raw", payload: encoded, }); - yield* Queue.offer(outgoing, encoded).pipe(Effect.asVoid); + const accepted = yield* Queue.offer(outgoing, encoded); + if (!accepted) { + const closed = yield* Ref.get(terminationFailure); + return yield* Option.getOrElse( + closed, + () => new CodexError.CodexAppServerInputStreamEndedError({}), + ); + } }); const removePending = (requestId: string) => @@ -271,9 +297,24 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const handleRequest = (request: CodexAppServerIncomingRequest) => Queue.offer(incomingRequests, request).pipe( - Effect.andThen( - options.onRequest - ? options.onRequest(request).pipe( + Effect.flatMap(() => { + const handler = options.onRequest; + if (!handler) return Effect.void; + + return Ref.modify(activeRequestHandlers, (count) => + count >= MAX_BUFFERED_RAW_MESSAGES ? [false, count] : [true, count + 1], + ).pipe( + Effect.flatMap((accepted) => { + if (!accepted) { + return respondError( + request.id, + CodexError.CodexAppServerRequestError.overloaded( + "Too many Codex requests are already active.", + ), + ); + } + + return handler(request).pipe( Effect.matchEffect({ onFailure: (error) => respondError( @@ -285,9 +326,21 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa ), onSuccess: (result) => respond(request.id, result), }), - ) - : Effect.void, - ), + Effect.ensuring( + Ref.update(activeRequestHandlers, (count) => Math.max(0, count - 1)), + ), + Effect.catch((error) => + handleTermination(() => Effect.succeed(error)).pipe( + Effect.forkIn(protocolScope), + Effect.asVoid, + ), + ), + Effect.forkIn(requestHandlerScope, { startImmediately: true }), + Effect.asVoid, + ); + }), + ); + }), Effect.asVoid, ); @@ -297,22 +350,13 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa Effect.asVoid, ); - const routeMessage = ( - message: unknown, - ): Effect.Effect => { - if (isIncomingRequest(message)) { - return handleRequest(message); - } - if (isIncomingNotification(message)) { - return handleNotification(message); - } - if (isIncomingResponse(message)) { - return handleResponse(message); - } - return Effect.fail( - CodexError.CodexAppServerProtocolParseError.fromUnroutableMessage(message), - ); - }; + const routeMessage = Effect.fnUntraced(function* (message: unknown) { + if (Option.isSome(yield* Ref.get(terminationFailure))) return; + if (isIncomingRequest(message)) return yield* handleRequest(message); + if (isIncomingNotification(message)) return yield* handleNotification(message); + if (isIncomingResponse(message)) return yield* handleResponse(message); + return yield* CodexError.CodexAppServerProtocolParseError.fromUnroutableMessage(message); + }); const handleLine = (line: string): Effect.Effect => { if (line.trim().length === 0) { @@ -352,6 +396,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa }; yield* options.stdio.stdin.pipe( + Stream.interruptWhen(Deferred.await(terminationSignal)), Stream.decodeText(), Stream.runForEach((chunk) => Ref.modify(remainder, (current) => { From 44767425188c80a6ca41d5ecd57440b728c7700d Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Tue, 1 Sep 2026 12:11:41 +0200 Subject: [PATCH 02/15] perf(server): cut chatty tool-update frames by 90% (#8368) --- .../ActivityPayloadProjection.ts | 7 +- .../ThreadLiveEventCoalescer.test.ts | 171 +++++++++++++ .../orchestration/ThreadLiveEventCoalescer.ts | 207 +++++++++++++++ apps/server/src/server.test.ts | 240 ++++++++++++++++++ apps/server/src/ws.ts | 28 +- 5 files changed, 637 insertions(+), 16 deletions(-) create mode 100644 apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts create mode 100644 apps/server/src/orchestration/ThreadLiveEventCoalescer.ts diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index e6b0a42f..f391eb25 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -412,9 +412,6 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | * update within the turn β€” a later update belongs to a subsequent call that * reuses the same identity and is still in flight. Rows without a lifecycle * identity pass through, matching the clients, which never collapse them. - * Live `thread.activity-appended` events are untouched: updates still stream - * in real time and the completion supersedes them on the client as before. - * * Deliberate divergence from client collapse: clients fold only *adjacent* * lifecycle rows, so a superseded update separated from its completion by an * interleaved parallel call renders as its own row today, and this drop @@ -441,7 +438,7 @@ function dropSupersededToolUpdatedActivities( if (!identity) { continue; } - const key = `${activity.turnId ?? ""}${identity}`; + const key = `${activity.turnId ?? ""}\u0000${identity}`; const indices = completionIndicesByKey.get(key); if (indices) { indices.push(index); @@ -461,7 +458,7 @@ function dropSupersededToolUpdatedActivities( if (!identity) { return true; } - const indices = completionIndicesByKey.get(`${activity.turnId ?? ""}${identity}`); + const indices = completionIndicesByKey.get(`${activity.turnId ?? ""}\u0000${identity}`); return !indices?.some((completionIndex) => completionIndex > index); }); } diff --git a/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts b/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts new file mode 100644 index 00000000..0a991529 --- /dev/null +++ b/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts @@ -0,0 +1,171 @@ +import { + EventId, + MessageId, + ThreadId, + TurnId, + type OrchestrationEvent, + type OrchestrationThreadActivity, +} from "@t3tools/contracts"; +import { it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; +import { describe, expect } from "vite-plus/test"; + +import { + coalesceLiveToolUpdatedEvents, + makeThreadLiveEventCoalescer, +} from "./ThreadLiveEventCoalescer.ts"; + +const threadId = ThreadId.make("thread-coalescer-test"); +const turnId = TurnId.make("turn-coalescer-test"); + +function makeToolActivity( + sequence: number, + options: { + readonly kind?: "tool.updated" | "tool.completed"; + readonly toolCallId?: string; + readonly turnId?: TurnId; + } = {}, +): OrchestrationEvent { + const { + kind = "tool.updated", + toolCallId = "call-edit", + turnId: activityTurnId = turnId, + } = options; + const activity: OrchestrationThreadActivity = { + id: EventId.make(`activity-${sequence}`), + tone: "tool", + kind, + summary: "Editing app.ts", + payload: { + itemType: "file_change", + title: "Editing app.ts", + data: toolCallId ? { toolCallId } : {}, + }, + turnId: activityTurnId, + createdAt: "2026-01-01T00:00:01.000Z", + }; + return { + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.activity-appended", + payload: { threadId, activity }, + }; +} + +function makeMessage(sequence: number): OrchestrationEvent { + return { + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId, + messageId: MessageId.make(`message-${sequence}`), + role: "assistant", + text: "Still working", + turnId, + streaming: false, + createdAt: "2026-01-01T00:00:02.000Z", + updatedAt: "2026-01-01T00:00:02.000Z", + }, + }; +} + +describe("ThreadLiveEventCoalescer", () => { + it("coalesces only calls with a stable toolCallId", () => { + const events = [ + makeToolActivity(1, { toolCallId: "call-a" }), + makeToolActivity(2, { toolCallId: "call-b" }), + makeToolActivity(3, { toolCallId: "call-a" }), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3]); + }); + + it("preserves parallel same-label calls without a stable toolCallId", () => { + const events = [ + makeToolActivity(1, { toolCallId: "" }), + makeToolActivity(2, { toolCallId: "" }), + makeToolActivity(3, { kind: "tool.completed", toolCallId: "" }), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2, 3]); + }); + + it("does not coalesce stable tool calls across turns", () => { + const events = [ + makeToolActivity(1, { turnId: TurnId.make("turn-old") }), + makeToolActivity(2, { turnId: TurnId.make("turn-new") }), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([1, 2]); + }); + + it("flushes a stable update run before a completion boundary", () => { + const events = [ + makeToolActivity(1), + makeToolActivity(2), + makeToolActivity(3, { kind: "tool.completed" }), + makeToolActivity(4), + ]; + + expect(coalesceLiveToolUpdatedEvents(events).map((event) => event.sequence)).toEqual([2, 3, 4]); + }); + + it.effect("flushes pending tool updates as soon as an unrelated event arrives", () => + Effect.scoped( + Effect.gen(function* () { + const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" }); + const startedAt = yield* Clock.currentTimeMillis; + yield* Effect.forEach( + Array.from({ length: 10 }, (_, index) => index + 2), + (sequence) => + coalescer.offerAndWait({ kind: "event", event: makeToolActivity(sequence) }), + { discard: true }, + ); + yield* coalescer.offerAndWait({ kind: "event", event: makeMessage(12) }); + + expect(yield* Clock.currentTimeMillis).toBe(startedAt); + expect( + Array.from(yield* coalescer.takeAll).map((item) => + item.kind === "event" ? item.event.sequence : item.kind, + ), + ).toEqual([11, 12]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("flushes pending tool updates as soon as a synchronization marker arrives", () => + Effect.scoped( + Effect.gen(function* () { + const coalescer = yield* makeThreadLiveEventCoalescer({ coalesceWindow: "500 millis" }); + const startedAt = yield* Clock.currentTimeMillis; + yield* coalescer.offerAndWait({ kind: "event", event: makeToolActivity(2) }); + yield* coalescer.offerAndWait({ kind: "event", event: makeToolActivity(3) }); + yield* coalescer.offerAndWait({ kind: "synchronized" }); + + expect(yield* Clock.currentTimeMillis).toBe(startedAt); + expect( + Array.from(yield* coalescer.takeAll).map((item) => + item.kind === "event" ? item.event.sequence : item.kind, + ), + ).toEqual([3, "synchronized"]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts b/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts new file mode 100644 index 00000000..8271f6a5 --- /dev/null +++ b/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts @@ -0,0 +1,207 @@ +import type { OrchestrationEvent, OrchestrationThreadStreamItem } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Predicate from "effect/Predicate"; +import * as Queue from "effect/Queue"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import { projectActivityEvent } from "./ActivityPayloadProjection.ts"; + +const COALESCE_WINDOW = Duration.millis(50); +const MAX_PENDING_UPDATES = 512; + +export type ThreadLiveInput = + | { readonly kind: "event"; readonly event: OrchestrationEvent } + | { readonly kind: "synchronized" }; + +function isToolUpdated(event: OrchestrationEvent): boolean { + return ( + event.type === "thread.activity-appended" && event.payload.activity.kind === "tool.updated" + ); +} + +function asTrimmedString(value: unknown): string | null { + if (!Predicate.isString(value)) { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function stableToolCallIdentity(event: OrchestrationEvent): string | null { + if (event.type !== "thread.activity-appended") { + return null; + } + const payload = event.payload.activity.payload; + if (!Predicate.isObject(payload)) { + return null; + } + const data = Predicate.isObject(payload.data) ? payload.data : null; + return asTrimmedString(payload.toolCallId) ?? asTrimmedString(data?.toolCallId); +} + +/** + * Retain only the latest in-flight update for each stable tool-call id in a + * live run. Anonymous calls pass through because labels are not unique when + * tools execute in parallel. Survivors remain in sequence order. + */ +export function coalesceLiveToolUpdatedEvents( + events: ReadonlyArray, +): ReadonlyArray { + const survivors: Array = []; + let pendingUpdates: Array = []; + + const flushUpdates = () => { + const seen = new Set(); + const latestUpdates: Array = []; + for (let index = pendingUpdates.length - 1; index >= 0; index -= 1) { + const event = pendingUpdates[index]!; + const identity = stableToolCallIdentity(event); + const activity = + event.type === "thread.activity-appended" ? event.payload.activity : undefined; + const key = identity ? `${activity?.turnId ?? ""}\u0000${identity}` : null; + if (key && seen.has(key)) { + continue; + } + if (key) { + seen.add(key); + } + latestUpdates.push(event); + } + latestUpdates.reverse(); + survivors.push(...latestUpdates); + pendingUpdates = []; + }; + + for (const event of events) { + if (isToolUpdated(event)) { + pendingUpdates.push(event); + continue; + } + flushUpdates(); + survivors.push(event); + } + flushUpdates(); + return survivors; +} + +export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoalescer")( + function* (options?: { readonly coalesceWindow?: Duration.Input }) { + const output = yield* Queue.unbounded(); + const input = yield* Queue.unbounded<{ + readonly value: ThreadLiveInput; + readonly processed?: Deferred.Deferred; + }>(); + const mutex = yield* Semaphore.make(1); + const coalesceWindow = options?.coalesceWindow ?? COALESCE_WINDOW; + let pendingUpdates: Array = []; + let windowGeneration = 0; + let windowFiber: Fiber.Fiber | null = null; + + const cancelWindow = Effect.fn("ThreadLiveEventCoalescer.cancelWindow")(function* () { + const fiber = windowFiber; + if (!fiber) { + return; + } + windowFiber = null; + yield* Fiber.interrupt(fiber); + }); + + const flushPending = Effect.fn("ThreadLiveEventCoalescer.flushPending")(function* ( + boundary?: OrchestrationEvent, + ) { + const events = boundary ? [...pendingUpdates, boundary] : pendingUpdates; + pendingUpdates = []; + if (events.length === 0) { + return; + } + yield* Queue.offerAll( + output, + coalesceLiveToolUpdatedEvents(events).map((event) => ({ + kind: "event" as const, + event: projectActivityEvent(event), + })), + ); + }); + + const flushWindow = (generation: number) => + Effect.sleep(coalesceWindow).pipe( + Effect.andThen( + mutex.withPermits(1)( + Effect.suspend(() => (generation === windowGeneration ? flushPending() : Effect.void)), + ), + ), + Effect.ensuring( + Effect.sync(() => { + if (generation === windowGeneration) { + windowFiber = null; + } + }), + ), + ); + + const process = Effect.fn("ThreadLiveEventCoalescer.process")(function* ( + input: ThreadLiveInput, + ) { + yield* mutex.withPermits(1)( + Effect.gen(function* () { + if (input.kind === "event" && isToolUpdated(input.event)) { + pendingUpdates.push(input.event); + if (pendingUpdates.length === 1) { + const generation = ++windowGeneration; + windowFiber = yield* Effect.forkScoped(flushWindow(generation)); + } + if (pendingUpdates.length >= MAX_PENDING_UPDATES) { + yield* cancelWindow(); + windowGeneration += 1; + yield* flushPending(); + } + return; + } + + yield* cancelWindow(); + windowGeneration += 1; + // A non-update event closes the run immediately. The coalescer keeps + // that boundary after the final update from the run. + if (input.kind === "event") { + yield* flushPending(input.event); + } else { + yield* flushPending(); + yield* Queue.offer(output, { kind: "synchronized" }); + } + }), + ); + }); + + yield* Stream.fromQueue(input).pipe( + Stream.runForEach(({ value, processed }) => + process(value).pipe( + Effect.andThen(processed ? Deferred.succeed(processed, undefined) : Effect.void), + ), + ), + Effect.forkScoped, + ); + + const offer = (value: ThreadLiveInput) => Queue.offer(input, { value }).pipe(Effect.asVoid); + + // Synchronization callers wait for their marker to pass through the same + // ordered input queue before draining output produced ahead of it. + const offerAndWait = Effect.fn("ThreadLiveEventCoalescer.offerAndWait")(function* ( + value: ThreadLiveInput, + ) { + const processed = yield* Deferred.make(); + yield* Queue.offer(input, { value, processed }); + yield* Deferred.await(processed); + }); + + return { + offer, + offerAndWait, + stream: Stream.fromQueue(output), + takeAll: Queue.takeAll(output), + } as const; + }, +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2fa23ac0..458890af 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -18,6 +18,7 @@ import { ExternalLauncherCommandNotFoundError, OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, + type OrchestrationThreadActivity, type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, @@ -29,6 +30,7 @@ import { ProviderInstanceId, ResolvedKeybindingRule, ThreadId, + TurnId, WS_METHODS, WsRpcGroup, EditorId, @@ -185,6 +187,44 @@ const defaultModelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", } as const; + +const makeLiveToolActivityEvent = ( + sequence: number, + kind: "tool.updated" | "tool.completed" = "tool.updated", + options: { + readonly toolCallId?: string; + readonly title?: string; + readonly path?: string; + } = {}, +): Extract => { + const { toolCallId = "call-edit", title = "Editing app.ts", path = "src/app.ts" } = options; + const activity: OrchestrationThreadActivity = { + id: EventId.make(`activity-${sequence}`), + tone: "tool", + kind, + summary: title, + payload: { + itemType: "file_change", + title, + data: { toolCallId, path }, + }, + turnId: TurnId.make("turn-edit"), + createdAt: "2026-01-01T00:00:01.000Z", + }; + return { + sequence, + eventId: EventId.make(`event-tool-${sequence}`), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.activity-appended", + payload: { threadId: defaultThreadId, activity }, + }; +}; const testEnvironmentDescriptor = { environmentId: EnvironmentId.make("environment-test"), label: "Test environment", @@ -6164,6 +6204,206 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); + it.effect("coalesces buffered live tool updates to the latest state", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publishAll(liveEvents, [ + makeLiveToolActivityEvent(2), + makeLiveToolActivityEvent(3), + makeLiveToolActivityEvent(4), + ]); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.equal(items[1]?.kind, "event"); + assert.equal(items[1]?.kind === "event" ? items[1].event.sequence : null, 4); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("flushes more than one tool chunk before the synchronization marker", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publishAll(liveEvents, [ + ...Array.from({ length: 512 }, (_, index) => + makeLiveToolActivityEvent(index + 2), + ), + makeLiveToolActivityEvent(514, "tool.updated", { + toolCallId: "call-read", + title: "Reading server.test.ts", + path: "apps/server/src/server.test.ts", + }), + ]); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + requestCompletionMarker: true, + }).pipe(Stream.take(4), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.deepEqual( + items.slice(1, 3).map((item) => { + assert.equal(item?.kind, "event"); + if (item?.kind !== "event" || item.event.type !== "thread.activity-appended") { + return null; + } + return { + sequence: item.event.sequence, + summary: item.event.payload.activity.summary, + payload: item.event.payload.activity.payload, + }; + }), + [ + { + sequence: 513, + summary: "Editing app.ts", + payload: { + itemType: "file_change", + title: "Editing app.ts", + data: { + files: [{ path: "src/app.ts" }], + toolCallId: "call-edit", + }, + }, + }, + { + sequence: 514, + summary: "Reading server.test.ts", + payload: { + itemType: "file_change", + title: "Reading server.test.ts", + data: { + files: [{ path: "apps/server/src/server.test.ts" }], + toolCallId: "call-read", + }, + }, + }, + ], + ); + assert.deepEqual(items[3], { kind: "synchronized" }); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("flushes a tool update before an interleaved message", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const liveEvents = yield* PubSub.unbounded(); + const messageEvent = { + sequence: 3, + eventId: EventId.make("event-interleaved-message"), + aggregateKind: "thread", + aggregateId: defaultThreadId, + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId: defaultThreadId, + messageId: MessageId.make("message-interleaved"), + role: "assistant", + text: "Still working", + turnId: TurnId.make("turn-edit"), + streaming: false, + createdAt: "2026-01-01T00:00:02.000Z", + updatedAt: "2026-01-01T00:00:02.000Z", + }, + } satisfies Extract; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + streamDomainEvents: Stream.fromPubSub(liveEvents), + }, + projectionSnapshotQuery: { + getThreadDetailSnapshot: () => + Effect.gen(function* () { + yield* Effect.sleep("25 millis"); + yield* PubSub.publishAll(liveEvents, [ + makeLiveToolActivityEvent(2), + messageEvent, + makeLiveToolActivityEvent(4, "tool.completed"), + ]); + return Option.some({ snapshotSequence: 1, thread }); + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const items = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + }).pipe(Stream.take(4), Stream.runCollect), + ), + ).pipe(Effect.timeout("2 seconds")); + + assert.equal(items[0]?.kind, "snapshot"); + assert.deepEqual( + items + .slice(1) + .map((item) => (item.kind === "event" ? [item.event.sequence, item.event.type] : null)), + [ + [2, "thread.activity-appended"], + [3, "thread.message-sent"], + [4, "thread.activity-appended"], + ], + ); + assert.equal( + items[3]?.kind === "event" && items[3].event.type === "thread.activity-appended" + ? items[3].event.payload.activity.kind + : null, + "tool.completed", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + it.effect("subscribeThread sends a fresh snapshot instead of replaying a large gap", () => Effect.gen(function* () { let readEventsCalls = 0; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index edf78cd1..02c9da99 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -71,7 +71,11 @@ import { projectActivityEvent, projectThreadDetailSnapshot, } from "./orchestration/ActivityPayloadProjection.ts"; -import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; +import { makeThreadLiveEventCoalescer } from "./orchestration/ThreadLiveEventCoalescer.ts"; +import { + cleanupFailedUploadedAttachments, + normalizeDispatchCommand, +} from "./orchestration/Normalizer.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -1326,17 +1330,15 @@ const makeWsRpcLayer = ( Stream.filter(isThisThreadDetailEvent), Stream.map((event) => ({ kind: "event" as const, - event: projectActivityEvent(event), + event, })), ); // Attach live delivery before reading either replay or snapshot state. // Otherwise an event published while the snapshot is loading is lost. - const liveBuffer = yield* Queue.unbounded(); - yield* Effect.forkScoped( - liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))), - ); - const bufferedLiveStream = Stream.fromQueue(liveBuffer); + const liveBuffer = yield* makeThreadLiveEventCoalescer(); + yield* Effect.forkScoped(liveStream.pipe(Stream.runForEach(liveBuffer.offer))); + const bufferedLiveStream = liveBuffer.stream; // When the client already loaded the snapshot over HTTP it passes // that snapshot's sequence, and we resume the live subscription by @@ -1385,8 +1387,10 @@ const makeWsRpcLayer = ( input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), - ).pipe(Stream.drain), + liveBuffer + .offerAndWait({ kind: "synchronized" as const }) + .pipe(Effect.andThen(liveBuffer.takeAll)), + ).pipe(Stream.flatMap((items) => Stream.fromIterable(items))), bufferedLiveStream, ) : bufferedLiveStream; @@ -1427,8 +1431,10 @@ const makeWsRpcLayer = ( input.requestCompletionMarker === true ? Stream.concat( Stream.fromEffect( - Queue.offer(liveBuffer, { kind: "synchronized" as const }), - ).pipe(Stream.drain), + liveBuffer + .offerAndWait({ kind: "synchronized" as const }) + .pipe(Effect.andThen(liveBuffer.takeAll)), + ).pipe(Stream.flatMap((items) => Stream.fromIterable(items))), bufferedLiveStream, ) : bufferedLiveStream; From 285f74b5013d613f0178ff1284c565fe4731fd8b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 04:09:06 -0700 Subject: [PATCH 03/15] perf(server): skip full-message reads while streaming (#9032) --- .../Layers/ProjectionPipeline.test.ts | 59 +++++++++++++++-- .../Layers/ProjectionPipeline.ts | 33 +++++++--- .../Layers/ProjectionThreadMessages.test.ts | 65 +++++++++++++++++++ .../Layers/ProjectionThreadMessages.ts | 53 +++++++++++++++ .../Services/ProjectionThreadMessages.ts | 12 ++++ 5 files changed, 205 insertions(+), 17 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index bb5947aa..5886d81c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1175,6 +1175,8 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { const eventStore = yield* OrchestrationEventStore; const sql = yield* SqlClient.SqlClient; const now = "2026-01-01T00:00:00.000Z"; + const streamingAt = "2026-01-01T00:00:01.000Z"; + const completedAt = "2026-01-01T00:00:02.000Z"; yield* eventStore.append({ type: "project.created", @@ -1239,7 +1241,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { role: "assistant", text: "hello", turnId: null, - streaming: false, + streaming: true, createdAt: now, updatedAt: now, }, @@ -1252,7 +1254,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { eventId: EventId.make("evt-a4"), aggregateKind: "thread", aggregateId: ThreadId.make("thread-a"), - occurredAt: now, + occurredAt: streamingAt, commandId: CommandId.make("cmd-a4"), causationEventId: null, correlationId: CorrelationId.make("cmd-a4"), @@ -1264,18 +1266,61 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { text: " world", turnId: null, streaming: true, - createdAt: now, - updatedAt: now, + createdAt: streamingAt, + updatedAt: streamingAt, }, }); yield* projectionPipeline.bootstrap; yield* projectionPipeline.bootstrap; - const messageRows = yield* sql<{ readonly text: string }>` - SELECT text FROM projection_thread_messages WHERE message_id = 'message-a' + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make("evt-a5"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-a"), + occurredAt: completedAt, + commandId: CommandId.make("cmd-a5"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-a5"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-a"), + messageId: MessageId.make("message-a"), + role: "assistant", + text: "", + turnId: null, + streaming: false, + createdAt: completedAt, + updatedAt: completedAt, + }, + }); + + yield* projectionPipeline.bootstrap; + yield* projectionPipeline.bootstrap; + + const messageRows = yield* sql<{ + readonly text: string; + readonly isStreaming: number; + readonly createdAt: string; + readonly updatedAt: string; + }>` + SELECT + text, + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE message_id = 'message-a' `; - assert.deepEqual(messageRows, [{ text: "hello world" }]); + assert.deepEqual(messageRows, [ + { + text: "hello world", + isStreaming: 0, + createdAt: now, + updatedAt: completedAt, + }, + ]); const stateRows = yield* sql<{ readonly projector: string; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 7f3e4383..146abcd4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -950,21 +950,34 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti )(function* (event, attachmentSideEffects) { switch (event.type) { case "thread.message-sent": { + if (event.payload.streaming) { + const attachments = + event.payload.attachments !== undefined + ? yield* materializeAttachmentsForProjection({ + attachments: event.payload.attachments, + }) + : undefined; + yield* projectionThreadMessageRepository.appendStreaming({ + messageId: event.payload.messageId, + threadId: event.payload.threadId, + turnId: event.payload.turnId, + role: event.payload.role, + text: event.payload.text, + ...(attachments !== undefined ? { attachments: [...attachments] } : {}), + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ messageId: event.payload.messageId, }); const previousMessage = Option.getOrUndefined(existingMessage); const nextText = Option.match(existingMessage, { onNone: () => event.payload.text, - onSome: (message) => { - if (event.payload.streaming) { - return `${message.text}${event.payload.text}`; - } - if (event.payload.text.length === 0) { - return message.text; - } - return event.payload.text; - }, + onSome: (message) => + event.payload.text.length === 0 ? message.text : event.payload.text, }); const nextAttachments = event.payload.attachments !== undefined @@ -979,7 +992,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti role: event.payload.role, text: nextText, ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), - isStreaming: event.payload.streaming, + isStreaming: false, createdAt: previousMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index 0d731b33..9c39c92f 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -12,6 +12,71 @@ const layer = it.layer( ); layer("ProjectionThreadMessageRepository", (it) => { + it.effect("appends streaming text and applies attachment updates", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.make("thread-streaming-append"); + const messageId = MessageId.make("message-streaming-append"); + const createdAt = "2026-02-28T19:05:00.000Z"; + const attachments = [ + { + type: "image" as const, + id: "thread-streaming-append-att-1", + name: "example.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ]; + + yield* repository.appendStreaming({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "hello", + attachments, + createdAt, + updatedAt: createdAt, + }); + yield* repository.appendStreaming({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: " world", + createdAt: "2026-02-28T19:05:01.000Z", + updatedAt: "2026-02-28T19:05:01.000Z", + }); + + const rowWithPreservedAttachments = yield* repository.getByMessageId({ messageId }); + assert.equal(rowWithPreservedAttachments._tag, "Some"); + if (rowWithPreservedAttachments._tag === "Some") { + assert.deepEqual(rowWithPreservedAttachments.value.attachments, attachments); + } + + yield* repository.appendStreaming({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "", + attachments: [], + createdAt: "2026-02-28T19:05:02.000Z", + updatedAt: "2026-02-28T19:05:02.000Z", + }); + + const row = yield* repository.getByMessageId({ messageId }); + assert.equal(row._tag, "Some"); + if (row._tag === "Some") { + assert.equal(row.value.text, "hello world"); + assert.deepEqual(row.value.attachments, []); + assert.equal(row.value.createdAt, createdAt); + assert.equal(row.value.updatedAt, "2026-02-28T19:05:02.000Z"); + assert.isTrue(row.value.isStreaming); + } + }), + ); + it.effect("preserves existing attachments when upsert omits attachments", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index bc38a923..d7761416 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -9,6 +9,7 @@ import { ChatAttachment } from "@helmcode/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { + AppendStreamingProjectionThreadMessage, GetProjectionThreadMessageInput, ProjectionThreadMessageRepository, type ProjectionThreadMessageRepositoryShape, @@ -95,6 +96,50 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { }, }); + const appendStreamingProjectionThreadMessageRow = SqlSchema.void({ + Request: AppendStreamingProjectionThreadMessage, + execute: (row) => { + const nextAttachmentsJson = + row.attachments !== undefined ? JSON.stringify(row.attachments) : null; + return sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + ${row.messageId}, + ${row.threadId}, + ${row.turnId}, + ${row.role}, + ${row.text}, + ${nextAttachmentsJson}, + 1, + ${row.createdAt}, + ${row.updatedAt} + ) + ON CONFLICT (message_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + role = excluded.role, + text = projection_thread_messages.text || excluded.text, + attachments_json = COALESCE( + excluded.attachments_json, + projection_thread_messages.attachments_json + ), + is_streaming = 1, + updated_at = excluded.updated_at + `; + }, + }); + const getProjectionThreadMessageRow = SqlSchema.findOneOption({ Request: GetProjectionThreadMessageInput, Result: ProjectionThreadMessageDbRowSchema, @@ -151,6 +196,13 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), ); + const appendStreaming: ProjectionThreadMessageRepositoryShape["appendStreaming"] = (row) => + appendStreamingProjectionThreadMessageRow(row).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.appendStreaming:query"), + ), + ); + const getByMessageId: ProjectionThreadMessageRepositoryShape["getByMessageId"] = (input) => getProjectionThreadMessageRow(input).pipe( Effect.mapError( @@ -176,6 +228,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { return { upsert, + appendStreaming, getByMessageId, listByThreadId, deleteByThreadId, diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index 09924491..65381388 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -16,6 +16,7 @@ import { } from "@helmcode/contracts"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; +import * as Struct from "effect/Struct"; import type * as Option from "effect/Option"; import type * as Effect from "effect/Effect"; @@ -34,6 +35,12 @@ export const ProjectionThreadMessage = Schema.Struct({ }); export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type; +export const AppendStreamingProjectionThreadMessage = Schema.Struct( + Struct.omit(ProjectionThreadMessage.fields, ["isStreaming"]), +); +export type AppendStreamingProjectionThreadMessage = + typeof AppendStreamingProjectionThreadMessage.Type; + export const ListProjectionThreadMessagesInput = Schema.Struct({ threadId: ThreadId, }); @@ -62,6 +69,11 @@ export interface ProjectionThreadMessageRepositoryShape { message: ProjectionThreadMessage, ) => Effect.Effect; + /** Insert a streaming message or append text to its existing row. */ + readonly appendStreaming: ( + message: AppendStreamingProjectionThreadMessage, + ) => Effect.Effect; + /** * Read a projected thread message by id. */ From 960880652b2f584bcea28bdb1f839c8455f5865e Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:59:16 +0200 Subject: [PATCH 04/15] perf(server): scan only appended transcript bytes for usage summaries (#9024) Co-authored-by: Claude Fable 5 Co-authored-by: Theo Browne --- apps/server/src/usage/UsageService.test.ts | 226 ++++++++++++++++++ apps/server/src/usage/UsageService.ts | 163 +++++++++++-- apps/server/src/usage/usageScanCache.test.ts | 84 ++++++- apps/server/src/usage/usageScanCache.ts | 176 +++++++++++--- .../src/usage/usageTranscriptReader.test.ts | 210 ++++++++++++++++ .../server/src/usage/usageTranscriptReader.ts | 195 +++++++++++++-- 6 files changed, 970 insertions(+), 84 deletions(-) create mode 100644 apps/server/src/usage/UsageService.test.ts create mode 100644 apps/server/src/usage/usageTranscriptReader.test.ts diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts new file mode 100644 index 00000000..8fc86ee3 --- /dev/null +++ b/apps/server/src/usage/UsageService.test.ts @@ -0,0 +1,226 @@ +// @effect-diagnostics nodeBuiltinImport:off - the suite seeds and grows real +// transcript trees on disk, outside the service's Effect FileSystem. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Scheduler from "effect/Scheduler"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as UsageService from "./UsageService.ts"; + +function claudeLine(id: number, outputTokens: number): string { + return `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: `req_${id}`, + sessionId: "session-1", + message: { + id: `msg_${id}`, + model: "claude-fable-5", + usage: { input_tokens: 10, output_tokens: outputTokens }, + }, + })}\n`; +} + +const WINDOW: UsageSummaryInput = { + timeZone: "UTC", + sinceDay: UsageDay.make("2026-07-31"), + untilDay: UsageDay.make("2026-08-02"), +}; + +const setup = Effect.gen(function* () { + const home = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-service-test-")), + ); + yield* Effect.addFinalizer(() => + Effect.promise(() => NodeFSP.rm(home, { recursive: true, force: true })), + ); + const transcriptDir = NodePath.join(home, "claude", "projects", "proj"); + yield* Effect.promise(() => NodeFSP.mkdir(transcriptDir, { recursive: true })); + return { + home, + transcript: NodePath.join(transcriptDir, "session.jsonl"), + settings: { + providers: { + claudeAgent: { homePath: NodePath.join(home, "claude") }, + codex: { homePath: NodePath.join(home, "codex") }, + }, + }, + }; +}); + +const serviceLayers = (input: { + readonly prefix: string; + readonly home: string; + readonly settings: Parameters[0]; + readonly onRatesFetch?: () => void; +}) => + ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettings.layerTest(input.settings)), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + input.onRatesFetch?.(); + // Unparsable rates: every scan retries the fetch, which makes the + // fetch count a boundary-level observation of how many scans ran. + return HttpClientResponse.fromWeb(request, Response.json({})); + }), + ), + ), + ), + Layer.provideMerge( + Layer.succeed(HostProcessEnvironment, { GROK_HOME: NodePath.join(input.home, "grok") }), + ), + ); + +function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens: number } }[] }) { + return summary.buckets.reduce((sum, bucket) => sum + bucket.totals.outputTokens, 0); +} + +describe("UsageService", () => { + it.live("counts appended usage on a rescan of a grown transcript", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + const service = yield* UsageService.make.pipe( + Effect.provide(serviceLayers({ prefix: "usage-service-grow-test", home, settings })), + ); + + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 5); + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, claudeLine(2, 7))); + const second = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(second), 12); + }).pipe(Effect.scoped), + ); + + it.live("shares one scan between concurrent identical requests", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + let ratesFetches = 0; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-flight-test", + home, + settings, + onRatesFetch: () => { + ratesFetches += 1; + }, + }), + ), + ); + + const [first, second] = yield* Effect.all( + [service.readSummary(WINDOW), service.readSummary(WINDOW)], + { concurrency: 2 }, + ); + assert.deepStrictEqual(first, second); + assert.strictEqual(ratesFetches, 1); + + // A later request is fresh work again, not a stale cached answer. + yield* service.readSummary(WINDOW); + assert.strictEqual(ratesFetches, 2); + }).pipe(Effect.scoped), + ); + + it.live("does not orphan an in-flight scan when its first caller is interrupted", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-interruption-test", home, settings }), + ), + ); + + let orphanedAt: number | undefined; + for (let interruptAt = 1; interruptAt <= 31; interruptAt += 1) { + const tasks: Array<() => void> = []; + const dispatcher: Scheduler.SchedulerDispatcher = { + scheduleTask: (task) => tasks.push(task), + flush: () => { + let task: (() => void) | undefined; + while ((task = tasks.shift()) !== undefined) task(); + }, + }; + + let requestFiber: Fiber.Fiber | undefined; + let requestChecks = 0; + const scheduler: Scheduler.Scheduler = { + executionMode: "async", + makeDispatcher: () => dispatcher, + shouldYield: (fiber) => { + if (fiber !== requestFiber) return false; + requestChecks += 1; + if (requestChecks !== interruptAt) return false; + fiber.interruptUnsafe(); + return true; + }, + }; + + // Each candidate needs a distinct key because the broken case leaves + // its entry in the service's private in-flight map. The invalid window + // keeps the real scan synchronous once its detached fiber starts. + const input: UsageSummaryInput = { + ...WINDOW, + sinceDay: UsageDay.make("2026-09-01"), + untilDay: UsageDay.make(`2026-08-${String(interruptAt).padStart(2, "0")}`), + }; + const first = yield* service + .readSummary(input) + .pipe( + Effect.exit, + Effect.provideService(Scheduler.Scheduler, scheduler), + Effect.forkChild, + ); + requestFiber = first; + yield* Effect.yieldNow; + dispatcher.flush(); + + const second = yield* service.readSummary(input).pipe( + Effect.match({ + onFailure: (error) => error.reason, + onSuccess: () => "success" as const, + }), + Effect.provideService(Scheduler.Scheduler, scheduler), + Effect.forkChild, + ); + yield* Effect.yieldNow; + dispatcher.flush(); + const secondExit = second.pollUnsafe(); + if (secondExit === undefined) { + second.interruptUnsafe(); + orphanedAt = interruptAt; + break; + } + if (Exit.isFailure(secondExit)) { + assert.fail("the matching request fiber was interrupted"); + } + assert.strictEqual(secondExit.value, "invalidWindow"); + } + + assert.isUndefined( + orphanedAt, + `interruption left the next matching request pending at scheduler check ${orphanedAt}`, + ); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index a0d00837..83986bc7 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -7,7 +7,8 @@ * * Transcripts are append-only, so parsed records are memoised per file by * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm - * scans only reparse files that changed. + * scans only reparse files that changed, and a file that merely grew resumes + * from its cached parse position so only the appended bytes are read. * * @module UsageService */ @@ -25,6 +26,7 @@ import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -257,7 +259,14 @@ export const make = Effect.gen(function* () { ); }); - /** Parses one transcript, reusing the cached result when it is unchanged. */ + /** + * Parses one transcript, reusing the cached result when it is unchanged. + * + * A file that only grew re-parses from the cached position, so an actively + * written multi-hundred-megabyte rollout costs its appended bytes per scan + * rather than a full re-read. The reader verifies the position's guard bytes + * and silently restarts from byte 0 when they no longer match. + */ const readFileRecords = ( filePath: string, size: number, @@ -274,23 +283,85 @@ export const make = Effect.gen(function* () { cached.mtimeMs === mtimeMs && cached.provider === provider ) { - return cached.records; + return cached.tailRecords.length === 0 + ? cached.records + : [...cached.records, ...cached.tailRecords]; } - const parsed = yield* Effect.promise(() => readTranscriptRecords(filePath, provider)); + // Only a strictly grown file may resume. Same size with a new mtime, or + // a shrunken file, means rewritten content; re-parse it whole. + const resumeFrom = + cached !== undefined && cached.provider === provider && size > cached.size + ? cached.position + : undefined; + + const parsed = yield* Effect.promise(() => + readTranscriptRecords(filePath, provider, resumeFrom), + ); // A read failure is not an empty transcript: caching it under this // (size, mtime) would silently drop the file's usage until it changes. if (parsed === null) return []; - // Stored already de-duplicated within the file, which is 99% of all - // duplicates. The aggregator still runs the cross-file dedupe pass. - const records = dedupeWithinFile(parsed); - fileCache.set(filePath, { size, mtimeMs, provider, records }); + // Stored already de-duplicated within the file, which is 99% of all + // duplicates. The aggregator still runs the cross-file dedupe pass. One + // seen set spans the cached base, the new lines, and the tail so a + // resumed parse dedupes exactly like a full one. + const base = parsed.resumed && cached !== undefined ? cached.records : []; + const seen = new Set(); + const records = dedupeWithinFile([...base, ...parsed.records], seen); + const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); + + fileCache.set(filePath, { + size, + mtimeMs, + provider, + records, + tailRecords, + position: parsed.position, + }); cacheDirty = true; - return records; + return tailRecords.length === 0 ? records : [...records, ...tailRecords]; }); - const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + /** One provider directory's walk and parse, before rates are involved. */ + interface ScannedDir { + readonly provider: UsageProviderKind; + readonly dir: string; + readonly volumeId: string; + /** Parsed records per file, or `null` when the directory does not exist. */ + readonly files: + | readonly { readonly path: string; readonly records: readonly UsageRecord[] }[] + | null; + } + + const collectDirs = Effect.fn("UsageService.collectDirs")(function* (windowStartMs: number) { + // The home resolvers ask for `Path` themselves; satisfy them from the + // instance we already hold so the scan stays context-free. + const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); + const scanned: ScannedDir[] = []; + for (const { provider, dir, fileName } of dirs) { + const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + const exists = yield* fileSystem + .exists(dir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + if (!exists) { + scanned.push({ provider, dir, volumeId, files: null }); + continue; + } + const files = yield* Effect.promise(() => + listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), + ); + const parsedFiles: { path: string; records: readonly UsageRecord[] }[] = []; + for (const file of files) { + const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); + parsedFiles.push({ path: file.path, records }); + } + scanned.push({ provider, dir, volumeId, files: parsedFiles }); + } + return scanned; + }); + + const scanSummary = Effect.fn("UsageService.scanSummary")(function* (input: UsageSummaryInput) { if (input.sinceDay > input.untilDay) { return yield* new UsageReadError({ reason: "invalidWindow", @@ -323,13 +394,9 @@ export const make = Effect.gen(function* () { } const startedAtMs = yield* Clock.currentTimeMillis; - yield* ensureRates(); yield* ensureScanCacheLoaded; const hostId = NodeOS.hostname(); - // The home resolvers ask for `Path` themselves; satisfy them from the - // instance we already hold so `readSummary` stays context-free. - const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); const windowStart = DateTime.make(`${input.sinceDay}T00:00:00Z`); if (Option.isNone(windowStart)) { return yield* new UsageReadError({ @@ -340,6 +407,13 @@ export const make = Effect.gen(function* () { const windowStartMs = (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + // Pricing only matters once records are aggregated, so the rate table + // loads while transcripts stream instead of gating them: a cold rates + // fetch on a slow network no longer delays the scan by its own timeout. + const [, scannedDirs] = yield* Effect.all([ensureRates(), collectDirs(windowStartMs)], { + concurrency: 2, + }); + const aggregator = new UsageAggregator({ timeZone: input.timeZone, sinceDay: input.sinceDay, @@ -353,13 +427,8 @@ export const make = Effect.gen(function* () { const livePaths = new Set(); const walkedRoots: string[] = []; - for (const { provider, dir } of dirs) { - const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); - const exists = yield* fileSystem - .exists(dir) - .pipe(Effect.catchCause(() => Effect.succeed(false))); - - if (!exists) { + for (const { provider, dir, volumeId, files } of scannedDirs) { + if (files === null) { sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, status: "missing", @@ -373,7 +442,6 @@ export const make = Effect.gen(function* () { } walkedRoots.push(dir); - const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a @@ -382,13 +450,12 @@ export const make = Effect.gen(function* () { for (const file of files) { livePaths.add(file.path); - const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); - if (records.length === 0) { + if (file.records.length === 0) { skippedFiles += 1; continue; } scannedFiles += 1; - for (const record of records) { + for (const record of file.records) { // Only sessions that contributed in-window count: the mtime slack // admits boundary files whose records fall outside the range. if (aggregator.add(record) && record.sessionId.length > 0) { @@ -442,6 +509,52 @@ export const make = Effect.gen(function* () { } satisfies UsageSummary; }); + /** + * In-flight scans by window, so concurrent identical requests (the usage + * page open on two clients at once) share one scan instead of racing over + * the same corpus twice. + */ + const inflightScans = new Map>(); + + const scanKey = (input: UsageSummaryInput): string => + JSON.stringify([ + input.timeZone, + input.sinceDay, + input.untilDay, + input.resolution ?? "day", + input.sinceTime ?? null, + input.untilTime ?? null, + ]); + + const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { + const key = scanKey(input); + const deferred = yield* Effect.uninterruptible( + Effect.gen(function* () { + const existing = inflightScans.get(key); + if (existing !== undefined) return existing; + + // Enrollment and detached-fiber creation must be atomic. Otherwise a + // canceled first caller can leave a Deferred with no scan to finish it. + const created = Deferred.makeUnsafe(); + inflightScans.set(key, created); + // Detached so one departing client cannot tear the scan out from under + // the fibers awaiting it; a finished scan warms the cache either way. + yield* scanSummary(input).pipe( + Effect.onExit((exit) => + Effect.sync(() => inflightScans.delete(key)).pipe( + Effect.andThen(Deferred.done(created, exit)), + ), + ), + Effect.forkDetach, + ); + return created; + }), + ); + // Waiting stays interruptible. The detached scan continues for other + // callers and still warms the cache if this caller leaves. + return yield* Deferred.await(deferred); + }); + return { readSummary } as const; }); diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index 64673e96..cec3ea4d 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -5,6 +5,7 @@ import { dedupeWithinFile, encodeScanCache, pruneScanCache, + type CachedFile, type ScanCache, } from "./usageScanCache.ts"; import type { UsageRecord } from "./usageTranscripts.ts"; @@ -28,10 +29,27 @@ function record(overrides: Partial = {}): UsageRecord { }; } +function position(overrides: Partial = {}): CachedFile["position"] { + return { + resumeOffset: 120, + guardLength: 64, + guardHash: 0xdeadbeef, + codexState: null, + ...overrides, + }; +} + function cacheWith(entries: readonly [string, number, readonly UsageRecord[]][]): ScanCache { const cache: ScanCache = new Map(); for (const [path, mtimeMs, records] of entries) { - cache.set(path, { size: records.length * 10, mtimeMs, provider: "claude", records }); + cache.set(path, { + size: records.length * 10, + mtimeMs, + provider: "claude", + records, + tailRecords: [], + position: position(), + }); } return cache; } @@ -42,12 +60,74 @@ describe("scan cache round trip", () => { ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5" })]], ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], ]); + original.set("/grok.jsonl", { + size: 40, + mtimeMs: 300, + provider: "grok", + records: [ + record({ provider: "grok", model: "grok-4.5-build", dedupeKey: "s:p:grok-4.5-build" }), + ], + tailRecords: [record({ provider: "grok", model: "grok-4.5-build", dedupeKey: null })], + position: position({ resumeOffset: 30, guardLength: 30, guardHash: 123 }), + }); + original.set("/codex.jsonl", { + size: 80, + mtimeMs: 400, + provider: "codex", + records: [record({ provider: "codex", model: "gpt-5.2-codex", dedupeKey: null })], + tailRecords: [], + position: position({ + codexState: { + model: "gpt-5.2-codex", + sessionId: "session-c", + lastUsageSignature: '{"input_tokens":1}', + sawSessionMeta: true, + suppressingForkCopies: false, + forkCopyAnchorMs: 0, + }, + }), + }); const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); - expect(restored.size).toBe(2); + expect(restored.size).toBe(4); expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl")); + expect(restored.get("/grok.jsonl")).toEqual(original.get("/grok.jsonl")); + expect(restored.get("/codex.jsonl")).toEqual(original.get("/codex.jsonl")); + }); + + it("drops an entry whose persisted parse state is corrupt", () => { + // Resuming with a bad reducer state would attach appended usage to the + // wrong model or replay fork-copied history; that entry must cold parse. + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const poisoned = { + ...encoded, + files: { + "/a.jsonl": { ...encoded.files["/a.jsonl"]!, cs: { model: 42 } }, + }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); + + it("drops an entry whose guard length is outside the supported range", () => { + // The guard length sizes a Buffer in the reader; a bogus value would make + // every parse of that file fail and silently drop its usage. + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const poisoned = { + ...encoded, + files: { "/a.jsonl": { ...encoded.files["/a.jsonl"]!, gl: 1e20 } }, + }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(poisoned))).has("/a.jsonl")).toBe(false); + }); + + it("rejects a document from the previous cache version", () => { + const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]])); + const previous = { ...encoded, version: 2 }; + + expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0); }); it("interns repeated model and session strings", () => { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 7121be3f..5e93070e 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -16,17 +16,28 @@ */ import type { UsageProviderKind } from "@helmcode/contracts"; -import type { UsageRecord } from "./usageTranscripts.ts"; +import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; +import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; // v2: Codex fork-copy suppression changed what a file parses to, so v1 // entries would keep serving double-counted records forever. -export const USAGE_SCAN_CACHE_VERSION = 2 as const; +// v3: entries carry the parse position and reducer state so a grown file +// re-parses only its appended bytes instead of starting over. +export const USAGE_SCAN_CACHE_VERSION = 3 as const; export interface CachedFile { readonly size: number; readonly mtimeMs: number; readonly provider: UsageProviderKind; + /** Records from newline-terminated lines, up to `position.resumeOffset`. */ readonly records: readonly UsageRecord[]; + /** + * Records from a trailing segment the writer had not newline-terminated at + * parse time. Kept apart from `records` because an incremental parse + * re-reads that segment and would otherwise double count it. + */ + readonly tailRecords: readonly UsageRecord[]; + readonly position: TranscriptParsePosition; } export type ScanCache = Map; @@ -54,6 +65,14 @@ interface SerializedFile { readonly m: number; readonly p: UsageProviderKind; readonly r: readonly SerializedRecord[]; + /** Tail records; see `CachedFile.tailRecords`. */ + readonly t: readonly SerializedRecord[]; + /** Parse position: resume offset, guard length, guard hash. */ + readonly o: number; + readonly gl: number; + readonly gh: number; + /** Codex reducer state at `o`; `null` for stateless providers. */ + readonly cs: CodexScanState | null; } interface SerializedCache { @@ -79,24 +98,31 @@ export function encodeScanCache(cache: ScanCache): SerializedCache { return next; }; + const serializeRecord = (record: UsageRecord): SerializedRecord => [ + record.timestampMs, + intern(models, modelIndex, record.model), + intern(sessions, sessionIndex, record.sessionId), + record.totals.uncachedInputTokens, + record.totals.cachedInputTokens, + record.totals.cacheCreationTokens, + record.totals.outputTokens, + record.totals.reasoningTokens, + record.dedupeKey, + record.reportedCostUsd, + ]; + const files: Record = {}; for (const [path, entry] of cache) { files[path] = { s: entry.size, m: entry.mtimeMs, p: entry.provider, - r: entry.records.map((record) => [ - record.timestampMs, - intern(models, modelIndex, record.model), - intern(sessions, sessionIndex, record.sessionId), - record.totals.uncachedInputTokens, - record.totals.cachedInputTokens, - record.totals.cacheCreationTokens, - record.totals.outputTokens, - record.totals.reasoningTokens, - record.dedupeKey, - record.reportedCostUsd, - ]), + r: entry.records.map(serializeRecord), + t: entry.tailRecords.map(serializeRecord), + o: entry.position.resumeOffset, + gl: entry.position.guardLength, + gh: entry.position.guardHash, + cs: entry.position.codexState, }; } @@ -130,24 +156,16 @@ export function decodeScanCache(document: unknown): ScanCache { const models = root.models as readonly string[]; const sessions = root.sessions as readonly string[]; - for (const [path, raw] of Object.entries(root.files)) { - if (typeof raw !== "object" || raw === null) continue; - const entry = raw as Partial; - if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; - if (entry.p !== "claude" && entry.p !== "codex") continue; - if (!isRecordArray(entry.r)) continue; - - const provider: UsageProviderKind = entry.p; + // Any corrupt row disqualifies the whole entry. Keeping the survivors + // under the original (size, mtime) would read as a valid warm hit and the + // file would never be re-parsed, silently losing the dropped rows' usage. + const decodeRecords = ( + rows: readonly unknown[], + provider: UsageProviderKind, + ): UsageRecord[] | null => { const records: UsageRecord[] = []; - // Any corrupt row disqualifies the whole entry. Keeping the survivors - // under the original (size, mtime) would read as a valid warm hit and the - // file would never be re-parsed, silently losing the dropped rows' usage. - let corrupt = false; - for (const row of entry.r) { - if (!isRecordArray(row) || row.length < 10) { - corrupt = true; - break; - } + for (const row of rows) { + if (!isRecordArray(row) || row.length < 10) return null; const [ timestampMs, modelIndex, @@ -172,8 +190,7 @@ export function decodeScanCache(document: unknown): ScanCache { !Number.isFinite(output) || !Number.isFinite(reasoning) ) { - corrupt = true; - break; + return null; } records.push({ @@ -192,14 +209,89 @@ export function decodeScanCache(document: unknown): ScanCache { dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, }); } + return records; + }; + + for (const [path, raw] of Object.entries(root.files)) { + if (typeof raw !== "object" || raw === null) continue; + const entry = raw as Partial; + if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; + if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; + if (!isRecordArray(entry.r) || !isRecordArray(entry.t)) continue; + // Position fields feed byte offsets and a Buffer allocation in the reader, + // so anything outside their real ranges must reject the entry: a bogus + // guard length would otherwise fail every parse of the file, silently + // dropping its usage instead of costing the documented cold re-parse. + if ( + typeof entry.o !== "number" || + !Number.isSafeInteger(entry.o) || + entry.o < 0 || + typeof entry.gl !== "number" || + !Number.isSafeInteger(entry.gl) || + entry.gl < 0 || + entry.gl > GUARD_LENGTH || + entry.gl > entry.o || + typeof entry.gh !== "number" || + !Number.isFinite(entry.gh) + ) { + continue; + } + const codexState = decodeCodexState(entry.cs); + if (codexState === undefined) continue; - if (corrupt) continue; - cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, records }); + const provider: UsageProviderKind = entry.p; + const records = decodeRecords(entry.r, provider); + const tailRecords = decodeRecords(entry.t, provider); + if (records === null || tailRecords === null) continue; + + cache.set(path, { + size: entry.s, + mtimeMs: entry.m, + provider, + records, + tailRecords, + position: { + resumeOffset: entry.o, + guardLength: entry.gl, + guardHash: entry.gh, + codexState, + }, + }); } return cache; } +/** + * Validates a persisted Codex reducer state. Returns `undefined` for a corrupt + * value, which disqualifies the entry: resuming with a bad state would attach + * appended usage to the wrong model or replay fork-copied history. + */ +function decodeCodexState(value: unknown): CodexScanState | null | undefined { + if (value === null) return null; + if (typeof value !== "object") return undefined; + const state = value as Partial; + if ( + typeof state.model !== "string" || + typeof state.sessionId !== "string" || + (state.lastUsageSignature !== null && typeof state.lastUsageSignature !== "string") || + typeof state.sawSessionMeta !== "boolean" || + typeof state.suppressingForkCopies !== "boolean" || + typeof state.forkCopyAnchorMs !== "number" || + !Number.isFinite(state.forkCopyAnchorMs) + ) { + return undefined; + } + return { + model: state.model, + sessionId: state.sessionId, + lastUsageSignature: state.lastUsageSignature ?? null, + sawSessionMeta: state.sawSessionMeta, + suppressingForkCopies: state.suppressingForkCopies, + forkCopyAnchorMs: state.forkCopyAnchorMs, + }; +} + export interface PruneOptions { /** Files the walk just saw. Only meaningful inside the walked window. */ readonly livePaths: ReadonlySet; @@ -240,9 +332,17 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number return removed; } -/** Within-file de-duplication, applied before an entry is cached. */ -export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { - const seen = new Set(); +/** + * Within-file de-duplication, applied before an entry is cached. + * + * Callers stitching an incremental parse together pass one `seen` set across + * the line and tail record batches so the whole file stays deduplicated as a + * unit; the set is mutated in place. + */ +export function dedupeWithinFile( + records: readonly UsageRecord[], + seen: Set = new Set(), +): readonly UsageRecord[] { const kept: UsageRecord[] = []; for (const record of records) { if (record.dedupeKey !== null) { diff --git a/apps/server/src/usage/usageTranscriptReader.test.ts b/apps/server/src/usage/usageTranscriptReader.test.ts new file mode 100644 index 00000000..5feb68b2 --- /dev/null +++ b/apps/server/src/usage/usageTranscriptReader.test.ts @@ -0,0 +1,210 @@ +// @effect-diagnostics nodeBuiltinImport:off - resume coverage writes, appends +// to, and truncates real transcript files byte-exactly, mirroring the reader's +// own deliberate node:fs usage. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; + +import { readTranscriptRecords } from "./usageTranscriptReader.ts"; + +let dir: string; + +beforeEach(async () => { + dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-")); +}); + +afterEach(async () => { + await NodeFSP.rm(dir, { recursive: true, force: true }); +}); + +function claudeLine(id: number, outputTokens: number): string { + return `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: `req_${id}`, + sessionId: "session-1", + message: { + id: `msg_${id}`, + model: "claude-fable-5", + usage: { input_tokens: 10, output_tokens: outputTokens }, + }, + })}\n`; +} + +function codexMetaLine(): string { + return `${JSON.stringify({ + type: "session_meta", + timestamp: "2026-08-01T10:00:00Z", + payload: { type: "session_meta", id: "codex-session-1" }, + })}\n`; +} + +function codexModelLine(model: string): string { + return `${JSON.stringify({ + type: "turn_context", + timestamp: "2026-08-01T10:00:01Z", + payload: { type: "turn_context", model }, + })}\n`; +} + +function codexUsageLine(outputTokens: number, secondsOffset: number): string { + return `${JSON.stringify({ + type: "event_msg", + timestamp: `2026-08-01T10:00:${String(secondsOffset).padStart(2, "0")}Z`, + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 100, output_tokens: outputTokens } }, + }, + })}\n`; +} + +describe("readTranscriptRecords resume", () => { + it("parses only appended lines when resuming a grown file", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 2); + assert.isFalse(first.resumed); + + await NodeFSP.appendFile(path, claudeLine(3, 11)); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.strictEqual(second.records.length, 1); + assert.strictEqual(second.records[0]?.totals.outputTokens, 11); + + // The stitched result matches a from-scratch parse of the whole file. + const full = await readTranscriptRecords(path, "claude"); + assert.isNotNull(full); + assert.deepStrictEqual([...first.records, ...second.records], [...full.records]); + }); + + it("carries the Codex reducer state across the resume boundary", async () => { + const path = NodePath.join(dir, "rollout.jsonl"); + await NodeFSP.writeFile(path, codexMetaLine() + codexModelLine("gpt-5.2-codex")); + const first = await readTranscriptRecords(path, "codex"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 0); + + // The appended usage event has no turn_context or session_meta of its own; + // model and session must come from the state captured before the boundary. + await NodeFSP.appendFile(path, codexUsageLine(9, 5)); + const second = await readTranscriptRecords(path, "codex", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.strictEqual(second.records.length, 1); + assert.strictEqual(second.records[0]?.model, "gpt-5.2-codex"); + assert.strictEqual(second.records[0]?.sessionId, "codex-session-1"); + }); + + it("suppresses a Codex duplicate usage event that straddles the boundary", async () => { + const path = NodePath.join(dir, "rollout.jsonl"); + await NodeFSP.writeFile( + path, + codexMetaLine() + codexModelLine("gpt-5.2-codex") + codexUsageLine(9, 5), + ); + const first = await readTranscriptRecords(path, "codex"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 1); + + // Codex re-emits an unchanged token_count on stream boundaries; the copy + // lands after the resume point and must still be dropped. + await NodeFSP.appendFile(path, codexUsageLine(9, 5) + codexUsageLine(21, 8)); + const second = await readTranscriptRecords(path, "codex", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [21], + ); + }); + + it("defers an unterminated trailing line to tailRecords, then consumes it once terminated", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + const unterminated = claudeLine(2, 7).trimEnd(); + await NodeFSP.writeFile(path, claudeLine(1, 5) + unterminated); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + assert.strictEqual(first.records.length, 1); + assert.strictEqual(first.tailRecords.length, 1); + assert.strictEqual(first.tailRecords[0]?.totals.outputTokens, 7); + + // Completing the line and appending another re-reads from the resume + // point, so the once-tail record arrives exactly once as a line record. + await NodeFSP.appendFile(path, `\n${claudeLine(3, 11)}`); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isTrue(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [7, 11], + ); + assert.strictEqual(second.tailRecords.length, 0); + }); + + it("re-parses from the start when the guard bytes no longer match", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + await NodeFSP.writeFile(path, claudeLine(1, 5)); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + + // Same path, larger size, different content: a replaced file, not growth. + await NodeFSP.writeFile(path, claudeLine(4, 13) + claudeLine(5, 17)); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isFalse(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [13, 17], + ); + }); + + it("re-parses from the start when the file shrank below the resume point", async () => { + const path = NodePath.join(dir, "claude.jsonl"); + await NodeFSP.writeFile(path, claudeLine(1, 5) + claudeLine(2, 7)); + const first = await readTranscriptRecords(path, "claude"); + assert.isNotNull(first); + + await NodeFSP.writeFile(path, claudeLine(3, 11)); + const second = await readTranscriptRecords(path, "claude", first.position); + assert.isNotNull(second); + assert.isFalse(second.resumed); + assert.deepStrictEqual( + second.records.map((record) => record.totals.outputTokens), + [11], + ); + }); + + it("parses a line larger than one stream chunk", async () => { + // Tool-heavy transcripts carry multi-megabyte single lines; they arrive + // split across many chunks and must reassemble into one record. + const path = NodePath.join(dir, "claude.jsonl"); + const bigLine = `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: "req_big", + sessionId: "session-1", + padding: "x".repeat(512 * 1024), + message: { + id: "msg_big", + model: "claude-fable-5", + usage: { input_tokens: 10, output_tokens: 42 }, + }, + })}\n`; + await NodeFSP.writeFile(path, bigLine + claudeLine(2, 7)); + + const parsed = await readTranscriptRecords(path, "claude"); + assert.isNotNull(parsed); + assert.deepStrictEqual( + parsed.records.map((record) => record.totals.outputTokens), + [42, 7], + ); + }); + + it("returns null for an unreadable file", async () => { + assert.isNull(await readTranscriptRecords(NodePath.join(dir, "missing.jsonl"), "claude")); + }); +}); diff --git a/apps/server/src/usage/usageTranscriptReader.ts b/apps/server/src/usage/usageTranscriptReader.ts index 24c8e4ab..a696e8d0 100644 --- a/apps/server/src/usage/usageTranscriptReader.ts +++ b/apps/server/src/usage/usageTranscriptReader.ts @@ -4,16 +4,19 @@ * * Isolated here so the rest of the usage code stays on Effect's `FileSystem`. * The direct `node:fs` streaming is deliberate: a cold 30-day window is ~1.4 GB - * across ~1,500 files, and `readline` over a read stream is roughly an order of + * across ~1,500 files, and buffer-level streaming is roughly an order of * magnitude cheaper than materialising each file. The equivalent Effect stream * pipeline is idiomatic but not fast enough to sit behind a page load. * + * Transcripts are append-only, so a parse also reports the byte position it + * stopped at. A later scan of the same file resumes from that position and + * parses only the appended bytes, which is what keeps a warm scan cheap while a + * session is actively writing a multi-hundred-megabyte rollout. + * * @module usageTranscriptReader */ -import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; -import * as NodeReadline from "node:readline"; import type { UsageProviderKind } from "@helmcode/contracts"; @@ -22,6 +25,7 @@ import { mightCarryUsage, parseClaudeLine, parseCodexLine, + type CodexScanState, type UsageRecord, } from "./usageTranscripts.ts"; @@ -31,6 +35,56 @@ export interface TranscriptFile { readonly mtimeMs: number; } +/** + * Where a parse stopped, with enough state to continue from there. + * + * The guard hash fingerprints the bytes immediately before `resumeOffset`. A + * resume only proceeds when those bytes still match: transcripts are + * append-only by design, but a rotated or rewritten file silently mis-parsed + * from the middle would corrupt usage totals. The window is a cheap tripwire + * for those realistic failure shapes, all of which disturb the file's tail at + * that exact offset; it deliberately does not hash the whole prefix, which + * would cost the full re-read the resume exists to avoid. + */ +export interface TranscriptParsePosition { + /** Byte offset just past the last newline-terminated line consumed. */ + readonly resumeOffset: number; + /** Length of the fingerprinted window ending at `resumeOffset`. */ + readonly guardLength: number; + /** FNV-1a hash of that window. */ + readonly guardHash: number; + /** Codex reducer state as of `resumeOffset`; `null` for stateless providers. */ + readonly codexState: CodexScanState | null; +} + +export interface TranscriptParseResult { + /** Records from newline-terminated lines at or after the parse start. */ + readonly records: readonly UsageRecord[]; + /** + * Records from a trailing segment the writer has not newline-terminated yet. + * Kept out of `records` because `position` deliberately excludes that + * segment: the next scan re-reads it once the writer finishes the line. + */ + readonly tailRecords: readonly UsageRecord[]; + readonly position: TranscriptParsePosition; + /** Whether the parse continued from `resumeFrom` rather than byte 0. */ + readonly resumed: boolean; +} + +/** 64 bytes of JSONL tail is ample to distinguish a replaced file. */ +export const GUARD_LENGTH = 64; +const NEWLINE = 0x0a; +const CARRIAGE_RETURN = 0x0d; + +function fnv1a(buffer: Buffer): number { + let hash = 0x811c9dc5; + for (let index = 0; index < buffer.length; index += 1) { + hash ^= buffer[index]!; + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + /** * Lists `.jsonl` transcripts under `root` last modified at or after `sinceMs`. * @@ -89,6 +143,25 @@ export async function readDirectoryVolumeId(path: string): Promise { } } +async function guardMatches( + handle: NodeFSP.FileHandle, + position: TranscriptParsePosition, +): Promise { + if (position.guardLength <= 0 || position.guardLength > GUARD_LENGTH) return false; + try { + const window = Buffer.alloc(position.guardLength); + const { bytesRead } = await handle.read( + window, + 0, + position.guardLength, + position.resumeOffset - position.guardLength, + ); + return bytesRead === position.guardLength && fnv1a(window) === position.guardHash; + } catch { + return false; + } +} + /** * Streams one transcript and returns the usage records it contains, or `null` * when the file could not be read. @@ -98,6 +171,10 @@ export async function readDirectoryVolumeId(path: string): Promise { * under the same `(size, mtime)` key would silently drop that file's usage * until the file next changes. * + * With `resumeFrom`, parsing continues from that position when its guard bytes + * still match, so only appended lines are read; otherwise the whole file is + * re-parsed from the start and `resumed` reports `false`. + * * Codex carries the active model on `turn_context` lines that hold no usage of * their own, so those still have to pass through the reducer to keep model * attribution correct. @@ -105,37 +182,117 @@ export async function readDirectoryVolumeId(path: string): Promise { export async function readTranscriptRecords( filePath: string, provider: UsageProviderKind, -): Promise { - const records: UsageRecord[] = []; - const codexState = initialCodexScanState(); + resumeFrom?: TranscriptParsePosition, +): Promise { + let handle: NodeFSP.FileHandle; + try { + handle = await NodeFSP.open(filePath, "r"); + } catch { + return null; + } try { - const lines = NodeReadline.createInterface({ - input: NodeFS.createReadStream(filePath, { encoding: "utf8" }), - crlfDelay: Infinity, - }); + let codexState = initialCodexScanState(); + let resumed = false; + let start = 0; + if ( + resumeFrom !== undefined && + resumeFrom.resumeOffset > 0 && + (provider !== "codex" || resumeFrom.codexState !== null) && + (await guardMatches(handle, resumeFrom)) + ) { + if (resumeFrom.codexState !== null) codexState = { ...resumeFrom.codexState }; + start = resumeFrom.resumeOffset; + resumed = true; + } - for await (const line of lines) { + const parseLine = (line: string, state: CodexScanState, out: UsageRecord[]): void => { if (provider === "codex") { if ( !mightCarryUsage(line, provider) && !line.includes('"turn_context"') && !line.includes('"session_meta"') ) { - continue; + return; } - const record = parseCodexLine(line, codexState); - if (record !== null) records.push(record); + const record = parseCodexLine(line, state); + if (record !== null) out.push(record); + return; + } + if (!mightCarryUsage(line, provider)) return; + const record = parseClaudeLine(line); + if (record !== null) out.push(record); + }; + + const toLineString = (lineBuffer: Buffer): string => { + const content = + lineBuffer.length > 0 && lineBuffer[lineBuffer.length - 1] === CARRIAGE_RETURN + ? lineBuffer.subarray(0, -1) + : lineBuffer; + return content.toString("utf8"); + }; + + const records: UsageRecord[] = []; + // Buffer-level line splitting rather than `readline`, because resuming + // needs byte-exact offsets and decoded strings cannot provide them. + // Newline-free chunks are collected rather than concatenated as they + // arrive, so a single huge line costs one copy instead of one per chunk. + let resumeOffset = start; + let pendingChunks: Buffer[] = []; + const stream = handle.createReadStream({ + start, + autoClose: false, + }) as AsyncIterable; + for await (const chunk of stream) { + if (!chunk.includes(NEWLINE)) { + pendingChunks.push(chunk); continue; } + const buffer: Buffer = + pendingChunks.length === 0 ? chunk : Buffer.concat([...pendingChunks, chunk]); + pendingChunks = []; + let lineStart = 0; + for (;;) { + const newlineIndex = buffer.indexOf(NEWLINE, lineStart); + if (newlineIndex === -1) break; + parseLine(toLineString(buffer.subarray(lineStart, newlineIndex)), codexState, records); + lineStart = newlineIndex + 1; + } + resumeOffset += lineStart; + if (lineStart < buffer.length) pendingChunks.push(buffer.subarray(lineStart)); + } - if (!mightCarryUsage(line, provider)) continue; - const record = parseClaudeLine(line); - if (record !== null) records.push(record); + // A trailing segment without its newline is parsed for this result but not + // consumed: a writer may still be appending to it, and counting a half + // record now and its full form later would double count. + const tailRecords: UsageRecord[] = []; + if (pendingChunks.length > 0) { + const pending = pendingChunks.length === 1 ? pendingChunks[0]! : Buffer.concat(pendingChunks); + if (pending.length > 0) parseLine(toLineString(pending), { ...codexState }, tailRecords); } + + const guardLength = Math.min(GUARD_LENGTH, resumeOffset); + let guardHash = 0; + if (guardLength > 0) { + const window = Buffer.alloc(guardLength); + await handle.read(window, 0, guardLength, resumeOffset - guardLength); + guardHash = fnv1a(window); + } + + return { + records, + tailRecords, + position: { + resumeOffset, + guardLength, + guardHash, + codexState: provider === "codex" ? codexState : null, + }, + resumed, + }; } catch { return null; + } finally { + await handle.close().catch(() => undefined); } - - return records; } From 4bee638af532d57dac0df47dc696663e22037128 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 01:25:24 -0700 Subject: [PATCH 05/15] perf(server): bound snapshot activity payload memory (#9000) --- .../ActivityPayloadProjection.test.ts | 114 +++++ .../ActivityPayloadProjection.ts | 122 ++++- .../Layers/ProjectionSnapshotQuery.test.ts | 257 ++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 450 ++++++++++++++++-- .../Services/ProjectionSnapshotQuery.ts | 14 + 5 files changed, 906 insertions(+), 51 deletions(-) diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 089d2548..dc162aeb 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -44,6 +44,120 @@ describe("projectActivityPayload agent-field survival", () => { expect(data.somethingClientNeverReads).toBeUndefined(); }); + it("keeps a bounded Codex command output summary", () => { + const projected = projectActivityPayload( + activity({ + itemType: "command_execution", + data: { + item: { + command: "/bin/zsh -lc 'printf hello'", + aggregatedOutput: `hello from codex\n${"x".repeat(5000)}`, + }, + }, + }), + ); + const data = (projected.payload as Record).data as Record; + expect(data.item).toEqual({ + command: "/bin/zsh -lc 'printf hello'", + aggregatedOutput: "hello from codex", + }); + expect(JSON.stringify(projected.payload).length).toBeLessThan(500); + }); + + it("keeps preview normalization and fence-only fallback while scanning lines", () => { + const preview = projectActivityPayload( + activity({ + itemType: "command_execution", + data: { rawOutput: `\`\`\`\n actual\tresult \n${"x".repeat(5000)}` }, + }), + ); + const fences = projectActivityPayload( + activity({ + itemType: "command_execution", + data: { rawOutput: "```\r\n \t \n```\n" }, + }), + ); + + expect((preview.payload as { data: { rawOutput: unknown } }).data.rawOutput).toEqual({ + content: "actual result", + }); + expect((fences.payload as { data: { rawOutput: unknown } }).data.rawOutput).toEqual({ + content: "2 lines", + }); + }); + + it("keeps bounded Claude and ACP command output summaries", () => { + const claude = projectActivityPayload( + activity({ + itemType: "command_execution", + data: { + command: "printf hello", + rawOutput: { stdout: `hello from claude\n${"y".repeat(5000)}` }, + }, + }), + ); + const acp = projectActivityPayload( + activity({ + itemType: "command_execution", + data: { + command: "printf hello", + content: [ + { + type: "content", + content: { type: "text", text: `hello from acp\n${"z".repeat(5000)}` }, + }, + ], + }, + }), + ); + + const claudeData = (claude.payload as Record).data as Record; + const acpData = (acp.payload as Record).data as Record; + expect(claudeData.rawOutput).toEqual({ content: "hello from claude" }); + expect(acpData.rawOutput).toEqual({ content: "hello from acp" }); + expect(JSON.stringify(claude.payload).length).toBeLessThan(500); + expect(JSON.stringify(acp.payload).length).toBeLessThan(500); + }); + + it("normalizes Claude and OpenCode command inputs before slimming provider data", () => { + const claude = projectActivityPayload( + activity({ + itemType: "command_execution", + toolCallId: "claude-call-1", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + result: { content: "x".repeat(5_000) }, + }, + }), + ); + const openCode = projectActivityPayload( + activity({ + itemType: "command_execution", + toolCallId: "opencode-call-1", + data: { + tool: "bash", + state: { + status: "running", + input: { command: "vp lint" }, + output: "x".repeat(5_000), + }, + }, + }), + ); + + expect(claude.payload).toMatchObject({ + toolCallId: "claude-call-1", + data: { command: "vp test run" }, + }); + expect(openCode.payload).toMatchObject({ + toolCallId: "opencode-call-1", + data: { command: "vp lint" }, + }); + expect(JSON.stringify(claude.payload).length).toBeLessThan(200); + expect(JSON.stringify(openCode.payload).length).toBeLessThan(200); + }); + it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => { const projected = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index f391eb25..ca9a4ed0 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -91,36 +91,82 @@ function projectCommandData(data: Record): Record = {}; + if ("command" in result) { + projectedResult.command = result.command; + } + const content = asTrimmedString(result.content); + if (content) { + const summary = summarizeToolTextOutput(content); + if (summary) { + projectedResult.content = summary; + } + } + if (Object.keys(projectedResult).length > 0) { + projectedItem.result = projectedResult; + } } return Object.keys(projectedItem).length > 0 ? projectedItem : undefined; } +function projectCommandValue(data: Record): unknown { + if (data.command !== undefined) { + return data.command; + } + + const input = asRecord(data.input); + if (input?.command !== undefined) { + return input.command; + } + + const stateInput = asRecord(asRecord(data.state)?.input); + if (stateInput?.command !== undefined) { + return stateInput.command; + } + + return undefined; +} + function summarizeToolTextOutput(value: string): string | null { - const lines: string[] = []; - for (const rawLine of value.split(/\r?\n/u)) { - const line = rawLine.replace(/\s+/g, " ").trim(); + let meaningfulLineCount = 0; + let offset = 0; + + while (offset <= value.length) { + const newlineIndex = value.indexOf("\n", offset); + const lineEnd = newlineIndex === -1 ? value.length : newlineIndex; + const line = value.slice(offset, lineEnd).replace(/\s+/g, " ").trim(); if (line.length > 0) { - lines.push(line); + meaningfulLineCount += 1; + if (line !== "```") { + const summary = line.length <= 84 ? line : `${line.slice(0, 83).trimEnd()}…`; + // V8 can retain the full tool output behind a short sliced string. + // Join a tiny character array so the returned preview owns its bytes. + return Array.from(summary).join(""); + } } + if (newlineIndex === -1) { + break; + } + offset = newlineIndex + 1; } - const firstLine = lines.find((line) => line !== "```"); - if (firstLine) { - return firstLine.length <= 84 ? firstLine : `${firstLine.slice(0, 83).trimEnd()}…`; - } - if (lines.length > 1) { - return `${lines.length.toLocaleString()} lines`; - } - return null; + return meaningfulLineCount > 1 ? `${meaningfulLineCount.toLocaleString()} lines` : null; } /** @@ -232,6 +278,12 @@ function projectMcpToolCallData(data: Record): Record | undefined { + const direct = asTrimmedString(value); + if (direct) { + const summary = summarizeToolTextOutput(direct); + return summary ? { content: summary } : undefined; + } + const rawOutput = asRecord(value); if (!rawOutput) { return undefined; @@ -256,9 +308,34 @@ function projectRawOutput(value: unknown): Record | undefined { return summary ? { content: summary } : undefined; } + const stderr = asTrimmedString(rawOutput.stderr); + if (stderr) { + const summary = summarizeToolTextOutput(stderr); + return summary ? { content: summary } : undefined; + } + return undefined; } +function projectAcpContent(value: unknown): Record | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const text = value + .map((entryValue) => { + const entry = asRecord(entryValue); + const content = asRecord(entry?.content); + return entry?.type === "content" && content?.type === "text" + ? asTrimmedString(content.text) + : null; + }) + .filter((entry): entry is string => entry !== null) + .join("\n"); + const summary = summarizeToolTextOutput(text); + return summary ? { content: summary } : undefined; +} + /** * Removes activity payload fields that no current client reads while retaining * the full payload in persistence and the event store. @@ -272,11 +349,17 @@ export function projectActivityPayload( return activity; } + const itemStatus = asRecord(data.item)?.status; + const projectedPayload = + payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined") + ? { ...payload, status: itemStatus } + : payload; + if (payload.itemType === "mcp_tool_call") { return { ...activity, payload: { - ...payload, + ...projectedPayload, data: projectMcpToolCallData(data), }, }; @@ -287,8 +370,9 @@ export function projectActivityPayload( if (item) { projectedData.item = item; } - if ("command" in data) { - projectedData.command = data.command; + const command = projectCommandValue(data); + if (command !== undefined) { + projectedData.command = command; } const changedFiles: string[] = []; @@ -305,7 +389,7 @@ export function projectActivityPayload( projectedData.kind = data.kind; } - const rawOutput = projectRawOutput(data.rawOutput); + const rawOutput = projectRawOutput(data.rawOutput) ?? projectAcpContent(data.content); if (rawOutput) { projectedData.rawOutput = rawOutput; } @@ -313,7 +397,7 @@ export function projectActivityPayload( return { ...activity, payload: { - ...payload, + ...projectedPayload, data: projectedData, }, }; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 9e982fc7..3cb7fbdd 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -21,6 +21,7 @@ import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts"; +import { projectThreadDetailSnapshot } from "../ActivityPayloadProjection.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); @@ -2287,6 +2288,262 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); + it.effect("bounds activity hydration and preserves unresolved requests", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql` + WITH RECURSIVE activity_rows(sequence) AS ( + SELECT 1 + UNION ALL + SELECT sequence + 1 FROM activity_rows WHERE sequence < 501 + ) + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) + SELECT + printf('activity-%04d', sequence), + 'thread-w', + 'turn-5', + 'tool', + CASE + WHEN sequence = 2 THEN 'tool.updated' + WHEN sequence IN (3, 70) THEN 'context-window.updated' + ELSE 'tool.completed' + END, + 'ran tool', + CASE + WHEN sequence IN (2, 80) THEN json_object( + 'itemType', 'command_execution', + 'toolCallId', 'cross-batch-call', + 'title', CASE WHEN sequence = 80 THEN 'Build completed' ELSE 'Build' END, + 'status', 'completed', + 'data', json_object( + 'toolCallId', 'cross-batch-call', + 'item', json_object( + 'command', 'vp test run', + 'aggregatedOutput', printf( + 'command output%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'x') + ) + ), + 'rawOutput', printf( + 'raw output%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'y') + ), + 'files', json_array(json_object('path', 'apps/server/src/snapshot.ts')) + ) + ) + WHEN sequence = 10 THEN json_object( + 'itemType', 'mcp_tool_call', + 'status', 'completed', + 'data', json_object( + 'item', json_object( + 'type', 'mcpToolCall', + 'id', 'mcp-item-10', + 'tool', 'fetch_pr', + 'server', 'github', + 'status', 'completed', + 'arguments', json_object('pr', 42), + 'result', json_object( + 'content', json_array(json_object( + 'type', 'text', + 'text', printf( + 'PR body line one%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'z') + ) + )) + ), + '_meta', json_object('raw', replace(hex(zeroblob(8192)), '00', 'q')) + ) + ) + ) + WHEN sequence = 11 THEN json_object( + 'itemType', 'command_execution', + 'status', 'completed', + 'data', json_object( + 'item', json_object( + 'status', 'failed', + 'command', 'vp test run', + 'aggregatedOutput', printf( + 'failed command%s%s', + char(10), + replace(hex(zeroblob(8192)), '00', 'w') + ) + ), + 'rawOutput', json_object('stdout', 'failed output'), + 'files', json_array(json_object('path', 'apps/server/src/failed.ts')) + ) + ) + WHEN sequence IN (3, 70) THEN json_object( + 'usedTokens', sequence * 100, + 'modelContextWindow', 100000 + ) + ELSE json_object('sequence', sequence) + END, + sequence, + '2026-03-01T00:04:00.000Z' + FROM activity_rows + `; + + const fullDetail = yield* snapshotQuery.getThreadDetailById(threadW); + assert.equal(fullDetail._tag, "Some"); + if (fullDetail._tag === "Some") { + assert.equal(fullDetail.value.activities.length, 500); + assert.equal(fullDetail.value.activities[0]?.id, asEventId("activity-0002")); + assert.equal(fullDetail.value.activities.at(-1)?.id, asEventId("activity-0501")); + } + + const windowedDetail = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + }); + assert.equal(windowedDetail._tag, "Some"); + if (windowedDetail._tag === "Some") { + assert.equal(windowedDetail.value.thread.activities.length, 500); + assert.equal(windowedDetail.value.thread.activities[0]?.id, asEventId("activity-0002")); + assert.equal(windowedDetail.value.thread.activities.at(-1)?.id, asEventId("activity-0501")); + } + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) + VALUES + ( + 'approval-old', 'thread-w', NULL, 'approval', 'approval.requested', + 'Approve old command', '{"requestId":"approval-1"}', NULL, + '2026-03-01T00:00:01.000Z' + ), + ( + 'user-input-old', 'thread-w', NULL, 'approval', 'user-input.requested', + 'Answer old question', '{"requestId":"input-1"}', NULL, + '2026-03-01T00:00:02.000Z' + ), + ( + 'user-input-closed', 'thread-w', NULL, 'approval', 'user-input.requested', + 'Closed question', '{"requestId":"input-closed"}', NULL, + '2026-03-01T00:00:03.000Z' + ), + ( + 'user-input-closed-resolution', 'thread-w', NULL, 'info', 'user-input.resolved', + 'Closed question', '{"requestId":"input-closed"}', NULL, + '2026-03-01T00:00:04.000Z' + ), + ( + 'user-input-tied-z-request', 'thread-w', NULL, 'approval', 'user-input.requested', + 'Tied open question', '{"requestId":"input-tied-open"}', NULL, + '2026-03-01T00:00:05.000Z' + ), + ( + 'user-input-tied-a-resolution', 'thread-w', NULL, 'info', 'user-input.resolved', + 'Tied open question', '{"requestId":"input-tied-open"}', NULL, + '2026-03-01T00:00:05.000Z' + ) + `; + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, thread_id, turn_id, status, decision, created_at, resolved_at + ) + VALUES ( + 'approval-1', 'thread-w', NULL, 'pending', NULL, + '2026-03-01T00:00:01.000Z', NULL + ) + `; + yield* sql` + UPDATE projection_threads + SET pending_approval_count = 1, pending_user_input_count = 1 + WHERE thread_id = 'thread-w' + `; + + const detailWithPinnedRequests = yield* snapshotQuery.getThreadDetailById(threadW); + assert.equal(detailWithPinnedRequests._tag, "Some"); + if (detailWithPinnedRequests._tag === "Some") { + const ids = new Set( + detailWithPinnedRequests.value.activities.map((activity) => activity.id), + ); + assert.equal(detailWithPinnedRequests.value.activities.length, 503); + assert.equal(ids.has(asEventId("approval-old")), true); + assert.equal(ids.has(asEventId("user-input-old")), true); + assert.equal(ids.has(asEventId("user-input-closed")), false); + assert.equal(ids.has(asEventId("user-input-tied-z-request")), true); + } + + const windowWithPinnedRequests = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + }); + assert.equal(windowWithPinnedRequests._tag, "Some"); + if (windowWithPinnedRequests._tag === "Some") { + const ids = new Set( + windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id), + ); + assert.equal(windowWithPinnedRequests.value.thread.activities.length, 503); + assert.equal(ids.has(asEventId("approval-old")), true); + assert.equal(ids.has(asEventId("user-input-old")), true); + assert.equal(ids.has(asEventId("user-input-closed")), false); + assert.equal(ids.has(asEventId("user-input-tied-z-request")), true); + } + + const fullSnapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW); + assert.equal(fullSnapshot._tag, "Some"); + if ( + detailWithPinnedRequests._tag === "Some" && + fullSnapshot._tag === "Some" && + windowWithPinnedRequests._tag === "Some" + ) { + const projectedFullSnapshot = projectThreadDetailSnapshot(fullSnapshot.value); + const projectedRawBaseline = projectThreadDetailSnapshot({ + snapshotSequence: fullSnapshot.value.snapshotSequence, + thread: detailWithPinnedRequests.value, + }); + assert.deepStrictEqual(projectedFullSnapshot, projectedRawBaseline); + + const rawActivitiesById = new Map( + detailWithPinnedRequests.value.activities.map((activity) => [activity.id, activity]), + ); + const projectedWindowSnapshot = projectThreadDetailSnapshot(windowWithPinnedRequests.value); + const projectedWindowBaseline = projectThreadDetailSnapshot({ + ...windowWithPinnedRequests.value, + thread: { + ...windowWithPinnedRequests.value.thread, + activities: windowWithPinnedRequests.value.thread.activities.map( + (activity) => rawActivitiesById.get(activity.id) ?? activity, + ), + }, + }); + assert.deepStrictEqual(projectedWindowSnapshot, projectedWindowBaseline); + + const projectedIds = new Set( + projectedFullSnapshot.thread.activities.map((activity) => activity.id), + ); + assert.equal(projectedIds.has(asEventId("activity-0002")), false); + assert.equal(projectedIds.has(asEventId("activity-0003")), false); + assert.equal(projectedIds.has(asEventId("activity-0070")), true); + + const failedCommand = projectedFullSnapshot.thread.activities.find( + (activity) => activity.id === asEventId("activity-0011"), + ); + assert.deepStrictEqual(failedCommand?.payload, { + itemType: "command_execution", + status: "failed", + data: { + item: { + command: "vp test run", + aggregatedOutput: "failed command", + }, + files: [{ path: "apps/server/src/failed.ts" }], + rawOutput: { content: "failed output" }, + }, + }); + } + }), + ); + it.effect("a thread with no turns returns its content unwindowed on the first page", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 2de56ed0..370e9bdf 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -57,6 +57,7 @@ import { decodeThreadDetailPageCursor, encodeThreadDetailPageCursor, } from "../threadDetailCursor.ts"; +import { projectActivityPayload } from "../ActivityPayloadProjection.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { @@ -65,11 +66,19 @@ import { type ProjectionSnapshotCounts, type ProjectionThreadCheckpointContext, type ProjectionSnapshotQueryShape, + type ProjectionThreadDetailQuery, } from "../Services/ProjectionSnapshotQuery.ts"; const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); +// Keep detail reads consistent with the in-memory projector's retained +// activity window. Applying the limit in SQL avoids decoding an unbounded +// payload_json set before the projector can enforce that invariant. +const THREAD_DETAIL_ACTIVITY_LIMIT = 500; +// Snapshot payloads are decoded and projected in small sequential batches so +// one client read does not retain the raw payloads for the full activity window. +const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25; const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -94,6 +103,9 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( sequence: Schema.NullOr(NonNegativeInt), }), ); +const ProjectionThreadActivityIdRowSchema = Schema.Struct({ + activityId: ProjectionThreadActivity.fields.activityId, +}); const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( Struct.assign({ @@ -139,6 +151,13 @@ const ProjectIdLookupInput = Schema.Struct({ const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); +const ThreadActivityKindsLookupInput = Schema.Struct({ + threadId: ThreadId, + activityKinds: Schema.Array(Schema.String), +}); +const ThreadActivityIdsLookupInput = Schema.Struct({ + activityIds: Schema.Array(ProjectionThreadActivity.fields.activityId), +}); // Windowed reads order turns by the stable keyset (anchor, turn key), where // anchor is requested_at and turn key is // COALESCE(turn_id, ''). Both are event-derived, so cursors survive the @@ -345,6 +364,21 @@ function mapProposedPlanRow( }; } +function mapThreadActivityRow( + row: Schema.Schema.Type, +): OrchestrationThreadActivity { + return { + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + createdAt: row.createdAt, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + }; +} + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): ProjectionRepositoryError => Schema.isSchemaError(cause) @@ -1031,8 +1065,105 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { payload_json AS "payload", sequence, created_at AS "createdAt" + FROM ( + SELECT + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + ) AS recent_activities + ORDER BY + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + + const listThreadActivityIdsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityIdRowSchema, + execute: ({ threadId }) => + sql` + SELECT activity_id AS "activityId" FROM projection_thread_activities WHERE thread_id = ${threadId} + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + `, + }); + + const listThreadActivityRowsByIds = SqlSchema.findAll({ + Request: ThreadActivityIdsLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ activityIds }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + -- The selectors already scoped these globally unique ids to the + -- thread inside this transaction. Keep this as a primary-key lookup. + WHERE ${sql.in("activity_id", activityIds)} + `, + }); + + const listThreadActivityRowsByThreadAndKinds = SqlSchema.findAll({ + Request: ThreadActivityKindsLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId, activityKinds }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM ( + SELECT + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ${sql.in("kind", activityKinds)} + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + ) AS recent_activities ORDER BY sequence ASC, created_at ASC, @@ -1251,6 +1382,110 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const pinnedThreadActivityIdsCte = (threadId: string) => sql` +pending_approval_requests AS ( + SELECT request_id, thread_id + FROM projection_pending_approvals + WHERE thread_id = ${threadId} + AND status = 'pending' + ), + pending_approval_activities AS ( + SELECT + activity.activity_id, + ROW_NUMBER() OVER ( + PARTITION BY pending.request_id + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS request_order + FROM pending_approval_requests AS pending + CROSS JOIN projection_thread_activities AS activity + WHERE activity.thread_id = pending.thread_id + AND activity.kind = 'approval.requested' + AND json_extract(activity.payload_json, '$.requestId') = pending.request_id + ), + pending_user_input_thread AS ( + SELECT thread_id + FROM projection_threads + WHERE thread_id = ${threadId} + AND pending_user_input_count > 0 + ), + user_input_lifecycle AS ( + SELECT + activity.activity_id, + activity.kind, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(activity.payload_json, '$.requestId') + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS request_order + FROM pending_user_input_thread AS pending + CROSS JOIN projection_thread_activities AS activity + WHERE activity.thread_id = pending.thread_id + AND ( + activity.kind IN ('user-input.requested', 'user-input.resolved') + OR ( + activity.kind = 'provider.user-input.respond.failed' + AND ( + lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%stale pending user-input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending user-input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending user input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending codex user input request%' + ) + ) + ) + AND json_extract(activity.payload_json, '$.requestId') IS NOT NULL + ), + pinned_activity_ids AS ( + SELECT activity_id + FROM pending_approval_activities + WHERE request_order = 1 + UNION ALL + SELECT activity_id + FROM user_input_lifecycle + WHERE request_order = 1 + AND kind = 'user-input.requested' + ) + `; + + // Blocking request payloads must remain available even if they predate the + // recent activity window. Each CTE returns at most one unresolved row per + // request, so the merge below stays bounded by actionable work. + const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + WITH ${pinnedThreadActivityIdsCte(threadId)} + SELECT + activity.activity_id AS "activityId", + activity.thread_id AS "threadId", + activity.turn_id AS "turnId", + activity.tone, + activity.kind, + activity.summary, + activity.payload_json AS "payload", + activity.sequence, + activity.created_at AS "createdAt" + FROM pinned_activity_ids AS pinned + INNER JOIN projection_thread_activities AS activity + ON activity.activity_id = pinned.activity_id + ORDER BY activity.created_at ASC, activity.activity_id ASC + `, + }); + + const listPinnedThreadActivityIdsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityIdRowSchema, + execute: ({ threadId }) => + sql` + WITH ${pinnedThreadActivityIdsCte(threadId)} + SELECT activity_id AS "activityId" + FROM pinned_activity_ids + `, + }); + const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({ Request: ThreadTurnRangeLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -1301,6 +1536,48 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadActivityIdsByThreadWindow = SqlSchema.findAll({ + Request: ThreadTurnRangeLookupInput, + Result: ProjectionThreadActivityIdRowSchema, + execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) => + sql` + SELECT activity_id AS "activityId" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ( + turn_id IN ( + SELECT turn_id FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + AND ( + requested_at > ${minAnchorAt} + OR ( + requested_at = ${minAnchorAt} + AND turn_id >= ${minTurnKey} + ) + ) + AND ( + requested_at < ${beforeAnchorAt} + OR ( + requested_at = ${beforeAnchorAt} + AND turn_id < ${beforeTurnKey} + ) + ) + ) + OR ( + turn_id IS NULL + AND created_at >= ${minAnchorAt} + AND created_at < ${beforeAnchorAt} + ) + ) + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + `, + }); + const getFullThreadDiffContextRow = SqlSchema.findOneOption({ Request: FullThreadDiffContextLookupInput, Result: ProjectionFullThreadDiffContextRowSchema, @@ -2367,13 +2644,136 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { readonly beforeTurnKey: string; } - const getThreadDetailByIdBounded = (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) => + type ThreadDetailActivityRead = + | { + readonly mode: "raw"; + readonly query?: ProjectionThreadDetailQuery; + } + | { + readonly mode: "client"; + }; + + const listProjectedThreadActivities = Effect.fn( + "ProjectionSnapshotQuery.listProjectedThreadActivities", + )(function* (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) { + const [activityIdRows, pinnedActivityIdRows] = yield* Effect.all([ + (bounds === undefined + ? listThreadActivityIdsByThread({ threadId }) + : listThreadActivityIdsByThreadWindow({ threadId, ...bounds }) + ).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivityIds:decodeRows", + ), + ), + ), + listPinnedThreadActivityIdsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivityIds:decodeRows", + ), + ), + ), + ]); + const activityIds = [ + ...new Set([...activityIdRows, ...pinnedActivityIdRows].map(({ activityId }) => activityId)), + ]; + const activities: OrchestrationThreadActivity[] = []; + + for ( + let offset = 0; + offset < activityIds.length; + offset += THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE + ) { + const batchIds = activityIds.slice( + offset, + offset + THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE, + ); + const batchRows = yield* listThreadActivityRowsByIds({ activityIds: batchIds }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivityPayloadBatch:decodeRows", + ), + ), + ); + for (const row of batchRows) { + activities.push(projectActivityPayload(mapThreadActivityRow(row))); + } + } + + return activities.toSorted( + (left, right) => + (left.sequence ?? -1) - (right.sequence ?? -1) || + left.createdAt.localeCompare(right.createdAt) || + left.id.localeCompare(right.id), + ); + }); + + const getThreadDetailByIdBounded = ( + threadId: ThreadId, + bounds: ThreadDetailBounds | undefined, + activityRead: ThreadDetailActivityRead = { mode: "raw" }, + ) => Effect.gen(function* () { + const activitiesEffect = + activityRead.mode === "client" + ? listProjectedThreadActivities(threadId, bounds) + : Effect.all([ + (activityRead.query?.activityKinds === undefined + ? bounds === undefined + ? listThreadActivityRowsByThread({ threadId }) + : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) + : activityRead.query.activityKinds.length === 0 + ? Effect.succeed([]) + : listThreadActivityRowsByThreadAndKinds({ + threadId, + activityKinds: activityRead.query.activityKinds, + }) + ).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", + ), + ), + ), + activityRead.query?.activityKinds === undefined + ? listPinnedThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", + ), + ), + ) + : Effect.succeed([]), + ]).pipe( + Effect.map(([activityRows, pinnedActivityRows]) => + [ + ...new Map( + [...activityRows, ...pinnedActivityRows].map( + (row) => [row.activityId, row] as const, + ), + ).values(), + ] + .toSorted( + (left, right) => + (left.sequence ?? -1) - (right.sequence ?? -1) || + left.createdAt.localeCompare(right.createdAt) || + left.activityId.localeCompare(right.activityId), + ) + .map(mapThreadActivityRow), + ), + ); + const [ threadRow, messageRows, proposedPlanRows, - activityRows, + activities, checkpointRows, latestTurnRow, sessionRow, @@ -2405,17 +2805,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - (bounds === undefined - ? listThreadActivityRowsByThread({ threadId }) - : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) - ).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", - "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", - ), - ), - ), + activitiesEffect, listCheckpointRowsByThread({ threadId }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2483,21 +2873,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return message; }), proposedPlans: proposedPlanRows.map(mapProposedPlanRow), - activities: activityRows.map((row) => { - const activity = { - id: row.activityId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - turnId: row.turnId, - createdAt: row.createdAt, - }; - if (row.sequence !== null) { - return Object.assign(activity, { sequence: row.sequence }); - } - return activity; - }), + activities, checkpoints: checkpointRows.map((row) => ({ turnId: row.turnId, checkpointTurnCount: row.checkpointTurnCount, @@ -2519,8 +2895,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ); }); - const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => - getThreadDetailByIdBounded(threadId, undefined); + const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = ( + threadId, + query, + ) => + getThreadDetailByIdBounded(threadId, undefined, { + mode: "raw", + ...(query === undefined ? {} : { query }), + }); // Bounds pathological fan-out: one user turn that spawned hundreds of // subagent turns still pages in bounded chunks, at the cost of splitting the @@ -2544,7 +2926,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { .withTransaction( Effect.gen(function* () { if (window?.turnLimit === undefined) { - const thread = yield* getThreadDetailById(threadId); + const thread = yield* getThreadDetailByIdBounded(threadId, undefined, { + mode: "client", + }); if (Option.isNone(thread)) { return Option.none(); } @@ -2597,7 +2981,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" } : undefined; - const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds); + const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds, { + mode: "client", + }); if (Option.isNone(thread)) { return Option.none(); } diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index cd54b8e5..ba8a297f 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -54,6 +54,15 @@ export interface ProjectionFullThreadDiffContext { readonly toCheckpointRef: CheckpointRef | null; } +export interface ProjectionThreadDetailQuery { + /** + * Limit activities before SQLite returns and decodes their payloads. + * Any explicit filter omits pinned-request reads. An empty list also skips + * the activity query. Omit this option to preserve the full detail response. + */ + readonly activityKinds?: ReadonlyArray; +} + /** * ProjectionSnapshotQueryShape - Service API for read-model snapshots. */ @@ -168,6 +177,7 @@ export interface ProjectionSnapshotQueryShape { */ readonly getThreadDetailById: ( threadId: ThreadId, + query?: ProjectionThreadDetailQuery, ) => Effect.Effect, ProjectionRepositoryError>; /** @@ -181,6 +191,10 @@ export interface ProjectionSnapshotQueryShape { * response carries `page` metadata (see `OrchestrationThreadDetailWindow`). * Without a window the full thread is returned with no `page` field β€” * pagination is strictly opt-in. + * + * Activity payloads are projected for clients as they are read in small + * sequential batches. Callers still apply the full snapshot projector for + * collection-level activity pruning. */ readonly getThreadDetailSnapshot: ( threadId: ThreadId, From f38658b13913bce0dfd210030a2c73b006ead049 Mon Sep 17 00:00:00 2001 From: Michael Buluma <1452922+buluma@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:28:18 +0300 Subject: [PATCH 06/15] fix(server): reconcile cherry-picked usage/stream imports with helmcode scope --- .../orchestration/ThreadLiveEventCoalescer.test.ts | 2 +- .../src/orchestration/ThreadLiveEventCoalescer.ts | 2 +- apps/server/src/usage/UsageService.test.ts | 4 ++-- apps/server/src/usage/UsageService.ts | 6 ++---- apps/server/src/usage/usageScanCache.test.ts | 12 +++++------- apps/server/src/ws.ts | 5 +---- 6 files changed, 12 insertions(+), 19 deletions(-) diff --git a/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts b/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts index 0a991529..60f29785 100644 --- a/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts +++ b/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts @@ -5,7 +5,7 @@ import { TurnId, type OrchestrationEvent, type OrchestrationThreadActivity, -} from "@t3tools/contracts"; +} from "@helmcode/contracts"; import { it } from "@effect/vitest"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; diff --git a/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts b/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts index 8271f6a5..b7545dfa 100644 --- a/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts +++ b/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts @@ -1,4 +1,4 @@ -import type { OrchestrationEvent, OrchestrationThreadStreamItem } from "@t3tools/contracts"; +import type { OrchestrationEvent, OrchestrationThreadStreamItem } from "@helmcode/contracts"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 8fc86ee3..46fb8811 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -6,8 +6,8 @@ import * as NodePath from "node:path"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@helmcode/shared/hostProcess"; +import { UsageDay, type UsageSummaryInput } from "@helmcode/contracts"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 83986bc7..18a1a370 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -339,7 +339,7 @@ export const make = Effect.gen(function* () { // instance we already hold so the scan stays context-free. const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); const scanned: ScannedDir[] = []; - for (const { provider, dir, fileName } of dirs) { + for (const { provider, dir } of dirs) { const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); const exists = yield* fileSystem .exists(dir) @@ -348,9 +348,7 @@ export const make = Effect.gen(function* () { scanned.push({ provider, dir, volumeId, files: null }); continue; } - const files = yield* Effect.promise(() => - listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }), - ); + const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs)); const parsedFiles: { path: string; records: readonly UsageRecord[] }[] = []; for (const file of files) { const records = yield* readFileRecords(file.path, file.size, file.mtimeMs, provider); diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index cec3ea4d..f3d652ad 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -60,14 +60,12 @@ describe("scan cache round trip", () => { ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5" })]], ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]], ]); - original.set("/grok.jsonl", { + original.set("/tail.jsonl", { size: 40, mtimeMs: 300, - provider: "grok", - records: [ - record({ provider: "grok", model: "grok-4.5-build", dedupeKey: "s:p:grok-4.5-build" }), - ], - tailRecords: [record({ provider: "grok", model: "grok-4.5-build", dedupeKey: null })], + provider: "claude", + records: [record({ model: "claude-opus-5", dedupeKey: "s:p:claude-opus-5" })], + tailRecords: [record({ model: "claude-opus-5", dedupeKey: null })], position: position({ resumeOffset: 30, guardLength: 30, guardHash: 123 }), }); original.set("/codex.jsonl", { @@ -93,7 +91,7 @@ describe("scan cache round trip", () => { expect(restored.size).toBe(4); expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl")); expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl")); - expect(restored.get("/grok.jsonl")).toEqual(original.get("/grok.jsonl")); + expect(restored.get("/tail.jsonl")).toEqual(original.get("/tail.jsonl")); expect(restored.get("/codex.jsonl")).toEqual(original.get("/codex.jsonl")); }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 02c9da99..85a6f6be 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -72,10 +72,7 @@ import { projectThreadDetailSnapshot, } from "./orchestration/ActivityPayloadProjection.ts"; import { makeThreadLiveEventCoalescer } from "./orchestration/ThreadLiveEventCoalescer.ts"; -import { - cleanupFailedUploadedAttachments, - normalizeDispatchCommand, -} from "./orchestration/Normalizer.ts"; +import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { From 263c31aec51478ec257ba92a7054724fed724002 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 31 Aug 2026 20:36:53 -0700 Subject: [PATCH 07/15] perf(server): reduce frequency of full tool call output being loaded into memory from db (#8988) --- .../orchestration/Layers/CheckpointReactor.ts | 2 +- .../Layers/ProjectionPipeline.test.ts | 170 ++++++++++++------ .../Layers/ProjectionPipeline.ts | 2 +- .../Layers/ProjectionSnapshotQuery.test.ts | 71 ++++++++ .../Layers/ProjectionSnapshotQuery.ts | 2 +- .../Layers/ProviderCommandReactor.ts | 2 +- .../Layers/ProviderRuntimeIngestion.ts | 10 +- .../Layers/ProjectionThreadActivities.ts | 72 ++++++-- .../Services/ProjectionThreadActivities.ts | 9 + 9 files changed, 264 insertions(+), 76 deletions(-) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 2704bba5..a8470e2c 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -164,7 +164,7 @@ const make = Effect.gen(function* () { const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery - .getThreadDetailById(threadId) + .getThreadDetailById(threadId, { activityKinds: [] }) .pipe(Effect.map(Option.getOrUndefined)); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 5886d81c..772581be 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1993,7 +1993,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); - it.effect("clears stale pending user input from projected shell summaries", () => + it.effect("reads only user-input activities when refreshing shell summaries", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -2051,70 +2051,128 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }, }); + // Invalid JSON proves the summary query filters tool rows before decoding payloads. + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-malformed-tool-output', + 'thread-stale-user-input', + NULL, + 'info', + 'tool.completed', + 'Tool completed', + '{not-json', + NULL, + '2026-02-26T12:35:02.000Z' + ), + ( + 'activity-user-input-resolved-requested', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.requested', + 'User input requested', + json_object('requestId', 'user-input-resolved'), + NULL, + '2026-02-26T12:35:03.000Z' + ), + ( + 'activity-user-input-resolved', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.resolved', + 'User input resolved', + json_object('requestId', 'user-input-resolved'), + NULL, + '2026-02-26T12:35:04.000Z' + ), + ( + 'activity-user-input-stale-requested', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.requested', + 'User input requested', + json_object('requestId', 'user-input-stale'), + NULL, + '2026-02-26T12:35:05.000Z' + ), + ( + 'activity-user-input-stale-failed', + 'thread-stale-user-input', + NULL, + 'error', + 'provider.user-input.respond.failed', + 'Provider user input response failed', + json_object( + 'requestId', + 'user-input-stale', + 'detail', + 'Unknown pending Codex user input request: user-input-stale' + ), + NULL, + '2026-02-26T12:35:06.000Z' + ), + ( + 'activity-user-input-active-requested', + 'thread-stale-user-input', + NULL, + 'info', + 'user-input.requested', + 'User input requested', + json_object('requestId', 'user-input-active'), + NULL, + '2026-02-26T12:35:07.000Z' + ), + ( + 'activity-user-input-active-failed', + 'thread-stale-user-input', + NULL, + 'error', + 'provider.user-input.respond.failed', + 'Provider user input response failed', + json_object( + 'requestId', + 'user-input-active', + 'detail', + 'Provider is temporarily unavailable' + ), + NULL, + '2026-02-26T12:35:08.000Z' + ) + `; + yield* appendAndProject({ - type: "thread.activity-appended", + type: "thread.message-sent", eventId: EventId.make("evt-stale-user-input-3"), aggregateKind: "thread", aggregateId: ThreadId.make("thread-stale-user-input"), - occurredAt: "2026-02-26T12:35:02.000Z", + occurredAt: "2026-02-26T12:35:09.000Z", commandId: CommandId.make("cmd-stale-user-input-3"), causationEventId: null, correlationId: CorrelationId.make("cmd-stale-user-input-3"), metadata: {}, payload: { threadId: ThreadId.make("thread-stale-user-input"), - activity: { - id: EventId.make("activity-stale-user-input-requested"), - tone: "info", - kind: "user-input.requested", - summary: "User input requested", - payload: { - requestId: "user-input-request-stale-1", - questions: [ - { - id: "sandbox_mode", - header: "Sandbox", - question: "Which mode should be used?", - options: [ - { - label: "workspace-write", - description: "Allow workspace writes only", - }, - ], - }, - ], - }, - turnId: null, - createdAt: "2026-02-26T12:35:02.000Z", - }, - }, - }); - - yield* appendAndProject({ - type: "thread.activity-appended", - eventId: EventId.make("evt-stale-user-input-4"), - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-stale-user-input"), - occurredAt: "2026-02-26T12:35:03.000Z", - commandId: CommandId.make("cmd-stale-user-input-4"), - causationEventId: null, - correlationId: CorrelationId.make("cmd-stale-user-input-4"), - metadata: {}, - payload: { - threadId: ThreadId.make("thread-stale-user-input"), - activity: { - id: EventId.make("activity-stale-user-input-failed"), - tone: "error", - kind: "provider.user-input.respond.failed", - summary: "Provider user input response failed", - payload: { - requestId: "user-input-request-stale-1", - detail: - "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: user-input-request-stale-1", - }, - turnId: null, - createdAt: "2026-02-26T12:35:03.000Z", - }, + messageId: MessageId.make("message-stale-user-input"), + role: "user", + text: "Continue", + turnId: null, + streaming: false, + createdAt: "2026-02-26T12:35:09.000Z", + updatedAt: "2026-02-26T12:35:09.000Z", }, }); @@ -2125,7 +2183,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { FROM projection_threads WHERE thread_id = 'thread-stale-user-input' `; - assert.deepEqual(threadRows, [{ pendingUserInputCount: 0 }]); + assert.deepEqual(threadRows, [{ pendingUserInputCount: 1 }]); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 146abcd4..f6915ee3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -565,7 +565,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([ projectionThreadMessageRepository.listByThreadId({ threadId }), projectionThreadProposedPlanRepository.listByThreadId({ threadId }), - projectionThreadActivityRepository.listByThreadId({ threadId }), + projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ threadId }), projectionPendingApprovalRepository.listByThreadId({ threadId }), ]); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 3cb7fbdd..5bd8bb60 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -475,6 +475,77 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { if (threadDetail._tag === "Some") { assert.deepEqual(threadDetail.value, snapshot.threads[0]); } + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + created_at + ) + VALUES + ( + 'activity-task-started', + 'thread-1', + 'turn-1', + 'info', + 'task.started', + 'Ship the query filter', + '{"taskId":"task-1","detail":"Ship the query filter"}', + '2026-02-24T00:00:06.100Z' + ), + ( + 'activity-malformed-tool', + 'thread-1', + 'turn-1', + 'info', + 'tool.completed', + 'Malformed tool output', + 'not-json', + '2026-02-24T00:00:06.200Z' + ) + `; + + const detailWithoutActivities = yield* snapshotQuery.getThreadDetailById( + ThreadId.make("thread-1"), + { activityKinds: [] }, + ); + assert.equal(detailWithoutActivities._tag, "Some"); + if (detailWithoutActivities._tag === "Some") { + assert.deepEqual(detailWithoutActivities.value.activities, []); + assert.deepEqual(detailWithoutActivities.value.messages, snapshot.threads[0]?.messages); + assert.deepEqual( + detailWithoutActivities.value.proposedPlans, + snapshot.threads[0]?.proposedPlans, + ); + assert.deepEqual( + detailWithoutActivities.value.checkpoints, + snapshot.threads[0]?.checkpoints, + ); + } + + const detailWithTaskActivities = yield* snapshotQuery.getThreadDetailById( + ThreadId.make("thread-1"), + { activityKinds: ["task.started", "task.progress"] }, + ); + assert.equal(detailWithTaskActivities._tag, "Some"); + if (detailWithTaskActivities._tag === "Some") { + assert.deepEqual(detailWithTaskActivities.value.activities, [ + { + id: asEventId("activity-task-started"), + tone: "info", + kind: "task.started", + summary: "Ship the query filter", + payload: { taskId: "task-1", detail: "Ship the query filter" }, + turnId: asTurnId("turn-1"), + createdAt: "2026-02-24T00:00:06.100Z", + }, + ]); + } }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 370e9bdf..74660bd3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -65,8 +65,8 @@ import { type ProjectionFullThreadDiffContext, type ProjectionSnapshotCounts, type ProjectionThreadCheckpointContext, - type ProjectionSnapshotQueryShape, type ProjectionThreadDetailQuery, + type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b0d1f1eb..833d681f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -443,7 +443,7 @@ const make = Effect.gen(function* () { const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) { return yield* projectionSnapshotQuery - .getThreadDetailById(threadId) + .getThreadDetailById(threadId, { activityKinds: [] }) .pipe(Effect.map(Option.getOrUndefined)); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index eb67f0d6..5a18ef05 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -47,6 +47,7 @@ import { ServerSettingsService } from "../../serverSettings.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; +const TASK_TITLE_ACTIVITY_KINDS = ["task.started", "task.progress"] as const; // Fallback when the in-memory description cache no longer has the task name // (server restart, session-exit sweep, TTL/capacity eviction): earlier @@ -958,9 +959,12 @@ const make = Effect.gen(function* () { ), ); - const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) { + const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* ( + threadId: ThreadId, + activityKinds: ReadonlyArray = [], + ) { return yield* projectionSnapshotQuery - .getThreadDetailById(threadId) + .getThreadDetailById(threadId, { activityKinds }) .pipe(Effect.map(Option.getOrUndefined)); }); @@ -2088,7 +2092,7 @@ const make = Effect.gen(function* () { if (event.type === "task.completed") { taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); if (!taskTitle) { - const threadDetail = yield* getLoadedThreadDetail(); + const threadDetail = yield* resolveThreadDetail(thread.id, TASK_TITLE_ACTIVITY_KINDS); taskTitle = findTaskTitleInActivities(threadDetail?.activities, event.payload.taskId); } } diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index a627337c..e964bf71 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -23,6 +23,21 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( }), ); +const mapActivityRows = ( + rows: ReadonlyArray>, +): ReadonlyArray => + rows.map((row) => ({ + activityId: row.activityId, + threadId: row.threadId, + turnId: row.turnId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + })); + function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown) => Schema.isSchemaError(cause) @@ -97,6 +112,36 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { `, }); + const listUserInputLifecycleActivityRows = SqlSchema.findAll({ + Request: ListProjectionThreadActivitiesInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND kind IN ( + 'user-input.requested', + 'user-input.resolved', + 'provider.user-input.respond.failed' + ) + ORDER BY + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + const deleteProjectionThreadActivityRows = SqlSchema.void({ Request: DeleteProjectionThreadActivitiesInput, execute: ({ threadId }) => @@ -124,21 +169,21 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { "ProjectionThreadActivityRepository.listByThreadId:decodeRows", ), ), - Effect.map((rows) => - rows.map((row) => ({ - activityId: row.activityId, - threadId: row.threadId, - turnId: row.turnId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - ...(row.sequence !== null ? { sequence: row.sequence } : {}), - createdAt: row.createdAt, - })), - ), + Effect.map(mapActivityRows), ); + const listUserInputLifecycleByThreadId: ProjectionThreadActivityRepositoryShape["listUserInputLifecycleByThreadId"] = + (input) => + listUserInputLifecycleActivityRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.listUserInputLifecycleByThreadId:query", + "ProjectionThreadActivityRepository.listUserInputLifecycleByThreadId:decodeRows", + ), + ), + Effect.map(mapActivityRows), + ); + const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => deleteProjectionThreadActivityRows(input).pipe( Effect.mapError( @@ -149,6 +194,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { return { upsert, listByThreadId, + listUserInputLifecycleByThreadId, deleteByThreadId, } satisfies ProjectionThreadActivityRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index a096d99e..b92589fe 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -67,6 +67,15 @@ export interface ProjectionThreadActivityRepositoryShape { input: ListProjectionThreadActivitiesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * List activity rows used to derive pending user-input state. + * + * Filters in SQLite so unrelated payloads do not enter server memory. + */ + readonly listUserInputLifecycleByThreadId: ( + input: ListProjectionThreadActivitiesInput, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Delete projected thread activity rows by thread. */ From 8d73eb36a5605d6e03a0fc3297b4ead45fc9fe1e Mon Sep 17 00:00:00 2001 From: Michael Buluma <1452922+buluma@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:52:54 +0300 Subject: [PATCH 08/15] fix(clients): dedupe skills in composer menus (#8043, helmcode sorcery) --- .../src/features/threads/ThreadComposer.tsx | 19 +++++++++++++- apps/web/src/providerSkillSearch.test.ts | 26 +++++++++++++++++++ apps/web/src/providerSkillSearch.ts | 16 +++++++++++- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 56221449..b445b98f 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -7,6 +7,7 @@ import type { ProviderInteractionMode, RuntimeMode, ServerConfig as HelmCodeServerConfig, + ServerProviderSkill, } from "@helmcode/contracts"; import { detectComposerTrigger, @@ -79,6 +80,20 @@ import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet- */ export const COMPOSER_COLLAPSED_CHROME = 60; +function dedupeProviderSkillsByName( + skills: ReadonlyArray, +): ServerProviderSkill[] { + const seenNames = new Set(); + return skills.filter((skill) => { + const normalizedName = skill.name.trim().toLowerCase(); + if (seenNames.has(normalizedName)) { + return false; + } + seenNames.add(normalizedName); + return true; + }); +} + /** * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset). * Used by the parent to compute the larger feed bottom inset when the composer is focused. @@ -431,7 +446,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } if (composerTrigger.kind === "skill") { - const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled); + const enabledSkills = dedupeProviderSkillsByName( + (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled), + ); const normalizedQuery = normalizeSearchQuery(composerTrigger.query, { trimLeadingPattern: /^\$+/, }); diff --git a/apps/web/src/providerSkillSearch.test.ts b/apps/web/src/providerSkillSearch.test.ts index eb484c85..fcf4c14e 100644 --- a/apps/web/src/providerSkillSearch.test.ts +++ b/apps/web/src/providerSkillSearch.test.ts @@ -56,4 +56,30 @@ describe("searchProviderSkills", () => { expect(searchProviderSkills(skills, "ui").map((skill) => skill.name)).toEqual([]); }); + + it("returns every enabled skill for an empty query", () => { + const skills = [ + makeSkill({ name: "unslop" }), + makeSkill({ name: "browser" }), + makeSkill({ name: "disabled", enabled: false }), + ]; + + expect(searchProviderSkills(skills, "").map((skill) => skill.name)).toEqual([ + "unslop", + "browser", + ]); + }); + + it("returns the first enabled definition for each skill name", () => { + const skills = [ + makeSkill({ name: "branch-audit", path: "/Users/matt/.codex/skills/branch-audit/SKILL.md" }), + makeSkill({ name: "browser" }), + makeSkill({ name: "branch-audit", path: "/Users/matt/.agents/skills/branch-audit/SKILL.md" }), + ]; + + expect(searchProviderSkills(skills, "").map((skill) => skill.path)).toEqual([ + "/Users/matt/.codex/skills/branch-audit/SKILL.md", + "/tmp/browser/SKILL.md", + ]); + }); }); diff --git a/apps/web/src/providerSkillSearch.ts b/apps/web/src/providerSkillSearch.ts index 03afb80c..9d62a258 100644 --- a/apps/web/src/providerSkillSearch.ts +++ b/apps/web/src/providerSkillSearch.ts @@ -66,12 +66,26 @@ function scoreProviderSkill(skill: ServerProviderSkill, query: string): number | return Math.min(...scores); } +function dedupeProviderSkillsByName( + skills: ReadonlyArray, +): ServerProviderSkill[] { + const seenNames = new Set(); + return skills.filter((skill) => { + const normalizedName = skill.name.trim().toLowerCase(); + if (seenNames.has(normalizedName)) { + return false; + } + seenNames.add(normalizedName); + return true; + }); +} + export function searchProviderSkills( skills: ReadonlyArray, query: string, limit = Number.POSITIVE_INFINITY, ): ServerProviderSkill[] { - const enabledSkills = skills.filter((skill) => skill.enabled); + const enabledSkills = dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled)); const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ }); if (!normalizedQuery) { From beae0b1ad6a8476446b9feb495eb3143338aa7cb Mon Sep 17 00:00:00 2001 From: Yukun Shan <92423096+nateEc@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:38:19 +0800 Subject: [PATCH 09/15] fix(server): allow long thread IDs in HTTP routes (#8898) --- apps/server/src/server.test.ts | 38 +++++++++++++++++++++++++++++++++- apps/server/src/server.ts | 7 +++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 458890af..7116b406 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -103,7 +103,7 @@ const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; -import { makeRoutesLayer } from "./server.ts"; +import { HTTP_ROUTER_CONFIG, makeRoutesLayer } from "./server.ts"; import { isThreadDetailEvent, resolveAvailableEditorsForConfig } from "./ws.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GitManager from "./git/GitManager.ts"; @@ -655,6 +655,7 @@ const buildAppUnderTest = (options?: { { disableListenLog: true, disableLogger: true, + routerConfig: HTTP_ROUTER_CONFIG, }, ).pipe( Layer.provide( @@ -1523,6 +1524,41 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("serves snapshots for MCP handoff thread IDs above the router default", () => + Effect.gen(function* () { + const threadId = ThreadId.make( + "thread:mcp:abfba0d2-b591-4b7e-aad1-e943d89811fa:handoff%3A0ae5edf4-2ea3-4ee3-ba7c-48de3ac92896%3A2026-08-24T17%3A08%3A52.138Z:0", + ); + const thread = { + ...makeDefaultOrchestrationReadModel().threads[0]!, + id: threadId, + }; + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getThreadDetailSnapshot: (requestedThreadId) => + Effect.succeed( + requestedThreadId === threadId + ? Option.some({ snapshotSequence: 1, thread }) + : Option.none(), + ), + }, + }, + }); + + const response = yield* fetchEffect( + yield* getHttpServerUrl(`/api/orchestration/threads/${encodeURIComponent(threadId)}`), + { headers: { cookie: yield* getAuthenticatedSessionCookieHeader() } }, + ); + const snapshot = yield* responseJsonEffect<{ + readonly thread: { readonly id: ThreadId }; + }>(response); + + assert.equal(response.status, 200); + assert.equal(snapshot.thread.id, threadId); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("compresses large JSON responses through the composed routes", () => Effect.gen(function* () { const descriptor = { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2eb46072..a6d674b3 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -118,6 +118,12 @@ import * as RelayClient from "@helmcode/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@helmcode/tailscale"; import { forkParked, ServerActivation } from "./serverActivation.ts"; +// MCP handoff thread IDs include escaped provenance and can exceed find-my-way's +// 100-character default for one path segment. +export const HTTP_ROUTER_CONFIG = { + maxParamLength: 512, +} as const; + // Effect's default preemptive shutdown waits 20s before finalizing request scopes. // HelmCode's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer // already closes the websocket gracefully. Do not add an artificial drain before @@ -661,6 +667,7 @@ export const makeServerLayer = Layer.unwrap( const routesLayer = HttpRouter.serve(makeRoutesLayer.pipe(Layer.provide(launcherLayer)), { disableLogger: !config.logWebSocketEvents, + routerConfig: HTTP_ROUTER_CONFIG, }).pipe(Layer.tap(() => Deferred.succeed(routesReady, undefined).pipe(Effect.orDie))); const serverApplicationLayer = Layer.mergeAll( routesLayer, From 0a7b1d595ffc8c27fac42b9f21fa1c8f5fb20a31 Mon Sep 17 00:00:00 2001 From: Simone Date: Tue, 1 Sep 2026 00:59:39 +0200 Subject: [PATCH 10/15] fix(server): preserve usage cache outside walked roots (#8540) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- apps/server/src/usage/usageScanCache.test.ts | 14 ++++++++++++++ apps/server/src/usage/usageScanCache.ts | 13 ++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index f3d652ad..d581e106 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -262,6 +262,20 @@ describe("pruneScanCache with an unwalked root", () => { expect(removed).toBe(0); expect(cache.size).toBe(1); }); + + it("keeps entries under a sibling path that only shares the walked root prefix", () => { + const cache = cacheWith([["/claude/projects-copy/a.jsonl", 5000, [record()]]]); + + const removed = pruneScanCache(cache, { + livePaths: new Set(), + walkedRoots: ["/claude/projects"], + windowStartMs: 4000, + retentionCutoffMs: 1000, + }); + + expect(removed).toBe(0); + expect(cache.size).toBe(1); + }); }); describe("dedupeWithinFile", () => { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 5e93070e..ecf83cb6 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -14,6 +14,9 @@ * * @module usageScanCache */ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; + import type { UsageProviderKind } from "@helmcode/contracts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; @@ -321,7 +324,15 @@ export function pruneScanCache(cache: ScanCache, options: PruneOptions): number let removed = 0; for (const [path, entry] of cache) { const agedOut = entry.mtimeMs < options.retentionCutoffMs; - const underWalkedRoot = options.walkedRoots.some((root) => path.startsWith(root)); + const underWalkedRoot = options.walkedRoots.some((root) => { + const relative = NodePath.relative(root, path); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${NodePath.sep}`) && + !NodePath.isAbsolute(relative)) + ); + }); const deleted = underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); if (agedOut || deleted) { From 815749ce03867e744e35acd1850a94f7244333e5 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:08:52 +0530 Subject: [PATCH 11/15] fix(server): skip IDE detection in Claude probes (#8634) --- .../src/provider/Layers/ClaudeCapabilitiesProbe.test.ts | 4 ++++ apps/server/src/provider/Layers/ClaudeProvider.ts | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index fc663b0f..bead0109 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -38,6 +38,7 @@ it("isolates Claude capability probes without dropping workspace setting sources environment: { HOME: "/home/user", ENABLE_CLAUDEAI_MCP_SERVERS: "true", + FORCE_CODE_TERMINAL: "1", }, cwd: "/workspace/project", }); @@ -52,6 +53,9 @@ it("isolates Claude capability probes without dropping workspace setting sources assert.equal(options.abortController, abortController); assert.equal(options.env?.HOME, "/home/user"); assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); + assert.equal(options.env?.FORCE_CODE_TERMINAL, undefined); + assert.equal(options.env?.CLAUDE_CODE_AUTO_CONNECT_IDE, "0"); + assert.equal(options.env?.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL, "1"); }); it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b5851513..2b64ea06 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -605,6 +605,12 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: { // Connected claude.ai MCP servers are discovered outside filesystem // config; disable them independently for this health check. ENABLE_CLAUDEAI_MCP_SERVERS: "false", + // This is a noninteractive health check, so IDE discovery cannot add any + // useful capability data. Skipping it also avoids Claude spawning a + // Windows `tasklist | findstr` process tree on every periodic refresh. + FORCE_CODE_TERMINAL: undefined, + CLAUDE_CODE_AUTO_CONNECT_IDE: "0", + CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1", }, ...(input.cwd ? { cwd: input.cwd } : {}), stderr: () => {}, From 43cbd1f281e8153e8384ab9d04497659e2575a90 Mon Sep 17 00:00:00 2001 From: Michael Buluma <1452922+buluma@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:43:27 +0300 Subject: [PATCH 12/15] fix(server): stop OpenCode child sessions during teardown When a user stops a turn or session, T3 Code now aborts the full OpenCode child session tree (via session.children API) instead of only the parent session. This prevents subagent work from continuing after the user stops. Changes: - Add abortOpenCodeDescendants: recursively walks the session tree and aborts all descendants with concurrency limit of 8 - Update abortOpenCodeSessionForTeardown: stops parent then all children - Update stopOpenCodeContext: uses the new teardown logic - Revert incomplete interruptTurn refactor from staged changes Test: - Verify child sessions are aborted during stopSession Co-authored-by: Kilo --- .../provider/Layers/OpenCodeAdapter.test.ts | 76 ++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 149 +++++++++++++++++- 2 files changed, 214 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 9633e90c..9b0d4d6b 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -15,6 +15,16 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { beforeEach } from "vite-plus/test"; +function promiseWithResolvers() { + let resolve: (value: T | PromiseLike) => void; + let reject: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve: resolve!, reject: reject! }; +} + import { OpenCodeSettings, ProviderDriverKind, @@ -63,6 +73,15 @@ const runtimeMock = { sessionCreateInputs: [] as Array>, authHeaders: [] as Array, abortCalls: [] as string[], + abortSignals: [] as AbortSignal[], + abortImplementation: null as + | ((sessionID: string, signal?: AbortSignal) => Promise) + | null, + sessionChildrenCalls: [] as string[], + sessionChildrenById: new Map>(), + sessionChildrenImplementation: null as + | ((sessionID: string) => Promise>) + | null, closeCalls: [] as string[], revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, promptCalls: [] as Array, @@ -76,6 +95,12 @@ const runtimeMock = { sessionDirectoryById: new Map(), sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>, forkCalls: [] as Array<{ sessionID: string; directory?: string }>, + sessionStatusCalls: 0, + sessionStatusFailures: 0, + sessionStatusImplementation: null as + | (() => Promise<{ data: Record }>) + | null, + sessionStatus: "idle" as "idle" | "busy", }, reset() { this.state.startCalls.length = 0; @@ -83,6 +108,11 @@ const runtimeMock = { this.state.sessionCreateInputs.length = 0; this.state.authHeaders.length = 0; this.state.abortCalls.length = 0; + this.state.abortSignals.length = 0; + this.state.abortImplementation = null; + this.state.sessionChildrenCalls.length = 0; + this.state.sessionChildrenById.clear(); + this.state.sessionChildrenImplementation = null; this.state.closeCalls.length = 0; this.state.revertCalls.length = 0; this.state.promptCalls.length = 0; @@ -96,6 +126,10 @@ const runtimeMock = { this.state.sessionDirectoryById.clear(); this.state.sessionUpdateCalls.length = 0; this.state.forkCalls.length = 0; + this.state.sessionStatusCalls = 0; + this.state.sessionStatusFailures = 0; + this.state.sessionStatusImplementation = null; + this.state.sessionStatus = "idle"; }, }; @@ -176,8 +210,36 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { } return { data: { id: forkedId, ...(directory ? { directory } : {}) } }; }, - abort: async ({ sessionID }: { sessionID: string }) => { + abort: async ({ sessionID }: { sessionID: string }, options?: { signal?: AbortSignal }) => { runtimeMock.state.abortCalls.push(sessionID); + if (options?.signal) { + runtimeMock.state.abortSignals.push(options.signal); + } + await runtimeMock.state.abortImplementation?.(sessionID, options?.signal); + }, + children: async ({ sessionID }: { sessionID: string }) => { + runtimeMock.state.sessionChildrenCalls.push(sessionID); + return { + data: runtimeMock.state.sessionChildrenImplementation + ? await runtimeMock.state.sessionChildrenImplementation(sessionID) + : (runtimeMock.state.sessionChildrenById.get(sessionID) ?? []), + }; + }, + status: async () => { + runtimeMock.state.sessionStatusCalls += 1; + if (runtimeMock.state.sessionStatusImplementation) { + return await runtimeMock.state.sessionStatusImplementation(); + } + if (runtimeMock.state.sessionStatusFailures > 0) { + runtimeMock.state.sessionStatusFailures -= 1; + throw new Error("status failed"); + } + return { + data: + runtimeMock.state.sessionStatus === "idle" + ? {} + : { "http://127.0.0.1:9999/session": { type: "busy" as const } }, + }; }, promptAsync: async (input: unknown) => { runtimeMock.state.promptCalls.push(input); @@ -588,6 +650,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { it.effect("stops a configured-server session without trying to own server lifecycle", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; + const rootSessionId = "http://127.0.0.1:9999/session"; + runtimeMock.state.sessionChildrenById.set(rootSessionId, [{ id: "ses_stop_child" }]); + runtimeMock.state.sessionChildrenById.set("ses_stop_child", [{ id: "ses_stop_grandchild" }]); yield* adapter.startSession({ provider: ProviderDriverKind.make("opencode"), threadId: asThreadId("thread-opencode"), @@ -597,10 +662,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { yield* adapter.stopSession(asThreadId("thread-opencode")); NodeAssert.deepEqual(runtimeMock.state.startCalls, []); - NodeAssert.deepEqual( - runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"), - true, - ); + NodeAssert.deepEqual(runtimeMock.state.abortCalls, [ + rootSessionId, + "ses_stop_child", + "ses_stop_grandchild", + ]); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index fa4de6b8..ded8d9ea 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -22,7 +22,9 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import type { AssistantMessage, @@ -183,6 +185,31 @@ type OpenCodeSubscribedEvent = ? TEvent : never; +type OpenCodeSessionStatusEvent = Extract< + OpenCodeSubscribedEvent, + { readonly type: "session.status" } +>; + +const OpenCodeSessionStatusMap = Schema.Record( + Schema.String, + Schema.Struct({ type: Schema.String }), +); +const decodeOpenCodeSessionStatusMap = Schema.decodeUnknownOption(OpenCodeSessionStatusMap); + +type OpenCodeTerminalRequestEvent = Extract< + OpenCodeSubscribedEvent, + { + readonly type: "permission.replied" | "question.replied" | "question.rejected"; + } +>; + +type OpenCodeAskedRequestEvent = Extract< + OpenCodeSubscribedEvent, + { readonly type: "permission.asked" | "question.asked" } +>; + +type OpenCodeRoutedRequestEvent = OpenCodeAskedRequestEvent | OpenCodeTerminalRequestEvent; + function trimText(value: string | undefined | null): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; @@ -598,6 +625,117 @@ function updateProviderSession( }); } +function applyProviderSessionUpdate( + context: OpenCodeSessionContext, + patch: Partial, + options: + | { + readonly clearActiveTurnId?: boolean; + readonly clearLastError?: boolean; + } + | undefined, + updatedAt: string, +): ProviderSession { + const nextSession = { + ...context.session, + ...patch, + updatedAt, + } as ProviderSession & Record; + const mutableSession = nextSession as Record; + if (options?.clearActiveTurnId) { + delete mutableSession.activeTurnId; + } + if (options?.clearLastError) { + delete mutableSession.lastError; + } + context.session = nextSession; + return nextSession; +} + +const abortOpenCodeDescendants = Effect.fn("abortOpenCodeDescendants")(function* ( + context: OpenCodeSessionContext, +) { + const visited = new Set([context.openCodeSessionId]); + const requestSemaphore = Semaphore.makeUnsafe(8); + + const visit = ( + sessionId: string, + abortSession: boolean, + ): Effect.Effect => + Effect.gen(function* () { + let firstFailure: OpenCodeRuntimeError | undefined; + if (abortSession) { + const abortResult = yield* requestSemaphore + .withPermit( + runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: sessionId }), + ), + ) + .pipe( + Effect.catchIf( + (cause) => isOpenCodeNotFound(cause), + () => Effect.void, + ), + Effect.result, + ); + if (abortResult._tag === "Failure") { + firstFailure = abortResult.failure; + } + } + + const childrenResult = yield* requestSemaphore + .withPermit( + runOpenCodeSdk("session.children", () => + context.client.session.children({ sessionID: sessionId }), + ), + ) + .pipe( + Effect.catchIf( + (cause) => isOpenCodeNotFound(cause), + () => Effect.void, + ), + Effect.result, + ); + if (childrenResult._tag === "Failure") { + return firstFailure ?? childrenResult.failure; + } + + const children = + (childrenResult as { success?: { data: Array<{ id: string }> } }).success?.data ?? []; + const newChildren = children.filter((child) => { + if (visited.has(child.id)) { + return false; + } + visited.add(child.id); + return true; + }); + const childFailures = yield* Effect.forEach(newChildren, (child) => visit(child.id, true), { + concurrency: 8, + }); + firstFailure ??= childFailures.find((failure) => failure !== undefined); + return firstFailure; + }); + + const firstFailure = yield* visit(context.openCodeSessionId, false); + if (firstFailure) { + return yield* firstFailure; + } +}); + +const abortOpenCodeSessionForTeardown = Effect.fn("abortOpenCodeSessionForTeardown")(function* ( + context: OpenCodeSessionContext, +) { + // Stop the parent before the snapshot so it cannot add another child after + // the adapter reads the tree. + yield* runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.timeout("1 second"), Effect.ignore({ log: true })); + yield* abortOpenCodeDescendants(context).pipe( + Effect.timeout("1 second"), + Effect.ignore({ log: true }), + ); +}); + const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( context: OpenCodeSessionContext, ) { @@ -606,12 +744,11 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( return false; } - // Best-effort remote abort. The scope close below tears down the local - // handles (event-pump fiber, server-exit fiber, event-subscribe fetch), - // but we still want to tell OpenCode that this session is done. - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.ignore({ log: true })); + // Best-effort remote abort including child sessions. The scope close below + // tears down the local handles (event-pump fiber, server-exit fiber, + // event-subscribe fetch), but we still want to tell OpenCode that this + // session and its descendants are done. + yield* abortOpenCodeSessionForTeardown(context); // Closing the session scope interrupts every fiber forked into it and // runs each finalizer we registered β€” the `AbortController.abort()` call, From 68b3b417a952effaad15b1327cd865bc6138ab2e Mon Sep 17 00:00:00 2001 From: Michael Buluma <1452922+buluma@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:07:52 +0300 Subject: [PATCH 13/15] fix(usage, protocol): three review fixes 1. usageScanCache.ts:222 - Remove unsupported 'grok' from provider filter (only 'claude' and 'codex' are valid UsageProviderKind) 2. protocol.ts:163-164 - Change incomingRequests queue from sliding (drops items) to unbounded; rate limiting is handled by activeRequestHandlers semaphore 3. UsageService.ts:540-547 - Add semaphore (capacity 2) to bound concurrent detached scanSummary operations, preventing unbounded parallel corpus scans All tests pass, typecheck clean. --- apps/server/src/usage/UsageService.ts | 18 +++++++++++++----- apps/server/src/usage/usageScanCache.ts | 2 +- .../effect-codex-app-server/src/protocol.ts | 3 +-- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 18a1a370..f5fe7545 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -33,6 +33,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; @@ -514,6 +515,10 @@ export const make = Effect.gen(function* () { */ const inflightScans = new Map>(); + // Bounds concurrent detached scans to prevent unbounded parallel corpus scans. + // The value 2 mirrors the `Effect.all` concurrency used for rates + dirs. + const scanSemaphore = yield* Semaphore.make(2); + const scanKey = (input: UsageSummaryInput): string => JSON.stringify([ input.timeZone, @@ -537,13 +542,16 @@ export const make = Effect.gen(function* () { inflightScans.set(key, created); // Detached so one departing client cannot tear the scan out from under // the fibers awaiting it; a finished scan warms the cache either way. - yield* scanSummary(input).pipe( - Effect.onExit((exit) => - Effect.sync(() => inflightScans.delete(key)).pipe( - Effect.andThen(Deferred.done(created, exit)), + // Acquire semaphore to bound concurrent detached scans. + yield* scanSemaphore.withPermit( + scanSummary(input).pipe( + Effect.onExit((exit) => + Effect.sync(() => inflightScans.delete(key)).pipe( + Effect.andThen(Deferred.done(created, exit)), + ), ), + Effect.forkDetach, ), - Effect.forkDetach, ); return created; }), diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index ecf83cb6..735c74c3 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -219,7 +219,7 @@ export function decodeScanCache(document: unknown): ScanCache { if (typeof raw !== "object" || raw === null) continue; const entry = raw as Partial; if (typeof entry.s !== "number" || typeof entry.m !== "number") continue; - if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue; + if (entry.p !== "claude" && entry.p !== "codex") continue; if (!isRecordArray(entry.r) || !isRecordArray(entry.t)) continue; // Position fields feed byte offsets and a Buffer allocation in the reader, // so anything outside their real ranges must reject the entry: a bogus diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts index 5572e61a..e876afff 100644 --- a/packages/effect-codex-app-server/src/protocol.ts +++ b/packages/effect-codex-app-server/src/protocol.ts @@ -160,8 +160,7 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa const outgoing = yield* Queue.unbounded>(); const incomingNotifications = yield* Queue.sliding(MAX_BUFFERED_RAW_MESSAGES); - const incomingRequests = - yield* Queue.sliding(MAX_BUFFERED_RAW_MESSAGES); + const incomingRequests = yield* Queue.unbounded(); const pending = yield* Ref.make(new Map()); const nextRequestId = yield* Ref.make(1); const remainder = yield* Ref.make(""); From 476ab114b7a650dd1ab53357c31984d64c78d799 Mon Sep 17 00:00:00 2001 From: Michael Buluma <1452922+buluma@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:18:10 +0300 Subject: [PATCH 14/15] test(server): fix test pollution by stopping session in first test The test 'reuses a configured OpenCode server URL' started a session with threadId 'thread-opencode' but never stopped it, leaving it in the adapter's internal sessions map. When the subsequent test 'stops a configured-server session' used the same threadId, startSession found the existing session, stopped it (triggering aborts), then created a new one. The test's own stopSession then triggered aborts again, doubling the abortCalls. --- apps/server/src/provider/Layers/OpenCodeAdapter.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 9b0d4d6b..816e25d3 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -362,6 +362,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { NodeAssert.deepEqual(runtimeMock.state.authHeaders, [ `Basic ${btoa("opencode:secret-password")}`, ]); + + yield* adapter.stopSession(asThreadId("thread-opencode")); }), ); From 1ef9522b3b1a26d7c6098104b624e086f851b46e Mon Sep 17 00:00:00 2001 From: Michael Buluma <1452922+buluma@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:38:36 +0300 Subject: [PATCH 15/15] fix(usage): hold semaphore permit for full scan duration in detached fiber The previous fix acquired the semaphore outside the forkDetach, releasing the permit before the scan ran. Now the semaphore wraps the scan inside the detached fiber, so the permit is held for the full scan duration including onExit cleanup. --- apps/server/src/usage/UsageService.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index f5fe7545..4a6506d2 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -542,15 +542,17 @@ export const make = Effect.gen(function* () { inflightScans.set(key, created); // Detached so one departing client cannot tear the scan out from under // the fibers awaiting it; a finished scan warms the cache either way. - // Acquire semaphore to bound concurrent detached scans. - yield* scanSemaphore.withPermit( - scanSummary(input).pipe( - Effect.onExit((exit) => - Effect.sync(() => inflightScans.delete(key)).pipe( - Effect.andThen(Deferred.done(created, exit)), + // Acquire semaphore inside the detached fiber so the permit is held for + // the full scan duration including cleanup. + yield* Effect.forkDetach( + scanSemaphore.withPermit( + scanSummary(input).pipe( + Effect.onExit((exit) => + Effect.sync(() => inflightScans.delete(key)).pipe( + Effect.andThen(Deferred.done(created, exit)), + ), ), ), - Effect.forkDetach, ), ); return created;