From 48c120e5cd400b2e2b8afae0afcc7c8bc4d2ccb4 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Tue, 2 Jun 2026 13:34:33 +0900 Subject: fix: reconcile live cacheStats to DB truth on turn-sealed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the live-accumulator overshoot a Gemini review surfaced: the frontend adds every streamed usage event to cacheStats, but a rate-limited fallback attempt's usage is discarded server-side (never persisted). Live numbers overshot until a reload re-seeded from the DB aggregate. Fix: turn-sealed (emitted AFTER the atomic usage-row write) now carries the authoritative getUsageStatsForTab aggregate. The store REPLACES (not adds) cacheStats with it every turn — landing the just-sealed turn's usage AND self-healing any live drift, including the discarded-fallback overshoot. No extra round-trip (piggybacks turn-sealed); idempotent in the happy path. - core: add UsageStats type; getUsageStatsForTab returns it; turn-sealed gains optional usageStats field. - api: agent-manager reads getUsageStatsForTab post-flush and attaches it to the turn-sealed emit (try/catch: omit on DB error). - frontend: turn-sealed handler replaces cacheStats (undefined ⇒ untouched back-compat; null ⇒ clear). Tests: frontend reconcile/self-heal/back-compat/null-clear; api turn-sealed carries aggregate. 509 -> 514 passing; typecheck + biome green. --- packages/core/src/db/chunks.ts | 23 +++++++++-------------- packages/core/src/types/index.ts | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 15 deletions(-) (limited to 'packages/core') diff --git a/packages/core/src/db/chunks.ts b/packages/core/src/db/chunks.ts index 509cbbd..e0aadf3 100644 --- a/packages/core/src/db/chunks.ts +++ b/packages/core/src/db/chunks.ts @@ -5,7 +5,14 @@ import { groupRowsToMessages, type MessageRow, } from "../chunks/transform.js"; -import type { ChunkData, ChunkRow, ChunkRowDraft, TextData, UsageData } from "../types/index.js"; +import type { + ChunkData, + ChunkRow, + ChunkRowDraft, + TextData, + UsageData, + UsageStats, +} from "../types/index.js"; import { getDatabase } from "./index.js"; // Re-export the DB-free transforms so existing barrel consumers @@ -173,19 +180,7 @@ export function getTotalChunkCount(tabId: string): number { * Sums in JS after selecting the rows (mirroring `mapRow`) to avoid relying on * `json_extract` over the freeform `data_json`. */ -export function getUsageStatsForTab(tabId: string): { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - requests: number; - last: { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - } | null; -} | null { +export function getUsageStatsForTab(tabId: string): UsageStats | null { const db = getDatabase(); const rows = db .query("SELECT data_json FROM chunks WHERE tab_id = $tabId AND type = 'usage' ORDER BY seq ASC") diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index abade27..a1fd2a8 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -143,6 +143,29 @@ export interface UsageData { cacheWriteTokens: number; } +/** + * Aggregate per-tab usage telemetry: the cumulative sum across ALL persisted + * `usage` rows, the request count, and the most recent request's split. This is + * the server-side source of truth (complete regardless of frontend + * eviction/pagination) returned by `getUsageStatsForTab`. Structurally + * identical to the frontend `CacheStats` so it can seed it directly. `null` when + * the tab has no usage rows. + */ +export interface UsageStats { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + /** Number of LLM requests (usage rows) counted. */ + requests: number; + last: { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + } | null; +} + export type ChunkData = | TextData | ThinkingData @@ -249,8 +272,16 @@ export type AgentEvent = * fold its transient live representation into the sealed chunk log. Emitted * after `status: idle`/`error` (which fire before the DB write). Display/sync * only — not conversation content. + * + * Carries `usageStats`: the tab's authoritative usage aggregate read from the + * DB AFTER the turn's usage rows were written. The frontend REPLACES (not adds) + * its live `cacheStats` with this, reconciling the live accumulator to the + * persisted truth every turn. This self-heals the live overshoot that occurs + * when a rate-limited fallback attempt's usage is streamed live but then + * discarded server-side (never persisted). `null` ⇒ tab has no usage rows; + * absent ⇒ leave `cacheStats` untouched (back-compat). */ - | { type: "turn-sealed"; turnId: string } + | { type: "turn-sealed"; turnId: string; usageStats?: UsageStats | null } | { type: "text-delta"; delta: string } | { type: "reasoning-delta"; delta: string } /** -- cgit v1.2.3