summaryrefslogtreecommitdiffhomepage
path: root/packages/core
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 13:34:33 +0900
committerAdam Malczewski <[email protected]>2026-06-02 13:34:33 +0900
commit48c120e5cd400b2e2b8afae0afcc7c8bc4d2ccb4 (patch)
tree2c434aeba0db7d6ec5b87e2f7fe2c81352f0888c /packages/core
parentb734eb96bf0af267fdfbef85df51940ca0b4e8c7 (diff)
downloaddispatch-48c120e5cd400b2e2b8afae0afcc7c8bc4d2ccb4.tar.gz
dispatch-48c120e5cd400b2e2b8afae0afcc7c8bc4d2ccb4.zip
fix: reconcile live cacheStats to DB truth on turn-sealed
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.
Diffstat (limited to 'packages/core')
-rw-r--r--packages/core/src/db/chunks.ts23
-rw-r--r--packages/core/src/types/index.ts33
2 files changed, 41 insertions, 15 deletions
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 }
/**