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/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 e6b0a42f..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, }, }; @@ -412,9 +496,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 +522,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 +542,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/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 bb5947aa..772581be 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; + + 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 }>` - SELECT text FROM projection_thread_messages WHERE message_id = 'message-a' + 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; @@ -1948,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; @@ -2006,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", }, }); @@ -2080,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 7f3e4383..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 }), ]); @@ -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/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 9e982fc7..5bd8bb60 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); @@ -474,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", + }, + ]); + } }), ); @@ -2287,6 +2359,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..74660bd3 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 { @@ -64,12 +65,20 @@ import { type ProjectionFullThreadDiffContext, type ProjectionSnapshotCounts, type ProjectionThreadCheckpointContext, + type ProjectionThreadDetailQuery, type ProjectionSnapshotQueryShape, } 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/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.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..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)); }); @@ -1504,6 +1508,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 +1539,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"; @@ -2076,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/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, diff --git a/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts b/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts new file mode 100644 index 00000000..60f29785 --- /dev/null +++ b/apps/server/src/orchestration/ThreadLiveEventCoalescer.test.ts @@ -0,0 +1,171 @@ +import { + EventId, + MessageId, + ThreadId, + TurnId, + type OrchestrationEvent, + type OrchestrationThreadActivity, +} from "@helmcode/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..b7545dfa --- /dev/null +++ b/apps/server/src/orchestration/ThreadLiveEventCoalescer.ts @@ -0,0 +1,207 @@ +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"; +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/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/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/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. */ 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. */ 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/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: () => {}, 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..816e25d3 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); @@ -300,6 +362,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { NodeAssert.deepEqual(runtimeMock.state.authHeaders, [ `Basic ${btoa("opencode:secret-password")}`, ]); + + yield* adapter.stopSession(asThreadId("thread-opencode")); }), ); @@ -588,6 +652,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 +664,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", + ]); }), ); @@ -1126,12 +1194,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..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; @@ -460,9 +487,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), }; } @@ -594,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, ) { @@ -602,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, 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/server.test.ts b/apps/server/src/server.test.ts index 2fa23ac0..7116b406 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, @@ -101,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"; @@ -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", @@ -615,6 +655,7 @@ const buildAppUnderTest = (options?: { { disableListenLog: true, disableLogger: true, + routerConfig: HTTP_ROUTER_CONFIG, }, ).pipe( Layer.provide( @@ -1483,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 = { @@ -6164,6 +6240,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/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, diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts new file mode 100644 index 00000000..46fb8811 --- /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 "@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"; +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..4a6506d2 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,12 +26,14 @@ 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"; 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"; @@ -257,7 +260,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 +284,83 @@ 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 } 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)); + 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 +393,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 +406,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 +426,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 +441,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 +449,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 +508,61 @@ 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>(); + + // 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, + 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. + // 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)), + ), + ), + ), + ), + ); + 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..d581e106 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,72 @@ 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("/tail.jsonl", { + size: 40, + mtimeMs: 300, + 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", { + 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("/tail.jsonl")).toEqual(original.get("/tail.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", () => { @@ -184,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 7121be3f..735c74c3 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -14,19 +14,33 @@ * * @module usageScanCache */ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; + 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 +68,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 +101,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 +159,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 +193,7 @@ export function decodeScanCache(document: unknown): ScanCache { !Number.isFinite(output) || !Number.isFinite(reasoning) ) { - corrupt = true; - break; + return null; } records.push({ @@ -192,14 +212,89 @@ export function decodeScanCache(document: unknown): ScanCache { dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null, }); } + return records; + }; - if (corrupt) continue; - cache.set(path, { size: entry.s, mtimeMs: entry.m, provider, 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") 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; + + 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; @@ -229,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) { @@ -240,9 +343,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; } 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/apps/server/src/ws.ts b/apps/server/src/ws.ts index edf78cd1..85a6f6be 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -71,6 +71,7 @@ import { projectActivityEvent, projectThreadDetailSnapshot, } from "./orchestration/ActivityPayloadProjection.ts"; +import { makeThreadLiveEventCoalescer } from "./orchestration/ThreadLiveEventCoalescer.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -1326,17 +1327,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 +1384,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 +1428,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; 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) { 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..e876afff 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,19 @@ 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 incomingNotifications = + 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(""); 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 +200,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 +218,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 +232,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 +296,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 +325,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 +349,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 +395,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) => {