From affd40e23024a972f12c5a9014212cb3bbb079d9 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sat, 22 Aug 2026 11:38:46 -0700 Subject: [PATCH 1/2] payload: add-only stale marker for read-only stale menubar serves (#771) isSessionHydrationComplete() (parser.ts, PR #937) already reports when a read-only serve skipped or staled real on-disk changes; thread it through buildMenubarPayload as an optional stale field, present and true only on a stale serve, always absent otherwise, so older/newer CLI-app pairs stay compatible. Mirrors the field into the desktop renderer types and the macOS menubar's Codable payload model (data layer only, no view change). Drafted with minimax/MiniMax-M3 via local gateway. --- app/renderer/lib/types.ts | 3 +++ .../CodeBurnMenubar/Data/MenubarPayload.swift | 12 ++++++++++-- src/menubar-json.ts | 10 ++++++++++ src/usage-aggregator.ts | 2 +- tests/menubar-json.test.ts | 10 ++++++++++ tests/usage-aggregator.test.ts | 1 + 6 files changed, 35 insertions(+), 3 deletions(-) diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 22a80d80..82614631 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -127,6 +127,9 @@ export type ClaudeConfigSelector = { export type MenubarPayload = { generated: string + // Optional: older CLIs omit it. Present and true only on a stale read-only + // serve; absent otherwise. Absence must always be read as "assume fresh." + stale?: boolean current: { label: string cost: number diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index d09db99d..274c0865 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -4,6 +4,11 @@ import Foundation /// `current` is scoped to the requested period; the whole payload reflects that slice. struct MenubarPayload: Codable, Sendable { let generated: String + /// Present and `true` only when this payload was assembled from a + /// read-only stale serve. Absent — never `false` — on a fresh payload; + /// absence must be read as "assume fresh," including for payloads from + /// a CLI version that predates this field. + let stale: Bool? let current: CurrentBlock let optimize: OptimizeBlock let history: HistoryBlock @@ -15,8 +20,10 @@ struct MenubarPayload: Codable, Sendable { optimize: OptimizeBlock, history: HistoryBlock, combined: CombinedUsage?, - claudeConfigs: ClaudeConfigSelector? = nil) { + claudeConfigs: ClaudeConfigSelector? = nil, + stale: Bool? = nil) { self.generated = generated + self.stale = stale self.current = current self.optimize = optimize self.history = history @@ -25,12 +32,13 @@ struct MenubarPayload: Codable, Sendable { } enum CodingKeys: String, CodingKey { - case generated, current, optimize, history, combined, claudeConfigs + case generated, stale, current, optimize, history, combined, claudeConfigs } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) generated = try c.decode(String.self, forKey: .generated) + stale = try c.decodeIfPresent(Bool.self, forKey: .stale) current = try c.decode(CurrentBlock.self, forKey: .current) optimize = try c.decode(OptimizeBlock.self, forKey: .optimize) history = try c.decode(HistoryBlock.self, forKey: .history) diff --git a/src/menubar-json.ts b/src/menubar-json.ts index 74354268..19d743fc 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -178,6 +178,12 @@ export type ClaudeConfigSelector = { export type MenubarPayload = { generated: string + /// Optional. Present and `true` only when this payload was assembled from a + /// read-only stale serve (see `isSessionHydrationComplete` in `parser.ts`). + /// Omitted — never `false` — on a fresh/complete payload, so absence always + /// means "assume fresh," including for payloads from a CLI version that + /// predates this field. + stale?: boolean current: { label: string cost: number @@ -507,6 +513,7 @@ export function buildMenubarPayload( breakdowns?: BreakdownArrays, claudeConfigs?: ClaudeConfigSelector, granularHistory?: GranularHistory, + stale?: boolean, ): MenubarPayload { const payload: MenubarPayload = { generated: new Date().toISOString(), @@ -557,5 +564,8 @@ export function buildMenubarPayload( if (claudeConfigs && claudeConfigs.options.length > 1) { payload.claudeConfigs = claudeConfigs } + if (stale) { + payload.stale = true + } return payload } diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index d8b03817..805c5364 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -944,5 +944,5 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange, opts.provider) const granularRange = opts.daysSelection?.range ?? scanRange const granularHistory = opts.timeline === false ? undefined : buildGranularHistory(scanProjects, granularRange) - return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory) + return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory, isSessionHydrationComplete() ? undefined : true) } diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts index cd447417..393482a2 100644 --- a/tests/menubar-json.test.ts +++ b/tests/menubar-json.test.ts @@ -413,4 +413,14 @@ describe('buildMenubarPayload', () => { ], }) }) + + it('sets stale:true when the caller reports an incomplete hydration', () => { + const payload = buildMenubarPayload(emptyPeriod('Today'), [], null, undefined, undefined, undefined, undefined, undefined, undefined, true) + expect(payload.stale).toBe(true) + }) + + it('omits stale on a normal fresh build', () => { + const payload = buildMenubarPayload(emptyPeriod('Today'), [], null) + expect(payload.stale).toBeUndefined() + }) }) diff --git a/tests/usage-aggregator.test.ts b/tests/usage-aggregator.test.ts index 1d7083a3..2e15a403 100644 --- a/tests/usage-aggregator.test.ts +++ b/tests/usage-aggregator.test.ts @@ -23,5 +23,6 @@ describe('buildMenubarPayloadForRange', () => { expect(payload.current.codexCredits).toBeGreaterThanOrEqual(0) // optimize:false => scanAndDetect skipped => empty optimize block regardless of data expect(payload.optimize).toEqual({ findingCount: 0, savingsUSD: 0, topFindings: [] }) + expect(payload.stale).toBeUndefined() }) }) From e19c099b0507bca6470a8dc56fd38c92af740778 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sat, 22 Aug 2026 11:56:03 -0700 Subject: [PATCH 2/2] fix: capture hydration flag right after the primary parse, not at return isSessionHydrationComplete() (parser.ts) reads a module-level global that is only safe immediately after the parse it describes, with no intervening awaits. The stale-marker wiring landed in the prior commit read it at the bottom of buildMenubarPayloadForRange, after several awaits -- including, on the claude-config-scoped branch, a second parseAllSessions call for the 365-day history block that runs after the parse producing the headline data. That let the history backfill's hydration outcome silently overwrite the headline's. Capture the flag into a local right after each branch's primary parse resolves (the claude-config-scoped parse, or buildDurablePeriod) and thread that captured value to buildMenubarPayload instead of re-reading the global at the end. This also closes a cross-request race: once captured synchronously, no other in-flight request's parse can flip it under us. Regression test simulates a complete primary parse followed by an incomplete bystander parse and asserts the payload reflects the primary outcome, not the bystander's. Drafted with minimax/MiniMax-M3 via local gateway. --- src/usage-aggregator.ts | 9 +- tests/usage-aggregator-freshness.test.ts | 124 +++++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 tests/usage-aggregator-freshness.test.ts diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 805c5364..60a02429 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -569,12 +569,18 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: const requestedClaudeConfigSourceId = opts.claudeConfigSourceId?.trim() || null const isClaudeConfigScoped = requestedClaudeConfigSourceId !== null + // Captured synchronously right after whichever branch's primary parse resolves — + // the ONLY safe read point for the module-level hydration global. Re-reading the + // global later would race against this function's own history-block re-parse and + // against concurrent requests (web-dashboard SWR, parallel MCP calls). + let hydrationComplete: boolean | undefined let effectivelyScoped = false if (isClaudeConfigScoped) { // A config source scopes Claude usage only, so scan just Claude (main.ts // rejects a contradictory non-Claude --provider). This also avoids parsing // every other provider's corpus on each scoped refresh. const rawProjects = fp(await parseAllSessions(periodInfo.range, 'claude')) + hydrationComplete = isSessionHydrationComplete() const fullProjects = daysSelection ? filterProjectsByDays(rawProjects, daysSelection.days) : rawProjects claudeConfigs = await claudeConfigSelector(fullProjects, requestedClaudeConfigSourceId) const selectedSourceId = claudeConfigs?.selectedId ?? null @@ -601,6 +607,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: exclude: opts.exclude, daysSelection, }) + hydrationComplete = isSessionHydrationComplete() currentData = durable.data scanProjects = durable.liveProjects scanRange = durable.scanRange @@ -944,5 +951,5 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange, opts.provider) const granularRange = opts.daysSelection?.range ?? scanRange const granularHistory = opts.timeline === false ? undefined : buildGranularHistory(scanProjects, granularRange) - return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory, isSessionHydrationComplete() ? undefined : true) + return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory, hydrationComplete === false ? true : undefined) } diff --git a/tests/usage-aggregator-freshness.test.ts b/tests/usage-aggregator-freshness.test.ts new file mode 100644 index 00000000..c09b4f54 --- /dev/null +++ b/tests/usage-aggregator-freshness.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, beforeAll, vi } from 'vitest' + +import { buildMenubarPayloadForRange } from '../src/usage-aggregator.js' +import { getDateRange } from '../src/cli-date.js' +import { loadPricing } from '../src/models.js' +import type { ProjectSummary } from '../src/types.js' + +const ts = new Date().toISOString() + +function makeCall(savingsUSD: number, supplementary: boolean) { + return { + provider: 'copilot', + model: 'llama3.1:8b', + usage: { + inputTokens: 10, + outputTokens: supplementary ? 0 : 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + costUSD: 0.01, + savingsUSD, + savingsBaselineModel: 'gpt-4o', + tools: [], + mcpTools: [], + skills: [], + subagentTypes: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard' as const, + timestamp: ts, + bashCommands: [], + deduplicationKey: supplementary ? 'sav-supp' : 'sav-real', + ...(supplementary ? { supplementaryAccounting: true } : {}), + } +} + +const emptyCat = { turns: 0, costUSD: 0, savingsUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 } + +function sessionFor(sourceId: string, sourceLabel: string, sourcePath: string) { + return { + sessionId: `sess-${sourceId}`, + project: `proj-${sourceId}`, + firstTimestamp: ts, + lastTimestamp: ts, + totalCostUSD: 0.02, + totalSavingsUSD: 7, + totalInputTokens: 20, + totalOutputTokens: 20, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [{ + userMessage: 'hi', + timestamp: ts, + sessionId: `sess-${sourceId}`, + category: 'coding', + retries: 0, + hasEdits: false, + assistantCalls: [makeCall(5, false), makeCall(2, true)], + }], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + subagentBreakdown: {}, + categoryBreakdown: { coding: { ...emptyCat, turns: 1, costUSD: 0.02, savingsUSD: 7 } }, + skillBreakdown: {}, + source: { id: sourceId, label: sourceLabel, path: sourcePath, kind: 'claude-config' }, + } +} + +function fixtureProjects(): ProjectSummary[] { + return [{ + project: 'proj-a', + projectPath: 'proj-a', + sessions: [sessionFor('claude-config:a', 'A', '/a')], + totalCostUSD: 0.02, + totalSavingsUSD: 7, + totalApiCalls: 1, + }, { + project: 'proj-b', + projectPath: 'proj-b', + sessions: [sessionFor('claude-config:b', 'B', '/b')], + totalCostUSD: 0.02, + totalSavingsUSD: 7, + totalApiCalls: 1, + }] as unknown as ProjectSummary[] +} + +vi.mock('../src/parser.js', async (importOriginal) => { + const mod = await importOriginal() + let parseCalls = 0 + return { + ...mod, + parseAllSessions: vi.fn(async () => { + parseCalls++ + return fixtureProjects() + }), + isSessionHydrationComplete: vi.fn(() => parseCalls === 1), + } +}) + +describe('buildMenubarPayloadForRange: hydration freshness marker', () => { + beforeAll(async () => { + await loadPricing() + }) + + it('reflects the primary parse hydration, not a later bystander parse', async () => { + // Two parseAllSessions calls happen on the scoped branch: the primary one + // (claude-only) and a second one for the 365-day history block. The mock + // makes the primary complete (parseCalls === 1 -> true) and the second + // incomplete (parseCalls === 2 -> false). The payload must trust the + // primary outcome, captured immediately after it resolves. + const payload = await buildMenubarPayloadForRange(getDateRange('today'), { + provider: 'all', + optimize: false, + claudeConfigSourceId: 'claude-config:a', + }) + expect(payload.stale).toBeUndefined() + }) +})