summaryrefslogtreecommitdiffhomepage
path: root/packages/api
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/api
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/api')
-rw-r--r--packages/api/src/agent-manager.ts13
-rw-r--r--packages/api/tests/agent-manager.test.ts39
2 files changed, 51 insertions, 1 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 1db9a04..9d7300a 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -35,6 +35,7 @@ import {
getMessagesForTab,
getSetting,
getTab,
+ getUsageStatsForTab,
listOpenTabs,
loadAgent,
loadAgents,
@@ -55,6 +56,7 @@ import {
toAvailableSubagents,
toAvailableUserAgents,
type UsageData,
+ type UsageStats,
validateConfig,
} from "@dispatch/core";
import type { PermissionManager } from "./permission-manager.js";
@@ -1639,7 +1641,16 @@ export class AgentManager {
// above). Signal the frontend that the turn's rows — with real seqs — are
// durable so it can fold its live representation into the sealed log.
// Emitted AFTER status:idle/error (which fire before the DB write).
- this.emit({ type: "turn-sealed", turnId }, tabId);
+ // Carry the authoritative usage aggregate (read AFTER the usage rows were
+ // persisted) so the frontend reconciles its live cacheStats to the DB truth
+ // — self-healing the live overshoot from a discarded rate-limited attempt.
+ let usageStats: UsageStats | null = null;
+ try {
+ usageStats = getUsageStatsForTab(tabId);
+ } catch {
+ // DB read failed — omit reconciliation rather than crash the turn.
+ }
+ this.emit({ type: "turn-sealed", turnId, usageStats }, tabId);
// Turn fully settled — clear the shared turn id.
tabAgent.currentTurnId = null;
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
index 95fb558..6d7d66f 100644
--- a/packages/api/tests/agent-manager.test.ts
+++ b/packages/api/tests/agent-manager.test.ts
@@ -104,6 +104,13 @@ function resetAppendChunksCalls(): void {
appendChunksCalls.length = 0;
}
+// Seedable return value for the mocked getUsageStatsForTab — what the backend
+// reads (post-write) to attach to the `turn-sealed` event.
+const fakeUsageStatsByTab = new Map<string, unknown>();
+function resetFakeUsageStats(): void {
+ fakeUsageStatsByTab.clear();
+}
+
// Allow tests to swap in a custom `run` generator (e.g. to simulate
// a fallback failure mid-stream). Returning to undefined restores
// the default.
@@ -371,6 +378,9 @@ vi.mock("@dispatch/core", () => ({
getMessagesForTab(tabId: string) {
return fakeMessagesByTab.get(tabId) ?? [];
},
+ getUsageStatsForTab(tabId: string) {
+ return fakeUsageStatsByTab.get(tabId) ?? null;
+ },
appendEventToChunks: appendEventToChunksSpy,
applySystemEvent(_messages: unknown[], _event: unknown) {
return { messageId: "mock-system-msg" };
@@ -421,6 +431,7 @@ describe("AgentManager", () => {
setRunImpl(null);
appendEventToChunksSpy.mockClear();
resetAppendChunksCalls();
+ resetFakeUsageStats();
});
it("initial status is idle", () => {
@@ -1402,6 +1413,34 @@ describe("AgentManager", () => {
});
});
+ it("attaches the DB usage aggregate to the turn-sealed event for live reconciliation", async () => {
+ const manager = new AgentManager();
+ const aggregate = {
+ inputTokens: 222,
+ outputTokens: 22,
+ cacheReadTokens: 100,
+ cacheWriteTokens: 5,
+ requests: 1,
+ last: { inputTokens: 222, outputTokens: 22, cacheReadTokens: 100, cacheWriteTokens: 5 },
+ };
+ fakeUsageStatsByTab.set("tab-sealed-usage", aggregate);
+
+ const events: AgentEvent[] = [];
+ manager.onEvent((event) => {
+ events.push(event);
+ });
+
+ await manager.processMessage("tab-sealed-usage", "go");
+
+ const sealed = events.find((e) => e.type === "turn-sealed") as
+ | Extract<AgentEvent, { type: "turn-sealed" }>
+ | undefined;
+ expect(sealed).toBeDefined();
+ // The aggregate read AFTER the write is carried on the event so the
+ // frontend can REPLACE its live cacheStats with the DB truth.
+ expect(sealed?.usageStats).toEqual(aggregate);
+ });
+
it("emits usage rows in the SAME appendChunks call as the turn's content (one atomic write)", async () => {
const manager = new AgentManager();
setRunImpl(async function* () {