diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 1ca43c290..3044abef0 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -117,7 +117,11 @@ import { joinAdeAgentSkillRoots, splitAdeAgentSkillRoots, } from "../../desktop/src/shared/agentSkillRoots"; -import { createUsageTrackingService } from "../../desktop/src/main/services/usage/usageTrackingService"; +import { + attachSharedUsageTrackingScope, + createUsageTrackingService, + type UsageTrackingHost, +} from "../../desktop/src/main/services/usage/usageTrackingService"; import { createBudgetCapService } from "../../desktop/src/main/services/usage/budgetCapService"; import { createProductAnalyticsService, @@ -342,7 +346,7 @@ export type AdeRuntime = { linearIngressService?: ReturnType | null; cursorCloudIngressService?: ReturnType | null; feedbackReporterService?: ReturnType | null; - usageTrackingService?: ReturnType | null; + usageTrackingService?: UsageTrackingHost | null; productAnalyticsService?: ProductAnalyticsService | null; usageProductAnalyticsExporter?: UsageProductAnalyticsExporter | null; storageInsightsService?: ReturnType | null; @@ -1962,47 +1966,59 @@ export async function createAdeRuntime(args: { let lastDailyAnalyticsDay: string | null = null; let dailyAnalyticsInFlight: Promise | null = null; - let usageTrackingService: ReturnType; - usageTrackingService = createUsageTrackingService({ - logger, - db, - pollIntervalMs: 120_000, - onUpdate: (snapshot) => { - pushEvent("runtime", { type: "usage", snapshot }); - if (!productAnalyticsService.getStatus().effective || dailyAnalyticsInFlight) return; - const target = completedDailyUsageAnalyticsTarget(); - if (!target || lastDailyAnalyticsDay === target.day) return; - const current = Promise.resolve() - .then(async () => { - // Report the last completed local day. Capturing the in-progress - // "today" bucket on the first poll systematically missed providers, - // models, and actions used later in the day. - const stats = await usageTrackingService.getAdeUsageStats({ - preset: "today", - until: target.occurredAt, - scope: "project", - }); - captureDailyUsageAnalytics({ - analytics: productAnalyticsService, - stats, - projectId, - reportDay: target.day, - occurredAt: target.occurredAt, - }); - lastDailyAnalyticsDay = target.day; - }) - .catch((error) => { - logger.debug("product_analytics.daily_summary_failed", { - errorKind: error instanceof Error ? error.name : "unknown", + let usageTrackingService: ReturnType; + // Provider quota belongs to the machine, so this daemon polls it once and + // every project scope attaches to that one poller. Per-project inputs (the + // database ADE's own stats and account rollups live in, the repository + // GitHub activity is read from) ride on the scope, so project-scoped + // answers stay per project while the quota meter cannot drift between + // windows. + usageTrackingService = attachSharedUsageTrackingScope( + resolveMachineAdeLayout().adeDir, + () => createUsageTrackingService({ logger, pollIntervalMs: 120_000 }), + { + key: `${projectId}:${projectRoot}`, + db, + projectRoot, + logger, + onUpdate: (snapshot) => { + pushEvent("runtime", { type: "usage", snapshot }); + if (!productAnalyticsService.getStatus().effective || dailyAnalyticsInFlight) return; + const target = completedDailyUsageAnalyticsTarget(); + if (!target || lastDailyAnalyticsDay === target.day) return; + const current = Promise.resolve() + .then(async () => { + // Report the last completed local day. Capturing the in-progress + // "today" bucket on the first poll systematically missed providers, + // models, and actions used later in the day. + const stats = await usageTrackingService.getAdeUsageStats({ + preset: "today", + until: target.occurredAt, + scope: "project", + }); + captureDailyUsageAnalytics({ + analytics: productAnalyticsService, + stats, + projectId, + reportDay: target.day, + occurredAt: target.occurredAt, + }); + lastDailyAnalyticsDay = target.day; + }) + .catch((error) => { + logger.debug("product_analytics.daily_summary_failed", { + errorKind: error instanceof Error ? error.name : "unknown", + }); + }) + .finally(() => { + if (dailyAnalyticsInFlight === current) dailyAnalyticsInFlight = null; }); - }) - .finally(() => { - if (dailyAnalyticsInFlight === current) dailyAnalyticsInFlight = null; - }); - dailyAnalyticsInFlight = current; + dailyAnalyticsInFlight = current; + }, }, - projectRoot, - }); + ); + // Detaches this project. The shared poller keeps running for the scopes + // that are still open and shuts down only with the last one. teardown.push(() => usageTrackingService.dispose()); const storageInsightsService = createStorageInsightsService({ projectRoot, diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index cb1f84506..04292c10d 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -303,7 +303,7 @@ import type { createOrchestrationService } from "../../../../desktop/src/main/se import type { createPrService } from "../../../../desktop/src/main/services/prs/prService"; import type { createPrSummaryService } from "../../../../desktop/src/main/services/prs/prSummaryService"; import type { createPtyService } from "../../../../desktop/src/main/services/pty/ptyService"; -import type { createUsageTrackingService } from "../../../../desktop/src/main/services/usage/usageTrackingService"; +import type { UsageTrackingHost } from "../../../../desktop/src/main/services/usage/usageTrackingService"; import type { ProductAnalyticsService } from "../../../../desktop/src/main/services/analytics/productAnalyticsService"; import { parseProductAnalyticsCapture } from "../../../../desktop/src/shared/types/productAnalytics"; import { deleteTerminalSessionWithRuntimeCleanup } from "../../../../desktop/src/main/services/sessions/deleteTerminalSession"; @@ -339,7 +339,7 @@ type SyncRemoteCommandServiceArgs = { * production callers (bootstrap, syncHostService) always pass it. */ db?: AdeDb; - usageTrackingService?: ReturnType | null; + usageTrackingService?: UsageTrackingHost | null; productAnalyticsService?: ProductAnalyticsService | null; projectRoot?: string; laneService: ReturnType; diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index f9fff5a10..09c2679f5 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -85,7 +85,7 @@ import type { PushPublisherService } from "../push/pushPublisherService"; import { acquireSyncHostSingleton, type SyncHostSingletonLease } from "./syncHostSingleton"; import type { SharedSyncListener } from "./sharedSyncListener"; import type { ModelPickerStore } from "../modelPickerStore"; -import type { createUsageTrackingService } from "../../../../desktop/src/main/services/usage/usageTrackingService"; +import type { UsageTrackingHost } from "../../../../desktop/src/main/services/usage/usageTrackingService"; import type { ProductAnalyticsService } from "../../../../desktop/src/main/services/analytics/productAnalyticsService"; import type { AccountAuthService } from "../account/accountAuthService"; import { @@ -102,7 +102,7 @@ import { type SyncServiceArgs = { db: AdeDb; - usageTrackingService?: ReturnType | null; + usageTrackingService?: UsageTrackingHost | null; productAnalyticsService?: ProductAnalyticsService | null; logger: Logger; getAccountDirectoryHealth?: () => SyncAccountDirectoryHealth; diff --git a/apps/ade-cli/src/sharedUsageTracking.test.ts b/apps/ade-cli/src/sharedUsageTracking.test.ts new file mode 100644 index 000000000..b35f4ed57 --- /dev/null +++ b/apps/ade-cli/src/sharedUsageTracking.test.ts @@ -0,0 +1,119 @@ +/** + * The brain hosts several project scopes in one process and each of them used + * to build its own usage tracker: two 120s poll timers on different phases, two + * `lastSnapshot`s, two demand leases. Two ADE windows on one machine then + * showed two different Claude/Codex meters, and one of them was always behind. + * + * `bootstrap.ts` now attaches every scope to one machine-level tracker. These + * tests hold that wiring — the same call shape `createAdeRuntime` uses, without + * booting two full runtimes for it. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { UsageSnapshot } from "../../desktop/src/shared/types/usage"; +import { + attachSharedUsageTrackingScope, + clearSharedUsageTrackingServicesForTesting, + createUsageTrackingService, + peekSharedUsageTrackingService, +} from "../../desktop/src/main/services/usage/usageTrackingService"; + +const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +function fastDependencies() { + return { + pollClaudeUsage: vi.fn(async () => ({ windows: [] as never[], extraUsage: null, errors: [] as never[] })), + pollCodexUsage: vi.fn(async () => ({ windows: [] as never[], errors: [] as never[] })), + scanClaudeLogs: vi.fn(async () => [] as never[]), + scanCodexLogs: vi.fn(async () => [] as never[]), + scanCursorLogs: vi.fn(async () => [] as never[]), + scanCursorAgentLogs: vi.fn(async () => [] as never[]), + scanOpenClawLogs: vi.fn(async () => [] as never[]), + scanOpenCodeLogs: vi.fn(async () => [] as never[]), + scanDroidLogs: vi.fn(async () => [] as never[]), + scanCopilotLogs: vi.fn(async () => [] as never[]), + scanGeminiLogs: vi.fn(async () => [] as never[]), + }; +} + +type RuntimeEvent = { type: string; snapshot: UsageSnapshot }; + +/** Mirrors bootstrap: one scope per project, pushing into that scope's buffer. */ +function attachScope( + adeDir: string, + make: () => ReturnType, + project: { projectId: string; projectRoot: string }, +) { + const events: RuntimeEvent[] = []; + const scope = attachSharedUsageTrackingScope(adeDir, make, { + key: `${project.projectId}:${project.projectRoot}`, + projectRoot: project.projectRoot, + logger, + onUpdate: (snapshot) => events.push({ type: "usage", snapshot }), + }); + return { scope, events }; +} + +describe("shared usage tracking across project scopes", () => { + afterEach(() => { + clearSharedUsageTrackingServicesForTesting(); + }); + + it("gives two project scopes in one process one tracker and one snapshot", async () => { + const adeDir = "/tmp/ade-shared-usage-one"; + const dependencies = fastDependencies(); + const make = vi.fn(() => createUsageTrackingService({ logger, dependencies })); + + const first = attachScope(adeDir, make, { projectId: "p1", projectRoot: "/repo-one" }); + const second = attachScope(adeDir, make, { projectId: "p2", projectRoot: "/repo-two" }); + + expect(make).toHaveBeenCalledTimes(1); + expect(peekSharedUsageTrackingService(adeDir)).toBeDefined(); + + await first.scope.poll(); + + expect(first.events).toHaveLength(1); + expect(second.events).toHaveLength(1); + const a = first.events[0]!.snapshot; + const b = second.events[0]!.snapshot; + expect(a.revision?.producerId).toBeTruthy(); + expect(b.revision?.producerId).toBe(a.revision?.producerId); + expect(b.revision?.seq).toBe(a.revision?.seq); + expect(b.lastPolledAt).toBe(a.lastPolledAt); + // The provider was polled once for the machine, not once per project. + expect(dependencies.pollClaudeUsage).toHaveBeenCalledTimes(1); + + first.scope.dispose(); + second.scope.dispose(); + }); + + it("keeps delivering to the remaining scope when one project closes", async () => { + const adeDir = "/tmp/ade-shared-usage-close"; + const dependencies = fastDependencies(); + const make = vi.fn(() => createUsageTrackingService({ logger, dependencies })); + + const first = attachScope(adeDir, make, { projectId: "p1", projectRoot: "/repo-one" }); + const second = attachScope(adeDir, make, { projectId: "p2", projectRoot: "/repo-two" }); + + first.scope.start(); + second.scope.start(); + await first.scope.poll(); + const beforeClose = second.events.length; + + first.scope.dispose(); + await second.scope.poll(); + + expect(second.events.length).toBeGreaterThan(beforeClose); + expect(first.events).toHaveLength(beforeClose); + expect(second.events.at(-1)!.snapshot.revision!.producerId) + .toBe(second.events[0]!.snapshot.revision!.producerId); + // The last scope takes the tracker with it. + second.scope.dispose(); + expect(peekSharedUsageTrackingService(adeDir)).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 79a229be6..498f05786 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -295,7 +295,12 @@ import { isAutomationAllowedAdeAction, isCtoOnlyAdeAction, } from "./services/adeActions/registry"; -import { createUsageTrackingService } from "./services/usage/usageTrackingService"; +import { + createUsageTrackingService, + isUsageSnapshot, + type AccountRollupFetcher, +} from "./services/usage/usageTrackingService"; +import { bootedUsageScopeRoot } from "./services/usage/bootedUsageScope"; import { createBudgetCapService } from "./services/usage/budgetCapService"; import { markMachineStateMigrationComplete, @@ -4431,7 +4436,17 @@ app.whenReady().then(async () => { logger, db, pollIntervalMs: 120_000, + // In-process (tests): unbound windows have no runtime pump, so this + // tracker is the producer and must broadcast. Production project open + // never reaches this constructor — it uses `initRuntimeBackedProjectContext` + // with `usageTrackingService: null`. The remaining production caller is + // mobile-sync context init, which must not start a second poller or + // broadcast into the same channel the brain bridge already feeds. onUpdate: (snapshot) => { + if (shouldUseInProcessProjectRuntime()) { + broadcast(IPC.usageEvent, snapshot); + return; + } emitProjectEvent(projectRoot, IPC.usageEvent, snapshot); }, projectRoot, @@ -4591,17 +4606,19 @@ app.whenReady().then(async () => { }) : null; - scheduleBackgroundProjectTask( - "usage.start", - () => usageTrackingService.start(), - (error) => { - logger.warn("usage.start_failed", { - error: error instanceof Error ? error.message : String(error), - }); - }, - 1_000, - "ADE_ENABLE_USAGE_TRACKING", - ); + if (shouldUseInProcessProjectRuntime()) { + scheduleBackgroundProjectTask( + "usage.start", + () => usageTrackingService.start(), + (error) => { + logger.warn("usage.start_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }, + 1_000, + "ADE_ENABLE_USAGE_TRACKING", + ); + } const budgetCapService = createBudgetCapService({ db, @@ -5416,7 +5433,140 @@ app.whenReady().then(async () => { }; }; + /** + * One main-process subscription to the brain's shared usage tracker. + * + * Windows with no local project binding — Welcome, Hub/Account, a + * remote-machine tab — accept usage only on the local `IPC.usageEvent` + * channel, and the dormant in-process tracker that used to feed it is stopped + * whenever a project is open. Without this they sat frozen at whatever the + * disk cache held while every bound window moved. Bound windows ignore this + * channel, so broadcasting costs nothing and cannot double-deliver. + * + * The brain exposes usage per project scope, so the bridge borrows any booted + * scope; every scope is fed by the same machine-level poller. + */ + let machineUsageEventRoot: string | null = null; + let machineUsageEventCleanup: (() => void) | null = null; + let machineUsageEventRetryTimer: ReturnType | null = null; + /** + * Assigned by `registerIpc` once the account directory and the paired remote + * connection pool exist. + * + * The brain owns the rollup store but has no client for calling another + * machine's ADE; the desktop app has the transport but no longer owns the + * tracker. Main is the only place that sees both, so it runs the fan-out and + * pushes the result into the brain's shared tracker. + */ + let machineAccountRollupFetcher: AccountRollupFetcher | null = null; + let machineAccountRollupRefreshInFlight: Promise | null = null; + let machineAccountRollupRefreshedAtMs = 0; + /** + * Floor on the desktop fan-out. Usage events fire far more often than the + * tracker's own getAdeUsageStats-driven refresh (30 s), so this is 60 s. + */ + const MACHINE_ACCOUNT_ROLLUP_MIN_INTERVAL_MS = 60_000; + const MACHINE_ACCOUNT_ROLLUP_TIMEOUT_MS = 4_000; + /** Re-arm delay after the brain's event stream ends or refuses to open. */ + const MACHINE_USAGE_EVENT_RETRY_MS = 5_000; + + const pushAccountRollupsToBrain = (rootPath: string): void => { + const fetcher = machineAccountRollupFetcher; + if (!fetcher || machineAccountRollupRefreshInFlight) return; + const startedAtMs = Date.now(); + if (startedAtMs - machineAccountRollupRefreshedAtMs < MACHINE_ACCOUNT_ROLLUP_MIN_INTERVAL_MS) return; + machineAccountRollupRefreshedAtMs = startedAtMs; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), MACHINE_ACCOUNT_ROLLUP_TIMEOUT_MS); + timer.unref?.(); + const task = (async () => { + try { + const result = await fetcher({ + timeoutMs: MACHINE_ACCOUNT_ROLLUP_TIMEOUT_MS, + signal: controller.signal, + }); + if (result.rollups.length === 0 && result.failures.length === 0) return; + await localRuntimePool.callActionForRoot(rootPath, { + domain: "usage", + action: "applyAccountRollups", + args: { rollups: result.rollups, failures: result.failures }, + }); + } catch (error) { + // Best effort by construction: the brain renders account scope from its + // durable rollups whether or not this ever lands. + dormantContext?.logger?.debug("usage.account_rollup_push_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + clearTimeout(timer); + } + })().finally(() => { + if (machineAccountRollupRefreshInFlight === task) { + machineAccountRollupRefreshInFlight = null; + } + }); + machineAccountRollupRefreshInFlight = task; + }; + + const syncMachineUsageEventBridge = (): void => { + if (shouldUseInProcessProjectRuntime()) return; + const desired = bootedUsageScopeRoot([...projectContexts.values()]); + if (desired === machineUsageEventRoot) return; + machineUsageEventCleanup?.(); + machineUsageEventCleanup = null; + machineUsageEventRoot = desired; + if (!desired) return; + const root = desired; + void localRuntimePool + .subscribeEventsForRoot( + root, + { category: "runtime", replay: false }, + (event) => { + if (machineUsageEventRoot !== root) return; + if (event.payload?.type !== "usage") return; + const snapshot = event.payload.snapshot; + if (!isUsageSnapshot(snapshot)) return; + broadcast(IPC.usageEvent, snapshot); + pushAccountRollupsToBrain(root); + }, + () => { + if (machineUsageEventRoot !== root) return; + machineUsageEventRoot = null; + machineUsageEventCleanup = null; + // A recycled brain ends the stream. Nothing else would re-arm the + // bridge until a project happened to open or close, which would leave + // every unbound window silently frozen in the meantime. + scheduleMachineUsageEventRetry(); + }, + ) + .then((cleanup) => { + // The chosen root can close while the subscription is being set up. + if (machineUsageEventRoot !== root) { + cleanup(); + return; + } + machineUsageEventCleanup = cleanup; + }) + .catch((error) => { + if (machineUsageEventRoot === root) machineUsageEventRoot = null; + dormantContext?.logger?.debug("usage.machine_event_bridge_failed", { + error: error instanceof Error ? error.message : String(error), + }); + scheduleMachineUsageEventRetry(); + }); + }; + + function scheduleMachineUsageEventRetry(): void { + if (machineUsageEventRetryTimer || projectContexts.size === 0) return; + machineUsageEventRetryTimer = setTimeout(() => { + machineUsageEventRetryTimer = null; + syncMachineUsageEventBridge(); + }, MACHINE_USAGE_EVENT_RETRY_MS); + machineUsageEventRetryTimer.unref?.(); + } + const syncDormantUsageTrackingState = (): void => { + syncMachineUsageEventBridge(); const usageTrackingService = (dormantContext as AppContext | undefined)?.usageTrackingService; if (!usageTrackingService) return; if (projectContexts.size > 0) { @@ -7888,6 +8038,9 @@ app.whenReady().then(async () => { : localRuntimePool, projectRecoveryConnectionPool: localRuntimePool, injectedProjectRecoveryService: machineRecoveryService, + onAccountRollupFetcherReady: (fetcher) => { + machineAccountRollupFetcher = fetcher; + }, autoDiagnosticsService, createWindow: openAdeWindow, closeWindow: closeAdeWindow, diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 05e8cca08..b5be21641 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -222,7 +222,10 @@ export const ADE_ACTION_CTO_ONLY: Partial AppContext; getResourceUsageContexts?: () => AppContext[]; @@ -1695,6 +1697,15 @@ export function registerIpc({ * *attempted* root has to count as known. */ attemptedProjectRoots?: AttemptedProjectRoots; + /** + * Hands the account rollup fan-out to main once the account directory and the + * remote connection pool exist. + * + * The peer transport lives here; the usage tracker that stores what it returns + * lives in the brain. Main owns the one place that sees both, so it drives the + * push (see `syncMachineUsageEventBridge`). + */ + onAccountRollupFetcherReady?: (fetcher: AccountRollupFetcher) => void; closeCurrentProject: () => Promise; closeProjectByPath: (projectRoot: string) => Promise; globalStatePath: string; @@ -6333,6 +6344,64 @@ export function registerIpc({ */ let accountRollupFetcher: AccountRollupFetcher | null = null; + /** + * A project scope the brain has already booted, for machine-level usage reads. + * + * The brain polls provider quota once per machine (see + * `attachSharedUsageTrackingScope`) and exposes it through the per-project + * `usage` action domain. A window with no local project binding — Welcome, + * Hub/Account, a remote-machine tab — has no runtime route of its own, so it + * borrows any booted scope for *machine* facts (quota, machine/account stats). + * + * Project-scoped stats are not a machine fact: borrowing would show another + * window's repository as "This project". Those reads stay on the dormant + * tracker, which has no project root and therefore an empty project slice. + * + * Null when no project is open. The in-process dormant tracker is the + * fallback producer for exactly that case, which is also when it is running. + */ + const callBootedUsageAction = async ( + action: "getAdeUsageStats" | "getUsageSnapshot" | "forceRefresh" | "refreshHistory" | "noteQuotaDemand", + args: Record = {}, + ): Promise<{ handled: true; result: unknown } | { handled: false }> => { + if (!localRuntimeConnectionPool) return { handled: false }; + const rootPath = bootedUsageScopeRoot(getResourceUsageContexts?.() ?? []); + if (!rootPath) return { handled: false }; + try { + const response = await localRuntimeConnectionPool.callActionForRoot(rootPath, { + domain: "usage", + action, + args, + }); + return { handled: true, result: response.result }; + } catch (error) { + // The brain is the source of truth while it is reachable; when it is not, + // an unbound window falling back to its own cached tracker is strictly + // better than an error toast on the Welcome screen. + getCtx().logger.debug("usage.machine_action_failed", { + action, + error: getErrorMessage(error), + }); + return { handled: false }; + } + }; + + const callMachineUsageSnapshot = async ( + action: "getUsageSnapshot" | "forceRefresh" | "refreshHistory" | "noteQuotaDemand", + ): Promise<{ handled: true; result: UsageSnapshot } | { handled: false }> => { + const machine = await callBootedUsageAction(action); + if (!machine.handled || !isUsageSnapshot(machine.result)) return { handled: false }; + return { handled: true, result: machine.result }; + }; + + const callMachineUsageStats = async ( + args: Record, + ): Promise<{ handled: true; result: AdeUsageStats } | { handled: false }> => { + const machine = await callBootedUsageAction("getAdeUsageStats", args); + if (!machine.handled || !isRecord(machine.result)) return { handled: false }; + return { handled: true, result: machine.result as AdeUsageStats }; + }; + ipcMain.handle(IPC.usageGetAdeStats, async (_event, arg: GetAdeUsageStatsArgs | undefined): Promise => { const ctx = getCtx(); if (arg != null && !isRecord(arg)) throw new Error("usage stats expects an object payload."); @@ -6354,28 +6423,36 @@ export function registerIpc({ // on each read keeps the current instance wired without a lifecycle hook. // Idempotent, and the service renders from its durable rollups regardless, // so a fetcher that never gets installed costs freshness and nothing else. + if (arg?.scope !== "project") { + const machine = await callMachineUsageStats(arg ?? {}); + if (machine.handled) return machine.result; + } if (accountRollupFetcher) ctx.usageTrackingService?.setAccountRollupFetcher(accountRollupFetcher); return ctx.usageTrackingService?.getAdeUsageStats(arg ?? {}) ?? null; }); ipcMain.handle(IPC.usageGetSnapshot, async (): Promise => { - const ctx = getCtx(); - return ctx.usageTrackingService?.getUsageSnapshot() ?? null; + const machine = await callMachineUsageSnapshot("getUsageSnapshot"); + if (machine.handled) return machine.result; + return getCtx().usageTrackingService?.getUsageSnapshot() ?? null; }); ipcMain.handle(IPC.usageRefresh, async (): Promise => { - const ctx = getCtx(); - return (await ctx.usageTrackingService?.forceRefresh()) ?? null; + const machine = await callMachineUsageSnapshot("forceRefresh"); + if (machine.handled) return machine.result; + return (await getCtx().usageTrackingService?.forceRefresh()) ?? null; }); ipcMain.handle(IPC.usageRefreshHistory, async (): Promise => { - const ctx = getCtx(); - return (await ctx.usageTrackingService?.refreshHistory()) ?? null; + const machine = await callMachineUsageSnapshot("refreshHistory"); + if (machine.handled) return machine.result; + return (await getCtx().usageTrackingService?.refreshHistory()) ?? null; }); ipcMain.handle(IPC.usageNoteDemand, async (): Promise => { - const ctx = getCtx(); - return ctx.usageTrackingService?.noteQuotaDemand() ?? null; + const machine = await callMachineUsageSnapshot("noteQuotaDemand"); + if (machine.handled) return machine.result; + return getCtx().usageTrackingService?.noteQuotaDemand() ?? null; }); ipcMain.handle( @@ -10073,6 +10150,7 @@ export function registerIpc({ warn: (message, meta) => getCtx().logger.warn(message, meta), }, }); + onAccountRollupFetcherReady?.(accountRollupFetcher); accountBridge.onPairMachineProgress((progress) => { for (const win of BrowserWindow.getAllWindows()) { diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index ac05dddc2..8a11b5196 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -1066,7 +1066,9 @@ describe("registerRuntimeBridge", () => { expect(subscriptions).toHaveLength(2); // The active pump keeps polling; the pinned PTY pump goes away silently. - for (let tick = 0; tick < 12; tick += 1) { + // Past the 180s idle bound, which is set by a background window's + // once-a-minute throttled pump rather than by a foreground one. + for (let tick = 0; tick < 20; tick += 1) { await vi.advanceTimersByTimeAsync(10_000); await poll(activePumpRequest); } @@ -1157,7 +1159,7 @@ describe("registerRuntimeBridge", () => { // Only the pinned PTY pump's subscription went; the active pump keeps its // own, and the released one stops reaching the renderer immediately — - // without waiting out the 60s idle expiry. + // without waiting out the idle expiry. expect(subscriptions[1].cleanup).toHaveBeenCalledTimes(1); expect(subscriptions[0].cleanup).not.toHaveBeenCalled(); subscriptions[1].emit(ptyEvent(8), "epoch-1"); @@ -2149,6 +2151,182 @@ describe("registerRuntimeBridge", () => { }); }); +/** + * Provider quota is polled once per machine, by the brain. A window with no + * local project binding — Welcome, Hub/Account, a remote-machine tab — has no + * runtime action route of its own, so its usage reads borrow a booted project + * scope instead of falling back to a private in-process tracker that is not + * even running. Reading two different trackers is what made two windows on one + * machine show two different meters. + */ +describe("registerIpc usage bridge", () => { + beforeEach(() => { + ipcHandlers.clear(); + browserWindowFromWebContents.mockReset(); + }); + + const brainSnapshot = { + windows: [], + pacing: { status: "on-track" }, + costs: [], + adeCosts: [], + extraUsage: [], + lastPolledAt: "2026-09-04T00:00:00.000Z", + errors: [], + revision: { producerId: "brain-1", seq: 12 }, + } as any; + + function registerUsageIpc({ + openRoots, + dormantSnapshot, + callActionForRoot, + }: { + openRoots: string[]; + dormantSnapshot?: unknown; + callActionForRoot?: ReturnType; + }) { + const logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const dormantTracker = { + getUsageSnapshot: vi.fn(() => dormantSnapshot), + getAdeUsageStats: vi.fn(async () => ({ generatedAt: "dormant", scope: "project" })), + setAccountRollupFetcher: vi.fn(), + }; + const pool = { + callActionForRoot: + callActionForRoot + ?? vi.fn(async (_root: string, request: { domain: string; action: string }) => ({ + domain: request.domain, + action: request.action, + result: brainSnapshot, + statusHints: {}, + })), + }; + registerIpc({ + getCtx: () => ({ logger, usageTrackingService: dormantTracker }) as any, + // Mirrors main.ts: open project contexts carry a database, the dormant + // one does not. + getResourceUsageContexts: () => + [ + ...openRoots.map((rootPath) => ({ + db: {}, + project: { rootPath, displayName: "Repo", baseRef: "main" }, + })), + { db: null, project: { rootPath: "", displayName: "", baseRef: "main" } }, + ] as any, + getSyncService: () => null, + localRuntimeConnectionPool: pool as any, + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + }); + return { pool, dormantTracker }; + } + + it("reads the brain's shared tracker for a window with no project binding", async () => { + const { pool, dormantTracker } = registerUsageIpc({ + openRoots: ["/repo-one", "/repo-two"], + dormantSnapshot: { lastPolledAt: "stale", revision: { producerId: "dormant", seq: 1 } }, + }); + + await expect( + ipcHandlers.get(IPC.usageGetSnapshot)?.(eventForSender()), + ).resolves.toBe(brainSnapshot); + expect(pool.callActionForRoot).toHaveBeenCalledWith("/repo-one", { + domain: "usage", + action: "getUsageSnapshot", + args: {}, + }); + expect(dormantTracker.getUsageSnapshot).not.toHaveBeenCalled(); + }); + + it("hands a bound-window read and an unbound-window read the same revision", async () => { + const callActionForRoot = vi.fn(async (_root: string, request: { domain: string; action: string }) => ({ + domain: request.domain, + action: request.action, + result: brainSnapshot, + statusHints: {}, + })); + registerUsageIpc({ openRoots: ["/repo"], callActionForRoot }); + // The bound window's route: the renderer calls the project runtime action + // directly rather than the local IPC fallback. + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + localRuntimeConnectionPool: { callActionForRoot } as any, + getWindowSession: () => ({ windowId: 7, project: null, binding: localBinding("/repo") }), + }); + + const unbound = (await ipcHandlers.get(IPC.usageGetSnapshot)?.(eventForSender())) as any; + const bound = (await ipcHandlers.get(IPC.localRuntimeCallAction)?.( + eventForSender(sender(101)), + { request: { domain: "usage", action: "getUsageSnapshot", args: {} } }, + )) as any; + + expect(unbound.revision).toEqual(bound.result.revision); + expect(unbound.lastPolledAt).toBe(bound.result.lastPolledAt); + }); + + it("falls back to the in-process tracker when the brain has no booted scope", async () => { + const dormantSnapshot = { lastPolledAt: "cached", revision: { producerId: "dormant", seq: 3 } }; + const { pool, dormantTracker } = registerUsageIpc({ openRoots: [], dormantSnapshot }); + + await expect( + ipcHandlers.get(IPC.usageGetSnapshot)?.(eventForSender()), + ).resolves.toBe(dormantSnapshot); + expect(pool.callActionForRoot).not.toHaveBeenCalled(); + expect(dormantTracker.getUsageSnapshot).toHaveBeenCalledTimes(1); + }); + + it("falls back to the in-process tracker when the brain rejects the read", async () => { + const dormantSnapshot = { lastPolledAt: "cached", revision: { producerId: "dormant", seq: 4 } }; + const { dormantTracker } = registerUsageIpc({ + openRoots: ["/repo"], + dormantSnapshot, + callActionForRoot: vi.fn(async () => { + throw new Error("connection closed"); + }), + }); + + await expect( + ipcHandlers.get(IPC.usageGetSnapshot)?.(eventForSender()), + ).resolves.toBe(dormantSnapshot); + expect(dormantTracker.getUsageSnapshot).toHaveBeenCalledTimes(1); + }); + + it("does not borrow another project's stats for an unbound project-scoped read", async () => { + const { pool, dormantTracker } = registerUsageIpc({ openRoots: ["/repo-one"] }); + + await expect( + ipcHandlers.get(IPC.usageGetAdeStats)?.(eventForSender(), { preset: "all", scope: "project" }), + ).resolves.toEqual({ generatedAt: "dormant", scope: "project" }); + expect(pool.callActionForRoot).not.toHaveBeenCalled(); + expect(dormantTracker.getAdeUsageStats).toHaveBeenCalledWith({ preset: "all", scope: "project" }); + }); + + it("reads machine-scoped stats from the brain for an unbound window", async () => { + const { pool, dormantTracker } = registerUsageIpc({ + openRoots: ["/repo-one"], + callActionForRoot: vi.fn(async (_root: string, request: { domain: string; action: string }) => ({ + domain: request.domain, + action: request.action, + result: { generatedAt: "brain", scope: "machine" }, + statusHints: {}, + })), + }); + + await expect( + ipcHandlers.get(IPC.usageGetAdeStats)?.(eventForSender(), { preset: "all", scope: "machine" }), + ).resolves.toEqual({ generatedAt: "brain", scope: "machine" }); + expect(pool.callActionForRoot).toHaveBeenCalledWith("/repo-one", { + domain: "usage", + action: "getAdeUsageStats", + args: { preset: "all", scope: "machine" }, + }); + expect(dormantTracker.getAdeUsageStats).not.toHaveBeenCalled(); + }); +}); + describe("registerIpc sync bridge", () => { beforeEach(() => { ipcHandlers.clear(); diff --git a/apps/desktop/src/main/services/ipc/runtimeEventSubscriptionRegistry.test.ts b/apps/desktop/src/main/services/ipc/runtimeEventSubscriptionRegistry.test.ts new file mode 100644 index 000000000..8aa749704 --- /dev/null +++ b/apps/desktop/src/main/services/ipc/runtimeEventSubscriptionRegistry.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { WebContents } from "electron"; + +import { createRuntimeEventSubscriptionRegistry } from "./runtimeEventSubscriptionRegistry"; + +function fakeSender(id: number): WebContents { + return { + id, + isDestroyed: () => false, + once: () => {}, + } as unknown as WebContents; +} + +describe("createRuntimeEventSubscriptionRegistry", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps a subscription whose pump only wakes once a minute", async () => { + const registry = createRuntimeEventSubscriptionRegistry(); + const sender = fakeSender(1); + const cleanup = vi.fn(); + const subscription = registry.addRuntimeEventSubscription({ + sender, + bindingKey: "local:/repo", + requestKey: "local:/repo:*:replay", + cleanup: null, + }); + registry.attachRuntimeEventSubscriptionCleanup( + sender.id, + subscription.requestKey, + subscription, + cleanup, + ); + + // A background window's setTimeout pump is throttled to roughly one wake a + // minute. Ten of those must not lose it its event feed. + for (let wake = 0; wake < 10; wake += 1) { + await vi.advanceTimersByTimeAsync(60_000); + expect( + registry.refreshRuntimeEventSubscription(sender.id, subscription.requestKey), + ).toBe(subscription); + } + + expect(cleanup).not.toHaveBeenCalled(); + expect( + registry.getRuntimeEventSubscription(sender.id, subscription.requestKey), + ).toBe(subscription); + }); + + it("sweeps a subscription whose pump stopped refreshing", async () => { + const registry = createRuntimeEventSubscriptionRegistry(); + const sender = fakeSender(2); + const cleanup = vi.fn(); + const subscription = registry.addRuntimeEventSubscription({ + sender, + bindingKey: "local:/repo", + requestKey: "local:/repo:*:replay", + cleanup: null, + }); + registry.attachRuntimeEventSubscriptionCleanup( + sender.id, + subscription.requestKey, + subscription, + cleanup, + ); + + await vi.advanceTimersByTimeAsync(181_000); + + expect(cleanup).toHaveBeenCalledTimes(1); + expect( + registry.getRuntimeEventSubscription(sender.id, subscription.requestKey), + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/services/ipc/runtimeEventSubscriptionRegistry.ts b/apps/desktop/src/main/services/ipc/runtimeEventSubscriptionRegistry.ts index b9e0ac8bd..8f61c1b80 100644 --- a/apps/desktop/src/main/services/ipc/runtimeEventSubscriptionRegistry.ts +++ b/apps/desktop/src/main/services/ipc/runtimeEventSubscriptionRegistry.ts @@ -24,8 +24,15 @@ type RuntimeEventWindowSubscriptionInput = Omit< // remove that, so a stale (sender, requestKey) is reclaimed by idle expiry. // Every live pump refreshes its subscription on each poll (750ms..5s normally, // 30s at the slowest failure backoff). -const RUNTIME_EVENT_SUBSCRIPTION_IDLE_MS = 60_000; -const RUNTIME_EVENT_SUBSCRIPTION_SWEEP_MS = 20_000; +// +// The bound is set by the slowest *real* refresh, not by the fastest. A +// background window's `setTimeout` pump is throttled by Chromium to roughly one +// wake per minute, so a 60s expiry raced its own renewal: the window lost its +// event feed — the usage meter among them — and only got it back when it came +// to the foreground. Three minutes clears a once-a-minute pump with room for a +// missed wake, and still reclaims a dead (sender, requestKey) promptly. +const RUNTIME_EVENT_SUBSCRIPTION_IDLE_MS = 180_000; +const RUNTIME_EVENT_SUBSCRIPTION_SWEEP_MS = 60_000; export function createRuntimeEventSubscriptionRegistry() { const subscriptions = new Map< diff --git a/apps/desktop/src/main/services/usage/bootedUsageScope.test.ts b/apps/desktop/src/main/services/usage/bootedUsageScope.test.ts new file mode 100644 index 000000000..ec94813e6 --- /dev/null +++ b/apps/desktop/src/main/services/usage/bootedUsageScope.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { bootedUsageScopeRoot } from "./bootedUsageScope"; + +describe("bootedUsageScopeRoot", () => { + it("skips the dormant context and returns the first project that has a db", () => { + expect(bootedUsageScopeRoot([ + { db: null, project: { rootPath: "" } }, + { db: {}, project: { rootPath: " /repo-one " } }, + { db: {}, project: { rootPath: "/repo-two" } }, + ])).toBe("/repo-one"); + }); + + it("returns null when no project scope is booted", () => { + expect(bootedUsageScopeRoot([ + { db: null, project: { rootPath: "" } }, + ])).toBeNull(); + expect(bootedUsageScopeRoot([])).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/services/usage/bootedUsageScope.ts b/apps/desktop/src/main/services/usage/bootedUsageScope.ts new file mode 100644 index 000000000..05c4abb0c --- /dev/null +++ b/apps/desktop/src/main/services/usage/bootedUsageScope.ts @@ -0,0 +1,24 @@ +/** + * A project scope the brain has already booted, for machine-level usage reads. + * + * The brain polls provider quota once per machine and exposes it through the + * per-project `usage` action domain. Desktop main and the unbound IPC fallback + * both need "any booted scope" and must pick by the same rule, or a window + * could subscribe to one root and read from another. + */ +export type BootedUsageScopeContext = { + db?: unknown; + project?: { rootPath?: string | null } | null; +}; + +export function bootedUsageScopeRoot( + contexts: ReadonlyArray, +): string | null { + for (const ctx of contexts) { + // `db` is the local-runtime discriminator: the dormant context has none. + if (!ctx.db) continue; + const root = ctx.project?.rootPath?.trim(); + if (root) return root; + } + return null; +} diff --git a/apps/desktop/src/main/services/usage/githubActivityStats.ts b/apps/desktop/src/main/services/usage/githubActivityStats.ts index ed6145091..96500c658 100644 --- a/apps/desktop/src/main/services/usage/githubActivityStats.ts +++ b/apps/desktop/src/main/services/usage/githubActivityStats.ts @@ -128,6 +128,10 @@ export function runBufferedCommand( stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); + if (!child) { + reject(new Error(`${command} failed to start`)); + return; + } let timeout: ReturnType | null = null; const finish = (fn: () => void) => { if (settled) return; @@ -136,7 +140,7 @@ export function runBufferedCommand( fn(); }; timeout = setTimeout(() => { - child.kill("SIGTERM"); + child?.kill("SIGTERM"); finish(() => reject(new Error(`${command} timed out`))); }, options.timeoutMs ?? GITHUB_STATS_COMMAND_TIMEOUT_MS); timeout.unref?.(); @@ -144,7 +148,7 @@ export function runBufferedCommand( child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); if (Buffer.byteLength(stdout, "utf8") > maxOutputBytes) { - child.kill("SIGTERM"); + child?.kill("SIGTERM"); finish(() => reject(new Error(`${command} produced too much output`))); } }); diff --git a/apps/desktop/src/main/services/usage/sharedUsageTracking.ts b/apps/desktop/src/main/services/usage/sharedUsageTracking.ts new file mode 100644 index 000000000..3b8bfbb34 --- /dev/null +++ b/apps/desktop/src/main/services/usage/sharedUsageTracking.ts @@ -0,0 +1,62 @@ +import type { + UsageTrackingProjectScope, + UsageTrackingProjectScopeInput, + UsageTrackingService, +} from "./usageTrackingService"; + +/** + * Provider quota is a machine fact, not a project fact. + * + * One process used to build one tracker per open project scope: two projects + * meant two 120 s poll timers on different phases, two demand leases and two + * `lastSnapshot`s, so two windows on one computer showed two different meters + * and one of them was always behind. The shared instance is created once per + * ADE home and every project scope attaches to it. + * + * Mirrors `getSharedProductAnalyticsService`, with a scope count instead of a + * bare get: the poller belongs to the process, so it must outlive any single + * project and shut down when the last one detaches. + */ +const sharedUsageTrackingServices = new Map< + string, + { service: UsageTrackingService; scopeCount: number } +>(); + +export function attachSharedUsageTrackingScope( + key: string, + make: () => UsageTrackingService, + scope: UsageTrackingProjectScopeInput, +): UsageTrackingProjectScope { + let entry = sharedUsageTrackingServices.get(key); + if (!entry) { + entry = { service: make(), scopeCount: 0 }; + sharedUsageTrackingServices.set(key, entry); + } + const shared = entry; + shared.scopeCount += 1; + const attached = shared.service.attachProjectScope(scope); + let released = false; + return { + ...attached, + dispose: (): void => { + if (released) return; + released = true; + attached.dispose(); + shared.scopeCount -= 1; + if (shared.scopeCount > 0) return; + if (sharedUsageTrackingServices.get(key) === shared) { + sharedUsageTrackingServices.delete(key); + } + shared.service.dispose(); + }, + }; +} + +export function peekSharedUsageTrackingService(key: string): UsageTrackingService | undefined { + return sharedUsageTrackingServices.get(key)?.service; +} + +export function clearSharedUsageTrackingServicesForTesting(): void { + for (const entry of sharedUsageTrackingServices.values()) entry.service.dispose(); + sharedUsageTrackingServices.clear(); +} diff --git a/apps/desktop/src/main/services/usage/usageLedgerWorker.ts b/apps/desktop/src/main/services/usage/usageLedgerWorker.ts index fd15d091a..afced9c49 100644 --- a/apps/desktop/src/main/services/usage/usageLedgerWorker.ts +++ b/apps/desktop/src/main/services/usage/usageLedgerWorker.ts @@ -45,7 +45,7 @@ export const providerScanners: ProviderScanner[] = [ { provider: "gemini", scan: scanGeminiLogs }, ]; -async function readInput(): Promise<{ projectRoot: string | null }> { +async function readInput(): Promise<{ projectRoot: string | null; projectRoots: string[] }> { let raw = ""; for await (const chunk of process.stdin) { raw += chunk.toString(); @@ -57,7 +57,11 @@ async function readInput(): Promise<{ projectRoot: string | null }> { if (!isRecord(parsed) || (parsed.projectRoot !== null && typeof parsed.projectRoot !== "string")) { throw new Error("Usage ledger worker input is invalid"); } - return { projectRoot: parsed.projectRoot }; + const projectRoots = Array.isArray(parsed.projectRoots) + ? [...new Set(parsed.projectRoots.filter((root): root is string => + typeof root === "string" && root.trim().length > 0))] + : parsed.projectRoot ? [parsed.projectRoot] : []; + return { projectRoot: parsed.projectRoot, projectRoots }; } function emit(line: unknown): void { @@ -65,7 +69,7 @@ function emit(line: unknown): void { } async function main(): Promise { - const { projectRoot } = await readInput(); + const { projectRoot, projectRoots } = await readInput(); await refreshDynamicTokenPricing().catch(() => 0); // The roster first, then one line per provider as it finishes. Buffering all // nine and writing a single object at the end meant a timeout — or any @@ -97,6 +101,7 @@ async function main(): Promise { provider: scanner.provider, costs: [], projectCosts: [], + projectCostsByRoot: {}, entryCount: 0, error: getErrorMessage(error), } satisfies UsageLedgerProviderChunk); @@ -115,6 +120,12 @@ async function main(): Promise { provider: scanner.provider, costs: buildCostSnapshots(providerEntries, "machine", projectRoot), projectCosts: buildCostSnapshots(providerEntries, "project", projectRoot), + // The ledgers are walked once for the whole brain; the per-root + // projection is a filter over entries already in hand. + projectCostsByRoot: Object.fromEntries(projectRoots.map((root) => [ + root, + buildCostSnapshots(providerEntries, "project", root), + ])), ...(daily7d ? { daily7d } : {}), entryCount: entries.length, ...(incomplete ? { incomplete: true } : {}), diff --git a/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.test.ts b/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.test.ts index f77704dc0..5ec57a9c9 100644 --- a/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.test.ts +++ b/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.test.ts @@ -89,7 +89,7 @@ describe("usage ledger worker client", () => { child.emit("close", 0, null); await expect(promise).resolves.toMatchObject({ entryCounts: { codex: 1 } }); - expect(JSON.parse(input)).toEqual({ projectRoot: "/repo" }); + expect(JSON.parse(input)).toEqual({ projectRoot: "/repo", projectRoots: ["/repo"] }); expect(spawnWorker).toHaveBeenCalledWith( process.execPath, [__filename], diff --git a/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.ts b/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.ts index bcc34e794..a898dc66f 100644 --- a/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.ts +++ b/apps/desktop/src/main/services/usage/usageLedgerWorkerClient.ts @@ -51,6 +51,13 @@ export type UsageLedgerProviderChunk = { provider: string; costs: CostSnapshot[]; projectCosts: CostSnapshot[]; + /** + * Project-scoped snapshots keyed by project root, for every root the caller + * asked about. One brain hosts several project scopes and polls the ledgers + * once for all of them, so the single `projectCosts` above (the primary + * root) is not enough to answer a second scope's project-scoped question. + */ + projectCostsByRoot?: Record; /** Seven daily buckets. Only Claude and Codex report these. */ daily7d?: number[]; entryCount: number; @@ -63,6 +70,8 @@ export type UsageLedgerProviderChunk = { export type UsageLedgerScanResult = { costs: CostSnapshot[]; projectCosts: CostSnapshot[]; + /** Per-project-root snapshots for every root the caller asked about. */ + projectCostsByRoot: Record; daily7d: Partial>; entryCounts: Record; providerErrors: Record; @@ -87,6 +96,13 @@ const MAX_PROVIDER_NAME_LENGTH = 128; type WorkerOptions = { signal?: AbortSignal; + /** + * Extra project roots to attribute this scan to, beyond `projectRoot`. The + * ledgers are walked once and projected per root, which is what lets one + * brain answer project-scoped usage for every project it hosts without + * re-reading gigabytes of transcripts per scope. + */ + additionalProjectRoots?: readonly (string | null | undefined)[]; workerPath?: string; spawnWorker?: typeof spawn; /** Test seam for the Node SEA runtime, whose executable embeds the worker. */ @@ -128,6 +144,17 @@ function isUsageProvider(value: string): value is UsageProvider { return value === "claude" || value === "codex" || value === "cursor"; } +function parseProjectCostsByRoot(value: unknown): Record { + if (!isRecord(value)) return {}; + const parsed: Record = {}; + for (const [root, snapshots] of Object.entries(value)) { + if (!root) continue; + if (!Array.isArray(snapshots) || !snapshots.every(isCostSnapshot)) continue; + parsed[root] = snapshots; + } + return parsed; +} + function invalidWorkerResult(): never { throw new Error("Usage ledger worker returned an invalid result"); } @@ -188,6 +215,7 @@ export function parseUsageLedgerWorkerResult(raw: string): UsageLedgerScanResult return { costs: parsed.costs, projectCosts: parsed.projectCosts, + projectCostsByRoot: parseProjectCostsByRoot(parsed.projectCostsByRoot), daily7d, entryCounts, providerErrors, @@ -238,6 +266,7 @@ function createLedgerStreamReader() { provider: parsed.provider, costs: parsed.costs, projectCosts: parsed.projectCosts, + projectCostsByRoot: parseProjectCostsByRoot(parsed.projectCostsByRoot), ...(daily7d ? { daily7d } : {}), entryCount: typeof parsed.entryCount === "number" && Number.isFinite(parsed.entryCount) ? Math.max(0, parsed.entryCount) @@ -270,6 +299,7 @@ function createLedgerStreamReader() { const result: UsageLedgerScanResult = { costs: [], projectCosts: [], + projectCostsByRoot: {}, daily7d: {}, entryCounts: {}, providerErrors: {}, @@ -278,6 +308,9 @@ function createLedgerStreamReader() { for (const chunk of chunks.values()) { result.costs.push(...chunk.costs); result.projectCosts.push(...chunk.projectCosts); + for (const [root, snapshots] of Object.entries(chunk.projectCostsByRoot ?? {})) { + (result.projectCostsByRoot[root] ??= []).push(...snapshots); + } result.entryCounts[chunk.provider] = chunk.entryCount; if (chunk.daily7d && isUsageProvider(chunk.provider)) { result.daily7d[chunk.provider] = chunk.daily7d; @@ -304,6 +337,10 @@ export function scanUsageLedgersInWorker( projectRoot: string | null | undefined, options: WorkerOptions = {}, ): Promise { + const projectRoots = [...new Set( + [projectRoot, ...(options.additionalProjectRoots ?? [])] + .filter((root): root is string => typeof root === "string" && root.trim().length > 0), + )]; const embeddedRuntime = options.embeddedRuntime ?? isEmbeddedAdeRuntime(); const workerPath = options.workerPath ?? (embeddedRuntime ? null : resolveUsageLedgerWorkerPath()); if (workerPath && !fs.existsSync(workerPath)) { @@ -415,7 +452,7 @@ export function scanUsageLedgersInWorker( child.stdin.on("error", (error: NodeJS.ErrnoException) => { if (error.code !== "EPIPE" && error.code !== "ERR_STREAM_DESTROYED") fail(error); }); - child.stdin.end(JSON.stringify({ projectRoot: projectRoot ?? null })); + child.stdin.end(JSON.stringify({ projectRoot: projectRoot ?? null, projectRoots })); }); } diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index aaa09c08d..2d3101371 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -37,7 +37,14 @@ vi.mock("../ai/codexExecutable", () => ({ resolveCodexExecutable: (...args: unknown[]) => mockState.resolveCodexExecutable(...args), })); -import { createUsageTrackingService, _testing } from "./usageTrackingService"; +import { + attachSharedUsageTrackingScope, + createUsageTrackingService, + isAccountRollupFetchResult, + _testing, +} from "./usageTrackingService"; +import type { AdeUsageRollup, UsageSnapshot } from "../../../shared/types/usage"; +import type { UsageLedgerScanResult } from "./usageLedgerWorkerClient"; import { tokenPriceSource, _testing as _pricingTesting } from "./usagePricing"; import { encodeActiveDayBits } from "../lanes/laneUsageTombstone"; // Cross-layer on purpose: the daily split is only useful if the renderer's @@ -59,6 +66,7 @@ import { import { providerScanners } from "./usageLedgerWorker"; import type { TokenEntry } from "./ledgers/localUsageLedgers"; import type { CostSnapshot } from "../../../shared/types"; +import { CURSOR_BILLED_USAGE_KV_REF } from "./cursorBilledUsageStore"; const { aggregateCosts, @@ -1960,6 +1968,326 @@ describe("createUsageTrackingService", () => { service.dispose(); }); + it("stamps every snapshot with a strictly increasing revision from one producer", async () => { + const logger = createLogger(); + const onUpdate = vi.fn(); + const service = createUsageTrackingService({ + logger, + onUpdate, + dependencies: createFastDependencies(), + }); + + const initial = service.getUsageSnapshot(); + expect(initial.revision?.producerId).toEqual(expect.any(String)); + expect(initial.revision?.seq).toBe(1); + + const first = await service.poll(); + const second = await service.poll(); + expect(first.revision?.producerId).toBe(initial.revision?.producerId); + expect(second.revision?.producerId).toBe(initial.revision?.producerId); + expect(first.revision!.seq).toBeGreaterThan(initial.revision!.seq); + expect(second.revision!.seq).toBeGreaterThan(first.revision!.seq); + + // Returned is a subset of emitted: the caller holds an object every other + // consumer of this instance was handed too. + expect(onUpdate.mock.calls.map(([snapshot]) => snapshot.revision.seq)).toEqual([ + first.revision!.seq, + second.revision!.seq, + ]); + expect(service.getUsageSnapshot().revision).toEqual(second.revision); + + // A second instance is a different producer, so its sequence is never + // comparable with this one's. + const other = createUsageTrackingService({ logger, dependencies: createFastDependencies() }); + expect(other.getUsageSnapshot().revision?.producerId) + .not.toBe(initial.revision?.producerId); + + other.dispose(); + service.dispose(); + }); + + it("publishes the snapshot a failed poll returns instead of handing it to one caller", async () => { + const logger = createLogger(); + const onUpdate = vi.fn(); + const service = createUsageTrackingService({ + logger, + onUpdate, + dependencies: { + ...createFastDependencies(), + // A provider adapter that breaks the result contract is what reaches + // poll()'s unexpected-error path; a poll that merely rejects is caught + // per provider. + pollClaudeUsage: vi.fn(async () => ({ + windows: [] as never[], + errors: null as unknown as string[], + })), + }, + }); + + const returned = await service.forceRefresh(); + expect(returned.errors.some((error) => error.startsWith("unexpected:"))).toBe(true); + + const emitted = onUpdate.mock.calls.at(-1)?.[0]; + expect(emitted).toBeDefined(); + expect(returned.revision).toEqual(emitted.revision); + expect(returned.lastPolledAt).toBe(emitted.lastPolledAt); + // The other read paths hand back exactly what was published, never a + // privately fresher copy. + expect(service.getUsageSnapshot().revision).toEqual(returned.revision); + expect(service.noteQuotaDemand().revision).toEqual(returned.revision); + + service.dispose(); + }); + + it("shares one machine tracker across every attached project scope", async () => { + const logger = createLogger(); + const dependencies = createFastDependencies(); + const key = `shared-usage-${Math.random()}`; + const firstUpdates: UsageSnapshot[] = []; + const secondUpdates: UsageSnapshot[] = []; + const make = vi.fn(() => createUsageTrackingService({ logger, dependencies })); + + const first = attachSharedUsageTrackingScope(key, make, { + key: "project-a", + projectRoot: "/repo-a", + onUpdate: (snapshot) => firstUpdates.push(snapshot), + }); + const second = attachSharedUsageTrackingScope(key, make, { + key: "project-b", + projectRoot: "/repo-b", + onUpdate: (snapshot) => secondUpdates.push(snapshot), + }); + + expect(make).toHaveBeenCalledTimes(1); + + await first.poll(); + expect(firstUpdates).toHaveLength(1); + expect(secondUpdates).toHaveLength(1); + expect(secondUpdates[0]).toBe(firstUpdates[0]); + expect(first.getUsageSnapshot().revision).toEqual(second.getUsageSnapshot().revision); + expect(first.getUsageSnapshot().lastPolledAt).toBe(second.getUsageSnapshot().lastPolledAt); + + // Closing one project detaches that scope; the machine's poller and the + // other project's feed are untouched. + first.dispose(); + await second.poll(); + expect(firstUpdates).toHaveLength(1); + expect(secondUpdates).toHaveLength(2); + expect(secondUpdates[1]!.revision!.seq) + .toBeGreaterThan(secondUpdates[0]!.revision!.seq); + expect(secondUpdates[1]!.revision!.producerId) + .toBe(secondUpdates[0]!.revision!.producerId); + + second.dispose(); + }); + + it("reads Cursor billed rows from attached project databases, not the constructor db", async () => { + const logger = createLogger(); + const now = Date.now(); + const billed = [{ + messageId: "billed-attached", + model: "cursor-auto", + inputTokens: 40, + outputTokens: 10, + timestamp: now, + }]; + const attachedDb = { + getJson: vi.fn((key: string) => (key === CURSOR_BILLED_USAGE_KV_REF ? billed : null)), + }; + const service = createUsageTrackingService({ + logger, + dependencies: createFastDependencies(), + }); + const scope = service.attachProjectScope({ + key: "project-a", + projectRoot: "/repo-a", + db: attachedDb as unknown as AdeDb, + logger, + }); + + await service.refreshHistory(); + const machine = await service.getAdeUsageStats({ preset: "all", scope: "machine" }); + expect(machine.providers.find((provider) => provider.provider === "cursor")).toMatchObject({ + totalTokens: 50, + }); + expect(attachedDb.getJson).toHaveBeenCalledWith(CURSOR_BILLED_USAGE_KV_REF); + + scope.dispose(); + service.dispose(); + }); + + it("rejects account rollups that omit source or carry untyped rows", () => { + const logger = createLogger(); + const service = createUsageTrackingService({ + logger, + dependencies: createFastDependencies(), + }); + + expect(isAccountRollupFetchResult({ + rollups: [{ + machineKey: "other", + capturedAt: "2026-09-04T00:00:00.000Z", + rows: [null], + }], + failures: [], + })).toBe(false); + expect(isAccountRollupFetchResult({ + rollups: [{ + machineKey: "other", + capturedAt: "2026-09-04T00:00:00.000Z", + rows: [], + }], + failures: [], + })).toBe(false); + + expect(() => service.applyAccountRollups({ + rollups: [{ + machineKey: "other", + capturedAt: "2026-09-04T00:00:00.000Z", + rows: [null], + }], + failures: [], + } as never)).not.toThrow(); + expect(logger.warn).toHaveBeenCalledWith( + "usage.account.apply_rollups_partially_rejected", + expect.objectContaining({ droppedRollups: 1, droppedFailures: 0 }), + ); + + service.dispose(); + }); + + it("keeps typed account rollups when a sibling peer sends a malformed payload", async () => { + const logger = createLogger(); + const stored = new Map(); + const service = createUsageTrackingService({ + logger, + dependencies: { + ...createFastDependencies(), + accountRollupStore: { + publish: (rollup: AdeUsageRollup) => { + stored.set(rollup.machineKey, rollup); + return true; + }, + readAll: () => [...stored.values()], + prune: () => undefined, + }, + localMachineIdentity: () => ({ machineKey: "this-machine", label: "Desk", platform: "darwin" }), + scanGitHubStats: vi.fn(async () => ({ + repo: null, + available: false, + fetchedAt: null, + error: null, + commitsCreated: 0, + prsTracked: 0, + prsOpen: 0, + prsMerged: 0, + prsClosed: 0, + prAdditions: 0, + prDeletions: 0, + filesChanged: 0, + daily: [], + })), + }, + }); + const valid = { + version: 1 as const, + machineKey: "peer-ok", + label: "Peer", + platform: "darwin", + capturedAt: "2026-09-04T00:00:00.000Z", + source: { sourceId: "peer-ok", roots: ["abc"] }, + rows: [{ + date: "2026-09-04", + provider: "claude", + model: "sonnet", + inputTokens: 1, + outputTokens: 1, + cachedTokens: 0, + totalTokens: 2, + costUsd: 0.01, + calls: 1, + }], + }; + + service.applyAccountRollups({ + rollups: [ + valid, + { + machineKey: "peer-bad", + capturedAt: "2026-09-04T00:00:00.000Z", + rows: [null], + }, + ], + failures: [], + } as never); + + expect(logger.warn).toHaveBeenCalledWith( + "usage.account.apply_rollups_partially_rejected", + expect.objectContaining({ droppedRollups: 1, droppedFailures: 0 }), + ); + expect(logger.warn).not.toHaveBeenCalledWith("usage.account.apply_rollups_rejected"); + const stats = await service.getAdeUsageStats({ preset: "all", scope: "account" }); + expect(stats.machines?.map((machine) => machine.machineKey)).toEqual( + expect.arrayContaining(["peer-ok", "peer-bad"]), + ); + expect(stats.machines?.find((machine) => machine.machineKey === "peer-bad")?.message) + .toBe("malformed rollup"); + + service.dispose(); + }); + + it("marks attached roots scanned even when the worker omitted them", async () => { + const logger = createLogger(); + const scanUsageLedgers = vi.fn(async (): Promise => ({ + costs: [], + projectCosts: [], + daily7d: {}, + entryCounts: {}, + providerErrors: {}, + incompleteProviders: [], + } as unknown as UsageLedgerScanResult)); + const service = createUsageTrackingService({ + logger, + projectRoot: "/repo-a", + dependencies: { + pollClaudeUsage: vi.fn(async () => ({ windows: [] as never[], extraUsage: null, errors: [] as never[] })), + pollCodexUsage: vi.fn(async () => ({ windows: [] as never[], errors: [] as never[] })), + scanUsageLedgers, + scanGitHubStats: vi.fn(async () => ({ + repo: null, + available: false, + fetchedAt: null, + error: null, + commitsCreated: 0, + prsTracked: 0, + prsOpen: 0, + prsMerged: 0, + prsClosed: 0, + prAdditions: 0, + prDeletions: 0, + filesChanged: 0, + daily: [], + })), + }, + }); + const other = service.attachProjectScope({ + key: "repo-b", + projectRoot: "/repo-b", + logger, + }); + + await service.refreshHistory(); + expect(scanUsageLedgers).toHaveBeenCalledTimes(1); + await other.getAdeUsageStats({ preset: "all", scope: "project" }); + await new Promise((resolve) => setImmediate(resolve)); + expect(scanUsageLedgers).toHaveBeenCalledTimes(1); + + other.dispose(); + service.dispose(); + }); + + // Two *separate* trackers still keep separate timers: sharing is opt-in + // through `attachSharedUsageTrackingScope`, which is what the brain uses. it("clamps out-of-range poll intervals internally", () => { const logger = createLogger(); const dependencies = createFastDependencies(); @@ -2700,8 +3028,10 @@ describe("createUsageTrackingService", () => { since: expectedSince, until: expectedUntil, }); - expect(collectDatabaseStats).toHaveBeenCalledWith(stats.range); - expect(scanGitHubStats).toHaveBeenCalledWith(stats.range); + // Second argument is the calling project scope's database / project root: + // one machine-level tracker answers stats for every project on the brain. + expect(collectDatabaseStats).toHaveBeenCalledWith(stats.range, null); + expect(scanGitHubStats).toHaveBeenCalledWith(stats.range, null); expect(stats.providers.find((provider) => provider.provider === "codex")?.totalTokens).toBe(300); service.dispose(); @@ -2784,10 +3114,13 @@ describe("createUsageTrackingService", () => { until: earlierDay.toISOString(), }); - expect(scanGitHubStats).toHaveBeenCalledWith(expect.objectContaining({ - since: new Date(2026, 4, 30, 0, 0, 0, 0).toISOString(), - until: new Date(2026, 4, 30, 23, 59, 59, 999).toISOString(), - })); + expect(scanGitHubStats).toHaveBeenCalledWith( + expect.objectContaining({ + since: new Date(2026, 4, 30, 0, 0, 0, 0).toISOString(), + until: new Date(2026, 4, 30, 23, 59, 59, 999).toISOString(), + }), + null, + ); service.dispose(); }); diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index 09ef8335c..00868ac16 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -10,6 +10,7 @@ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; import type { @@ -20,6 +21,7 @@ import type { AdeUsageProviderSummary, AdeUsageRangePreset, AdeUsageRollup, + AdeUsageRollupRow, AdeUsageScope, AdeUsageStats, AdeUsageTranscriptSource, @@ -89,7 +91,7 @@ import { sanitizeClaudeProjectPath, } from "./ledgers/localUsageLedgers"; import { listCursorBilledUsage } from "./cursorBilledUsageStore"; -import { isPathInside, pathComparisonKey } from "../shared/pathCompare"; +import { isPathInside, pathComparisonKey, pathKey } from "../shared/pathCompare"; import { buildRollupRows, mergeAccountUsageStats, @@ -189,7 +191,7 @@ function isCostSnapshotArray(value: unknown): value is CostSnapshot[] { return Array.isArray(value) && value.every((entry) => isRecord(entry) && typeof entry.provider === "string"); } -function isUsageSnapshot(value: unknown): value is UsageSnapshot { +export function isUsageSnapshot(value: unknown): value is UsageSnapshot { return isRecord(value) && Array.isArray(value.windows) && (value.spendControlReached === undefined || typeof value.spendControlReached === "boolean") @@ -1174,6 +1176,17 @@ function canonicalProjectRoot(projectRoot: string): string { return markerIndex >= 0 ? path.resolve(normalized.slice(0, markerIndex)) : resolved; } +/** + * Map key for per-project cost snapshots. `pathKey` folds Windows/macOS case + * so `C:\\repo` and `c:\\repo` are one cache slot; a bare `===` left a stale + * extra key behind when one spelling detached. + */ +function scopeRootKey(root: string | null | undefined): string { + const trimmed = root?.trim(); + if (!trimmed) return ""; + return pathKey(path.resolve(trimmed)); +} + /** * Both comparisons here are against paths a *provider* wrote into its own * ledger, not paths ADE controls, so their case is whatever that tool happened @@ -2216,6 +2229,27 @@ function buildProviderWindows( export type UsageTrackingService = ReturnType; +/** + * A project scope attached to one machine-level tracker. + * + * Provider quota is a machine fact, so one brain polls it once. Everything that + * genuinely differs per project — which repository GitHub activity is read + * from, which database ADE's own stats and account rollups live in, which + * project the analytics fact is attributed to — arrives through here, and the + * handle `attachProjectScope` returns is the per-project face of that single + * poller. + */ +export type UsageTrackingProjectScopeInput = { + /** Stable identity for this scope. Re-attaching the same key replaces it. */ + key: string; + projectRoot?: string | null; + db?: AdeDb | null; + logger?: Logger; + captureAnalytics?: (input: ProductAnalyticsCapture) => void; + /** Receives every snapshot the shared tracker publishes. */ + onUpdate?: (snapshot: UsageSnapshot) => void; +}; + /** Who this machine is, for the account directory. */ type LocalMachineIdentity = { machineKey: string; label: string; platform: string | null }; @@ -2231,9 +2265,13 @@ type UsageTrackingDependencies = { scanDroidLogs?: () => Promise; scanCopilotLogs?: () => Promise; scanGeminiLogs?: () => Promise; - scanGitHubStats?: (range: ResolvedAdeUsageRange) => Promise; - collectDatabaseStats?: (range: ResolvedAdeUsageRange) => AdeDatabaseUsageStats | null; - scanUsageLedgers?: (projectRoot: string | null | undefined, signal: AbortSignal) => Promise; + scanGitHubStats?: (range: ResolvedAdeUsageRange, projectRoot?: string | null) => Promise; + collectDatabaseStats?: (range: ResolvedAdeUsageRange, db?: AdeDb | null) => AdeDatabaseUsageStats | null; + scanUsageLedgers?: ( + projectRoot: string | null | undefined, + signal: AbortSignal, + projectRoots?: readonly string[], + ) => Promise; /** Durable per-machine rollup storage. Omitted = account scope has only this machine. */ accountRollupStore?: AccountUsageRollupStore; /** Identity/label this machine publishes under. Null = not resolvable yet. */ @@ -2265,6 +2303,53 @@ export type AccountRollupFetcher = (options: { timeoutMs: number; signal: AbortS failures: Array<{ machineKey: string; label: string; platform: string | null; message: string }>; }>; +/** What one fan-out over the account's machines produced. */ +export type AccountRollupFetchResult = Awaited>; + +/** Shape guard for a rollup fan-out result that arrived over RPC. */ +function isAdeUsageRollupRow(value: unknown): value is AdeUsageRollupRow { + return isRecord(value) + && typeof value.date === "string" + && typeof value.provider === "string" + && typeof value.model === "string" + && typeof value.inputTokens === "number" + && typeof value.outputTokens === "number" + && typeof value.cachedTokens === "number" + && typeof value.totalTokens === "number" + && typeof value.costUsd === "number" + && typeof value.calls === "number"; +} + +function isAdeUsageTranscriptSource(value: unknown): value is AdeUsageTranscriptSource { + return isRecord(value) + && (value.sourceId === null || typeof value.sourceId === "string") + && Array.isArray(value.roots) + && value.roots.every((root) => typeof root === "string"); +} + +function isAccountRollup(value: unknown): value is AdeUsageRollup { + return isRecord(value) + && typeof value.machineKey === "string" + && typeof value.capturedAt === "string" + && isAdeUsageTranscriptSource(value.source) + && Array.isArray(value.rows) + && value.rows.every(isAdeUsageRollupRow); +} + +function isAccountRollupFailure( + value: unknown, +): value is AccountRollupFetchResult["failures"][number] { + return isRecord(value) + && typeof value.machineKey === "string" + && typeof value.message === "string"; +} + +export function isAccountRollupFetchResult(value: unknown): value is AccountRollupFetchResult { + if (!isRecord(value)) return false; + if (!Array.isArray(value.rollups) || !Array.isArray(value.failures)) return false; + return value.rollups.every(isAccountRollup) && value.failures.every(isAccountRollupFailure); +} + /** How long the opportunistic live pull may run before the stored rollups stand alone. */ const ACCOUNT_LIVE_REFRESH_TIMEOUT_MS = 4_000; @@ -2402,10 +2487,40 @@ export function createUsageTrackingService({ Math.min(MAX_POLL_INTERVAL_MS, configuredInterval ?? DEFAULT_POLL_INTERVAL_MS) ); - let lastSnapshot: UsageSnapshot | null = readCachedUsageSnapshot(logger); - let cachedCosts: CostSnapshot[] = lastSnapshot?.costs ?? []; - let cachedAdeCosts: CostSnapshot[] = lastSnapshot?.adeCosts ?? []; - let cachedProjectCosts: CostSnapshot[] = []; + /** + * Producer-stamped ordering (see `UsageSnapshot.revision`). + * + * Two windows on one machine used to compare snapshots by wall-clock + * `lastPolledAt` across unrelated producers. `producerId` names this instance + * and `seq` counts every snapshot it hands out, so a consumer can order + * within a producer and never has to guess across producers. + */ + const producerId = randomUUID(); + let revisionSeq = 0; + function stampRevision(snapshot: UsageSnapshot): UsageSnapshot { + revisionSeq += 1; + return { ...snapshot, revision: { producerId, seq: revisionSeq } }; + } + + const diskCachedSnapshot = readCachedUsageSnapshot(logger); + /** + * Never null. Every read path returns exactly this object, so a snapshot a + * caller receives is always one this instance also emitted (or is byte + * identical to the current one) — no caller ever gets private freshness. + * + * The disk cache is re-stamped as this instance's seq 1: it was produced by + * a previous process, and carrying that process's revision forward would let + * a consumer order two unrelated producers against each other. + */ + let lastSnapshot: UsageSnapshot = stampRevision(diskCachedSnapshot ?? emptySnapshot()); + let cachedCosts: CostSnapshot[] = diskCachedSnapshot?.costs ?? []; + let cachedAdeCosts: CostSnapshot[] = diskCachedSnapshot?.adeCosts ?? []; + /** + * Project-scoped cost snapshots, per attached scope. One brain hosts several + * projects and walks the ledgers once for all of them; the projection per + * root is a filter over entries the scan already read. + */ + const cachedProjectCostsByRoot = new Map(); let projectCostsReady = false; /** * Providers whose scan threw on the last round. @@ -2434,22 +2549,22 @@ export function createUsageTrackingService({ * than the rest of the page" — the part a user has to be told about. */ let cachedIncompleteScanProviders: string[] = []; - const cachedCostTimestampIso = lastSnapshot?.costsLastPolledAt - ?? (cachedCosts.length > 0 || cachedAdeCosts.length > 0 ? lastSnapshot?.lastPolledAt : null); + const cachedCostTimestampIso = diskCachedSnapshot?.costsLastPolledAt + ?? (cachedCosts.length > 0 || cachedAdeCosts.length > 0 ? diskCachedSnapshot?.lastPolledAt : null); const cachedCostTimestampMs = cachedCostTimestampIso ? Date.parse(cachedCostTimestampIso) : Number.NaN; let costCacheTimestamp = Number.isFinite(cachedCostTimestampMs) ? cachedCostTimestampMs : 0; let costRefreshFailureCount = 0; let costRefreshNextRetryAtMs = 0; - let cachedDaily7d: Partial> = lastSnapshot?.dailyUsage7d ?? {}; + let cachedDaily7d: Partial> = diskCachedSnapshot?.dailyUsage7d ?? {}; // Track the last poll that returned real windows per provider so carried-forward // (stale) data can still report when it was genuinely fresh. const providerLastSuccess: Partial> = {}; for (const provider of ["claude", "codex"] as const) { - const cachedStatus = lastSnapshot?.providerStatus?.[provider]; + const cachedStatus = diskCachedSnapshot?.providerStatus?.[provider]; if (cachedStatus?.lastSuccessAt) { providerLastSuccess[provider] = cachedStatus.lastSuccessAt; - } else if (lastSnapshot && lastSnapshot.windows.some((w) => w.provider === provider)) { - providerLastSuccess[provider] = lastSnapshot.lastPolledAt; + } else if (diskCachedSnapshot && diskCachedSnapshot.windows.some((w) => w.provider === provider)) { + providerLastSuccess[provider] = diskCachedSnapshot.lastPolledAt; } } const githubStatsCache = new Map(); @@ -2474,10 +2589,23 @@ export function createUsageTrackingService({ const scanCodexCostLogs = dependencies?.scanCodexLogs ?? scanCodexLogs; const scanCursorCostLogs = async (): Promise => { const scanned = await (dependencies?.scanCursorLogs ?? scanCursorLogs)(); - const billed = db ? listCursorBilledUsage(db) : []; - if (!billed.length) return scanned; + // Constructor `db` is only the in-process default scope. The brain builds + // the tracker with no database and attaches each project's db afterward, + // so billed Cursor rows live on those scopes — not on the closed-over + // constructor handle. + const billedById = new Map(); + for (const scope of allScopes()) { + if (!scope.db) continue; + for (const entry of listCursorBilledUsage(scope.db)) { + billedById.set(entry.messageId, entry); + } + } + if (billedById.size === 0) return scanned; const seen = new Set(scanned.map((entry) => entry.messageId)); - return [...scanned, ...billed.filter((entry) => !seen.has(entry.messageId))]; + return [ + ...scanned, + ...[...billedById.values()].filter((entry) => !seen.has(entry.messageId)), + ]; }; const scanCursorAgentCostLogs = dependencies?.scanCursorAgentLogs ?? scanCursorAgentLogs; const scanOpenClawCostLogs = dependencies?.scanOpenClawLogs ?? scanOpenClawLogs; @@ -2486,9 +2614,10 @@ export function createUsageTrackingService({ const scanCopilotCostLogs = dependencies?.scanCopilotLogs ?? scanCopilotLogs; const scanGeminiCostLogs = dependencies?.scanGeminiLogs ?? scanGeminiLogs; const scanGitHubStatsForRange = dependencies?.scanGitHubStats - ?? ((range: ResolvedAdeUsageRange) => scanGithubActivityStats(projectRoot, range)); + ?? ((range: ResolvedAdeUsageRange, root?: string | null) => scanGithubActivityStats(root ?? null, range)); const collectDatabaseStatsForRange = dependencies?.collectDatabaseStats - ?? ((range: ResolvedAdeUsageRange) => collectAdeDatabaseUsageStats(db, range, logger)); + ?? ((range: ResolvedAdeUsageRange, scopeDb?: AdeDb | null) => + collectAdeDatabaseUsageStats(scopeDb ?? null, range, logger)); const hasInjectedLedgerScanners = Boolean( dependencies?.scanClaudeLogs || dependencies?.scanCodexLogs @@ -2503,9 +2632,91 @@ export function createUsageTrackingService({ const ledgerAbortController = new AbortController(); let disposed = false; + // ── Project scopes ───────────────────────────────────────────── + type AttachedScope = { + key: string; + projectRoot: string | null; + db: AdeDb | null; + logger: Logger; + captureAnalytics?: (input: ProductAnalyticsCapture) => void; + onUpdate?: (snapshot: UsageSnapshot) => void; + rollupStore: AccountUsageRollupStore; + }; + + const makeScope = (input: UsageTrackingProjectScopeInput): AttachedScope => { + const scopeLogger = input.logger ?? logger; + const scopeDb = input.db ?? null; + return { + key: input.key, + projectRoot: input.projectRoot ?? null, + db: scopeDb, + logger: scopeLogger, + captureAnalytics: input.captureAnalytics, + onUpdate: input.onUpdate, + rollupStore: dependencies?.accountRollupStore + ?? createAccountUsageRollupStore({ db: scopeDb, logger: scopeLogger }), + }; + }; + + /** + * The scope this instance was constructed with. Always present, so a host + * that never attaches a scope (the desktop in-process context, every test) + * behaves exactly as it did when the service was one-per-project. + */ + const defaultScope = makeScope({ + key: "__default__", + projectRoot: projectRoot ?? null, + db: db ?? null, + logger, + captureAnalytics: dependencies?.captureAnalytics, + onUpdate, + }); + const attachedScopes = new Map(); + const allScopes = (): AttachedScope[] => [defaultScope, ...attachedScopes.values()]; + const scopeProjectRoots = (): string[] => { + const seen = new Set(); + const roots: string[] = []; + for (const scope of allScopes()) { + const root = scope.projectRoot?.trim(); + if (!root) continue; + const key = scopeRootKey(root); + if (seen.has(key)) continue; + seen.add(key); + roots.push(root); + } + return roots; + }; + // ── Account scope ────────────────────────────────────────────── - const accountRollupStore = dependencies?.accountRollupStore - ?? createAccountUsageRollupStore({ db, logger }); + /** + * Rollups are replicated per project database, so a machine-level poller has + * to write through to every attached scope rather than pick one. Reads merge + * the scopes and keep the freshest row per machine: two project databases on + * one computer are two replicas of the same account fact, not two facts. + */ + const accountRollupStore: AccountUsageRollupStore = { + publish: (rollup, options) => { + let changed = false; + for (const scope of allScopes()) { + if (scope.rollupStore.publish(rollup, options)) changed = true; + } + return changed; + }, + readAll: () => { + const byMachine = new Map(); + for (const scope of allScopes()) { + for (const rollup of scope.rollupStore.readAll()) { + const existing = byMachine.get(rollup.machineKey); + if (existing && Date.parse(existing.capturedAt) >= Date.parse(rollup.capturedAt)) continue; + byMachine.set(rollup.machineKey, rollup); + } + } + return [...byMachine.values()]; + }, + prune: (oldestDayKey) => { + for (const scope of allScopes()) scope.rollupStore.prune(oldestDayKey); + }, + }; const readLocalMachineIdentity = dependencies?.localMachineIdentity ?? defaultLocalMachineIdentity; const readTranscriptRoots = dependencies?.transcriptRoots @@ -2620,6 +2831,107 @@ export function createUsageTrackingService({ fetchAccountRollups = fetcher; } + /** + * Store what a fan-out over the account's machines returned. + * + * Split out from `refreshAccountRollupsInBackground` because the fan-out and + * the storing of its result do not have to happen in the same process: the + * brain owns the rollup store and the poller, while the peer transport lives + * in the desktop app, which pushes results here. + */ + function applyAccountRollups( + result: AccountRollupFetchResult, + startedAtMs: number = Date.now(), + ): void { + // Crosses a process boundary (the desktop app pushes its fan-out result to + // the brain over the local socket), so the shape is checked here rather + // than trusted from the call site. One stale peer must not wipe the rest + // of the account: keep typed entries and drop only the malformed ones. + const payload: unknown = result; + if (!isRecord(payload) || !Array.isArray(payload.rollups) || !Array.isArray(payload.failures)) { + logger.warn("usage.account.apply_rollups_rejected"); + return; + } + const rollups = payload.rollups.filter(isAccountRollup); + const failures = payload.failures.filter(isAccountRollupFailure); + if (rollups.length !== payload.rollups.length || failures.length !== payload.failures.length) { + logger.warn("usage.account.apply_rollups_partially_rejected", { + droppedRollups: payload.rollups.length - rollups.length, + droppedFailures: payload.failures.length - failures.length, + }); + } + // `publish` returns true only when it really wrote something. Counting + // "did not throw" instead would emit an update for every refresh, + // including one that stored byte-identical history — and that update is + // what makes the next read, which starts the next refresh. + let published = 0; + // A peer on an older build still sends its whole decade of history. It + // must be cut to the same retention edge before it is stored, or this + // machine's own `prune` deletes those rows and the next fetch puts them + // straight back — the CRR delete/insert churn, arriving over the wire. + const oldestDay = rollupOldestDay(startedAtMs); + for (const rollup of rollups) { + try { + // Unknown identity cannot match anything, and nothing was published + // under this machine's name either, so there is no self-row to skip. + if (rollup.machineKey === readLocalMachineIdentity()?.machineKey) continue; + const bounded = oldestDay + ? { ...rollup, rows: rollup.rows.filter((row) => row.date >= oldestDay) } + : rollup; + // Fetched from that machine, not scanned here: this side cannot know + // which of the peer's providers failed, so it must not delete one + // that simply did not appear. See `publish`'s `ownerAuthoritative`. + if (accountRollupStore.publish(bounded, { ownerAuthoritative: false })) published += 1; + } catch (error) { + logger.warn("usage.account.apply_rollup_failed", { error: getErrorMessage(error) }); + } + } + // Replace wholesale rather than merge: a machine missing from this + // round's failures either answered or was not asked, and in both cases + // last round's error is no longer something to show. + // + // "Changed" is a real comparison against the previous round, not "there + // were failures". A peer that is permanently unreachable reports the + // same failure every time, and reading that as a change would keep the + // page emitting updates forever over news it already showed. + const previousFailures = new Map(accountRollupFailures); + accountRollupFailures.clear(); + const failureKeys = new Set(failures.map((failure) => failure.machineKey)); + for (const failure of failures) { + accountRollupFailures.set(failure.machineKey, { + label: failure.label, + platform: failure.platform, + message: failure.message, + }); + } + for (const raw of payload.rollups) { + if (isAccountRollup(raw) || !isRecord(raw) || typeof raw.machineKey !== "string") continue; + if (failureKeys.has(raw.machineKey)) continue; + failureKeys.add(raw.machineKey); + accountRollupFailures.set(raw.machineKey, { + label: typeof raw.label === "string" ? raw.label : raw.machineKey, + platform: typeof raw.platform === "string" ? raw.platform : null, + message: "malformed rollup", + }); + } + let failuresChanged = previousFailures.size !== accountRollupFailures.size; + if (!failuresChanged) { + for (const [machineKey, failure] of accountRollupFailures) { + const before = previousFailures.get(machineKey); + if (before + && before.label === failure.label + && before.platform === failure.platform + && before.message === failure.message) continue; + failuresChanged = true; + break; + } + } + // Content is unchanged; the rollup store behind it is not. Republish + // through the same path so the snapshot carries a new revision — an + // emit that reuses the current revision is dropped by ordering. + if (published > 0 || failuresChanged) publishSnapshot(lastSnapshot); + } + /** * Refresh reachable machines in the background and republish what they say. * @@ -2644,58 +2956,7 @@ export function createUsageTrackingService({ timer.unref?.(); const task = fetchAccountRollups({ timeoutMs: ACCOUNT_LIVE_REFRESH_TIMEOUT_MS, signal: controller.signal }) .then((result) => { - // `publish` returns true only when it really wrote something. Counting - // "did not throw" instead would emit an update for every refresh, - // including one that stored byte-identical history — and that update is - // what makes the next read, which starts the next refresh. - let published = 0; - // A peer on an older build still sends its whole decade of history. It - // must be cut to the same retention edge before it is stored, or this - // machine's own `prune` deletes those rows and the next fetch puts them - // straight back — the CRR delete/insert churn, arriving over the wire. - const oldestDay = rollupOldestDay(startedAtMs); - for (const rollup of result.rollups) { - // Unknown identity cannot match anything, and nothing was published - // under this machine's name either, so there is no self-row to skip. - if (rollup.machineKey === readLocalMachineIdentity()?.machineKey) continue; - const bounded = oldestDay - ? { ...rollup, rows: rollup.rows.filter((row) => row.date >= oldestDay) } - : rollup; - // Fetched from that machine, not scanned here: this side cannot know - // which of the peer's providers failed, so it must not delete one - // that simply did not appear. See `publish`'s `ownerAuthoritative`. - if (accountRollupStore.publish(bounded, { ownerAuthoritative: false })) published += 1; - } - // Replace wholesale rather than merge: a machine missing from this - // round's failures either answered or was not asked, and in both cases - // last round's error is no longer something to show. - // - // "Changed" is a real comparison against the previous round, not "there - // were failures". A peer that is permanently unreachable reports the - // same failure every time, and reading that as a change would keep the - // page emitting updates forever over news it already showed. - const previousFailures = new Map(accountRollupFailures); - accountRollupFailures.clear(); - for (const failure of result.failures) { - accountRollupFailures.set(failure.machineKey, { - label: failure.label, - platform: failure.platform, - message: failure.message, - }); - } - let failuresChanged = previousFailures.size !== accountRollupFailures.size; - if (!failuresChanged) { - for (const [machineKey, failure] of accountRollupFailures) { - const before = previousFailures.get(machineKey); - if (before - && before.label === failure.label - && before.platform === failure.platform - && before.message === failure.message) continue; - failuresChanged = true; - break; - } - } - if (published > 0 || failuresChanged) emitUpdate(lastSnapshot ?? emptySnapshot()); + applyAccountRollups(result, startedAtMs); }) .catch((error) => { logger.warn("usage.account.live_refresh_failed", { error: getErrorMessage(error) }); @@ -2771,27 +3032,47 @@ export function createUsageTrackingService({ return contributions; } - const emptySnapshot = (): UsageSnapshot => ({ - windows: [], - pacing: emptyPacing(), - pacingByProvider: {}, - providerStatus: {}, - costs: [], - adeCosts: [], - extraUsage: [], - lastPolledAt: nowIso(), - errors: [], - }); + function emptySnapshot(): UsageSnapshot { + return { + windows: [], + pacing: emptyPacing(), + pacingByProvider: {}, + providerStatus: {}, + costs: [], + adeCosts: [], + extraUsage: [], + lastPolledAt: nowIso(), + errors: [], + }; + } function emitUpdate(snapshot: UsageSnapshot): void { if (disposed) return; - try { - onUpdate?.(snapshot); - } catch { - // Never crash on callback error + // Constructor `onUpdate` lives on `defaultScope`. Attached project scopes + // add their own. One loop, so a host cannot observe a snapshot the others + // did not receive. + for (const scope of allScopes()) { + try { + scope.onUpdate?.(snapshot); + } catch { + // Never crash on callback error + } } } + /** + * The one way a new snapshot becomes visible: stamp it, store it, emit it, + * and hand the *same* object back to whoever asked. A return path that built + * a fresher snapshot without emitting it gave one window a value no other + * window could ever receive, which is exactly how two windows drifted. + */ + function publishSnapshot(snapshot: UsageSnapshot): UsageSnapshot { + const published = stampRevision(snapshot); + lastSnapshot = published; + emitUpdate(published); + return published; + } + function cachedCostResult(): { costs: CostSnapshot[]; adeCosts: CostSnapshot[] } { return { costs: cachedCosts, adeCosts: cachedAdeCosts }; } @@ -2803,7 +3084,9 @@ export function createUsageTrackingService({ if (!options.force && costCacheTimestamp > 0 && now - costCacheTimestamp < COST_CACHE_TTL_MS - && projectCostsReady) { + && projectCostsReady + && cachedProjectCostsByRoot.has(scopeRootKey(projectRoot)) + && scopeProjectRoots().every((root) => cachedProjectCostsByRoot.has(scopeRootKey(root)))) { return cachedCostResult(); } @@ -2817,9 +3100,10 @@ export function createUsageTrackingService({ let scanResult: UsageLedgerScanResult; if (!hasInjectedLedgerScanners) { - scanResult = await (dependencies?.scanUsageLedgers ?? ((root, signal) => ( - scanUsageLedgersInWorker(root, { signal }) - )))(projectRoot, ledgerAbortController.signal); + const roots = scopeProjectRoots(); + scanResult = await (dependencies?.scanUsageLedgers ?? ((root, signal, allRoots) => ( + scanUsageLedgersInWorker(root, { signal, additionalProjectRoots: allRoots }) + )))(projectRoot ?? null, ledgerAbortController.signal, roots); } else { // Recorded, not merely logged: a provider whose scan failed produced no // rows, and a peer sharing this home must be able to tell that from a @@ -2875,6 +3159,10 @@ export function createUsageTrackingService({ scanResult = { costs: buildCostSnapshots(providerEntries, "machine", projectRoot), projectCosts: buildCostSnapshots(providerEntries, "project", projectRoot), + projectCostsByRoot: Object.fromEntries(scopeProjectRoots().map((root) => [ + root, + buildCostSnapshots(providerEntries, "project", root), + ])), daily7d: { ...(claudeEntries.length > 0 ? { claude: bucketDaily7d(claudeEntries, now) } : {}), ...(codexEntries.length > 0 ? { codex: bucketDaily7d(codexEntries, now) } : {}), @@ -2951,9 +3239,35 @@ export function createUsageTrackingService({ return [...kept, ...carried]; }; const nextCosts = carryForward(cachedCosts, scanResult.costs); - const nextProjectCosts = projectCostsReady - ? carryForward(cachedProjectCosts, scanResult.projectCosts) - : [...scanResult.projectCosts]; + const scannedProjectCostsByRoot = new Map(); + for (const [root, snapshots] of Object.entries(scanResult.projectCostsByRoot ?? {})) { + const key = scopeRootKey(root); + if (!scannedProjectCostsByRoot.has(key)) scannedProjectCostsByRoot.set(key, snapshots); + } + // The constructor's own root is keyed by "" when there is none, so a host + // with no project at all still records that its project history was read + // (and is empty) rather than rescanning on every read. + const primaryRootKey = scopeRootKey(projectRoot); + if (!scannedProjectCostsByRoot.has(primaryRootKey)) { + scannedProjectCostsByRoot.set(primaryRootKey, scanResult.projectCosts); + } + const nextProjectCostsByRoot = new Map(); + for (const [key, scanned] of scannedProjectCostsByRoot) { + const previous = cachedProjectCostsByRoot.get(key); + nextProjectCostsByRoot.set( + key, + projectCostsReady && previous ? carryForward(previous, scanned) : [...scanned], + ); + } + // A worker that reported no projection for a root the caller asked about + // must still leave that root marked as read. A missing key means "project + // history was never scanned", which forces a full ledger walk on every + // project-scoped read of that scope. + for (const root of scopeProjectRoots()) { + const key = scopeRootKey(root); + if (nextProjectCostsByRoot.has(key)) continue; + nextProjectCostsByRoot.set(key, cachedProjectCostsByRoot.get(key) ?? []); + } const nextDaily7d: Partial> = { ...scanResult.daily7d }; for (const [provider, buckets] of Object.entries(cachedDaily7d) as [UsageProvider, number[]][]) { if (unreadableProviders.has(provider) && !(provider in nextDaily7d)) { @@ -2980,7 +3294,10 @@ export function createUsageTrackingService({ ])].sort(); cachedCosts = nextCosts; cachedAdeCosts = []; - cachedProjectCosts = nextProjectCosts; + cachedProjectCostsByRoot.clear(); + for (const [root, snapshots] of nextProjectCostsByRoot) { + cachedProjectCostsByRoot.set(root, snapshots); + } projectCostsReady = true; cachedDaily7d = nextDaily7d; costCacheTimestamp = now; @@ -3022,7 +3339,7 @@ export function createUsageTrackingService({ try { const providerTasks = providerStrategies.map(async (strategy) => { - const previousStatus = lastSnapshot?.providerStatus?.[strategy.provider] ?? null; + const previousStatus = lastSnapshot.providerStatus?.[strategy.provider] ?? null; const nextRetryMs = providerNextRetryAtMs[strategy.provider] ?? 0; const shouldHonorBackoff = reason === "automatic" || previousStatus?.errorKind === "rate_limited"; if (shouldHonorBackoff && nextRetryMs > Date.now()) { @@ -3096,12 +3413,12 @@ export function createUsageTrackingService({ // Reconcile each provider against the last snapshot so a transient // failure (409/timeout) carries forward good data instead of wiping it. const polledAt = nowIso(); - const prevWindows = lastSnapshot?.windows ?? []; + const prevWindows = lastSnapshot.windows; const providerStatus: UsageProviderStatusMap = {}; const mergedRaw: UsageWindow[] = []; for (const entry of providerResults) { const { provider, result, skipped, backoffSkipped } = entry; - const previousStatus = lastSnapshot?.providerStatus?.[provider] ?? null; + const previousStatus = lastSnapshot.providerStatus?.[provider] ?? null; if (skipped) { /* * A user asked for this refresh and the provider was skipped because @@ -3137,7 +3454,7 @@ export function createUsageTrackingService({ ); mergedRaw.push(...carriedWindows); const hasLegacyNonInteractiveCredentialError = provider === "claude" - && lastSnapshot?.errors.includes("claude: no non-interactive credentials found") === true; + && lastSnapshot.errors.includes("claude: no non-interactive credentials found"); if ( previousStatus && !hasLegacyNonInteractiveCredentialError @@ -3188,23 +3505,23 @@ export function createUsageTrackingService({ const extraUsage: ExtraUsage[] = []; if (claudeResult.extraUsage) extraUsage.push(claudeResult.extraUsage); else if (claudePollEntry?.skipped || providerStatus.claude?.state === "stale" || providerStatus.claude?.state === "unauthed") { - const previousClaudeExtra = lastSnapshot?.extraUsage.find((extra) => extra.provider === "claude"); + const previousClaudeExtra = lastSnapshot.extraUsage.find((extra) => extra.provider === "claude"); if (previousClaudeExtra) extraUsage.push(previousClaudeExtra); } - const costsLastPolledAt = lastSnapshot?.costsLastPolledAt; + const costsLastPolledAt = lastSnapshot.costsLastPolledAt; const dailyUsage7d: Partial> = { ...cachedDaily7d }; if (codexResult.dailyUsage7d?.some((value) => value > 0)) { dailyUsage7d.codex = codexResult.dailyUsage7d; cachedDaily7d = dailyUsage7d; } const providerMessages = [ - ...(lastSnapshot?.providerMessages ?? []).filter((message) => message.provider !== "codex"), + ...(lastSnapshot.providerMessages ?? []).filter((message) => message.provider !== "codex"), ...(codexResult.providerMessages ?? []), ]; let spendControlReached = codexResult.spendControlReached; if (typeof spendControlReached !== "boolean" && (codexPollEntry?.skipped || codexResult.windows.length === 0)) { // Codex wasn't polled this round — retain the last known spend-control state. - spendControlReached = lastSnapshot?.spendControlReached; + spendControlReached = lastSnapshot.spendControlReached; } const costResult = cachedCostResult(); @@ -3224,10 +3541,8 @@ export function createUsageTrackingService({ errors, }; - lastSnapshot = snapshot; - void writeCachedUsageSnapshot(snapshot, logger); - - emitUpdate(snapshot); + const published = publishSnapshot(snapshot); + void writeCachedUsageSnapshot(published, logger); logger.debug("usage.poll.complete", { reason, @@ -3237,17 +3552,17 @@ export function createUsageTrackingService({ pacing: pacing.status, }); - return snapshot; + return published; } catch (err) { const msg = getErrorMessage(err); logger.error("usage.poll.unexpected_error", { error: msg }); errors.push(`unexpected: ${msg}`); - if (lastSnapshot) { - return { ...lastSnapshot, errors, lastPolledAt: nowIso() }; - } - - return { ...emptySnapshot(), errors }; + // Published, not merely returned. This snapshot carries a fresher + // `lastPolledAt` than the one every other window holds, so handing it + // to this one caller alone is how a second window fell behind and + // stayed behind until its own poll landed. + return publishSnapshot({ ...lastSnapshot, errors, lastPolledAt: nowIso() }); } finally { if (inFlightPoll === currentPoll) { inFlightPoll = null; @@ -3300,7 +3615,7 @@ export function createUsageTrackingService({ } function getUsageSnapshot(): UsageSnapshot { - return lastSnapshot ?? emptySnapshot(); + return lastSnapshot; } function noteQuotaDemand(): UsageSnapshot { @@ -3326,7 +3641,7 @@ export function createUsageTrackingService({ logger.warn("usage.force_refresh_returning_cached_snapshot", { timeoutMs: QUOTA_REFRESH_RESPONSE_TIMEOUT_MS, }); - resolve(lastSnapshot ?? emptySnapshot()); + resolve(lastSnapshot); }, QUOTA_REFRESH_RESPONSE_TIMEOUT_MS).unref?.(); }); try { @@ -3342,7 +3657,7 @@ export function createUsageTrackingService({ if (inFlightHistoryRefresh) return await inFlightHistoryRefresh; const reason = options.reason ?? "user"; if (reason === "automatic" && Date.now() < costRefreshNextRetryAtMs) { - return lastSnapshot ?? emptySnapshot(); + return lastSnapshot; } githubStatsCache.clear(); githubStatsInFlight.clear(); @@ -3357,25 +3672,19 @@ export function createUsageTrackingService({ costRefreshFailureCount = 0; costRefreshNextRetryAtMs = 0; const refreshedAt = nowIso(); - const snapshot: UsageSnapshot = { - ...(lastSnapshot ?? emptySnapshot()), + const published = publishSnapshot({ + ...lastSnapshot, costs: costResult.costs, adeCosts: costResult.adeCosts, dailyUsage7d: { ...cachedDaily7d }, costsLastPolledAt: refreshedAt, - }; - lastSnapshot = snapshot; - void writeCachedUsageSnapshot(snapshot, logger); - try { - onUpdate?.(snapshot); - } catch { - // Never crash on callback error. - } + }); + void writeCachedUsageSnapshot(published, logger); logger.debug("usage.refresh.history_complete", { durationMs: Date.now() - startedAt, providerCount: costResult.costs.length, }); - return snapshot; + return published; }) .catch((error) => { costRefreshFailureCount += 1; @@ -3406,29 +3715,35 @@ export function createUsageTrackingService({ * read-heavy path costs at most three accepted events a day. Never allowed to * affect the read: analytics failing must not fail the Usage page. */ - function captureScopeAnalytics(scope: AdeUsageScope): void { - if (!dependencies?.captureAnalytics) return; + function captureScopeAnalytics(usageScope: AdeUsageScope, scope: AttachedScope): void { + const capture = scope.captureAnalytics; + if (!capture) return; try { - dependencies.captureAnalytics(usageScopeSelectedCapture(scope)); + capture(usageScopeSelectedCapture(usageScope)); } catch (error) { - logger.debug("usage.scope_analytics_failed", { error: getErrorMessage(error) }); + scope.logger.debug("usage.scope_analytics_failed", { error: getErrorMessage(error) }); } } - async function getAdeUsageStats(args: GetAdeUsageStatsArgs = {}): Promise { + async function getAdeUsageStats( + args: GetAdeUsageStatsArgs = {}, + forScope: AttachedScope = defaultScope, + ): Promise { const nowMs = Date.now(); const scope = normalizeScope(args.scope); - captureScopeAnalytics(scope); + captureScopeAnalytics(scope, forScope); const range = resolveAdeUsageRange(args, nowMs); const exactRange = Boolean(args.since || args.until); - const cacheKey = githubStatsCacheKey(range, exactRange); + const cacheKey = githubStatsCacheKey(range, exactRange, forScope); const githubCached = githubStatsCache.get(cacheKey)?.stats ?? null; - const machineSnapshot = lastSnapshot ?? emptySnapshot(); + const machineSnapshot = lastSnapshot; + const scopeProjectCosts = cachedProjectCostsByRoot.get(scopeRootKey(forScope.projectRoot)) ?? []; const snapshot = scope === "project" - ? { ...machineSnapshot, costs: cachedProjectCosts } + ? { ...machineSnapshot, costs: scopeProjectCosts } : machineSnapshot; const providerHistoryMissing = costCacheTimestamp === 0; - const projectHistoryMissing = scope === "project" && !projectCostsReady; + const projectHistoryMissing = scope === "project" + && !cachedProjectCostsByRoot.has(scopeRootKey(forScope.projectRoot)); const providerHistoryIncomplete = providerHistoryMissing || projectHistoryMissing; const providerHistoryStale = providerHistoryIncomplete || nowMs - costCacheTimestamp > COST_CACHE_TTL_MS; @@ -3441,12 +3756,12 @@ export function createUsageTrackingService({ && nowMs >= costRefreshNextRetryAtMs; const githubNeedsRefresh = !githubCached || nowMs - (githubStatsCache.get(cacheKey)?.fetchedAtMs ?? 0) > GITHUB_STATS_CACHE_TTL_MS; if (providerNeedsRefresh || githubNeedsRefresh) { - refreshStatsInBackground(range, { provider: providerNeedsRefresh, github: githubNeedsRefresh }, exactRange); + refreshStatsInBackground(range, { provider: providerNeedsRefresh, github: githubNeedsRefresh }, exactRange, forScope); } const stats = collectAdeUsageStats({ snapshot, githubStats: githubCached, - databaseStats: collectDatabaseStatsForRange(range), + databaseStats: collectDatabaseStatsForRange(range, forScope.db), args, nowMs, }); @@ -3513,49 +3828,59 @@ export function createUsageTrackingService({ range: ResolvedAdeUsageRange, requested: { provider: boolean; github: boolean }, exactRange = false, + forScope: AttachedScope = defaultScope, ): void { - const key = `${githubStatsCacheKey(range, exactRange)}:${requested.provider ? "provider" : ""}:${requested.github ? "github" : ""}`; + const key = `${githubStatsCacheKey(range, exactRange, forScope)}:${requested.provider ? "provider" : ""}:${requested.github ? "github" : ""}`; if (statsRefreshInFlight.has(key)) return; const task = (async () => { const work: Promise[] = []; if (requested.provider) work.push(refreshHistory({ reason: "automatic" })); - if (requested.github) work.push(getGithubStatsForRange(range, exactRange, true)); + if (requested.github) work.push(getGithubStatsForRange(range, exactRange, true, forScope)); await Promise.allSettled(work); // History refresh emits when its ledger scan settles. GitHub can finish // later, so always emit again after its cache is populated. - if (requested.github) emitUpdate(lastSnapshot ?? emptySnapshot()); + if (requested.github) publishSnapshot(lastSnapshot); })().finally(() => { statsRefreshInFlight.delete(key); }); statsRefreshInFlight.set(key, task); } - function githubStatsCacheKey(range: ResolvedAdeUsageRange, exactRange = false): string { + function githubStatsCacheKey( + range: ResolvedAdeUsageRange, + exactRange = false, + forScope: AttachedScope = defaultScope, + ): string { + // GitHub activity is read from one repository, so the cache is keyed by the + // scope's project root as well: two projects on one brain must not serve + // each other's commit counts. + const scopeKey = scopeRootKey(forScope.projectRoot); // Preset ranges move by milliseconds on every request. Keying on `until` // made the old cache miss forever, so every Stats render launched `gh`. // Calendar-day buckets preserve exact-range semantics while making normal // day/week/month/year requests stable for the full cache TTL. if (exactRange) { - return `${range.preset}:${range.since ?? "all"}:${range.until}`; + return `${scopeKey}:${range.preset}:${range.since ?? "all"}:${range.until}`; } const untilDay = localDayKey(range.until); const sinceDay = range.since ? localDayKey(range.since) : "all"; - return `${range.preset}:${sinceDay || "all"}:${untilDay}`; + return `${scopeKey}:${range.preset}:${sinceDay || "all"}:${untilDay}`; } async function getGithubStatsForRange( range: ResolvedAdeUsageRange, exactRange = false, waitForComplete = false, + forScope: AttachedScope = defaultScope, ): Promise { - const cacheKey = githubStatsCacheKey(range, exactRange); + const cacheKey = githubStatsCacheKey(range, exactRange, forScope); const cached = githubStatsCache.get(cacheKey); if (cached && Date.now() - cached.fetchedAtMs < GITHUB_STATS_CACHE_TTL_MS) { return cached.stats; } let inFlight = githubStatsInFlight.get(cacheKey); if (!inFlight) { - inFlight = scanGitHubStatsForRange(range) + inFlight = scanGitHubStatsForRange(range, forScope.projectRoot) .catch((error) => makeEmptyGithubStats(getErrorMessage(error))) .then((statsForRange) => { githubStatsCache.set(cacheKey, { fetchedAtMs: Date.now(), stats: statsForRange }); @@ -3583,6 +3908,57 @@ export function createUsageTrackingService({ } } + /** + * Scopes that asked for polling. The shared poller runs while at least one + * does and stops when the last one detaches, so a project closing does not + * take the machine's quota feed away from the projects still open. + */ + const startedScopeKeys = new Set(); + + function attachProjectScope(input: UsageTrackingProjectScopeInput) { + const scope = makeScope(input); + attachedScopes.set(scope.key, scope); + let detached = false; + + const scopeStop = (): void => { + if (!startedScopeKeys.delete(scope.key)) return; + if (startedScopeKeys.size === 0) stop(); + }; + + return { + start: (): void => { + if (detached || startedScopeKeys.has(scope.key)) return; + startedScopeKeys.add(scope.key); + start(); + }, + stop: scopeStop, + getUsageSnapshot, + noteQuotaDemand, + forceRefresh, + refreshHistory, + getAdeUsageStats: (args: GetAdeUsageStatsArgs = {}) => getAdeUsageStats(args, scope), + getUsageRollup, + setAccountRollupFetcher, + applyAccountRollups, + poll, + /** + * Detach this project, not the machine's poller. The shared service is + * owned by the process and outlives every scope on it. + */ + dispose: (): void => { + if (detached) return; + detached = true; + scopeStop(); + if (attachedScopes.get(scope.key) === scope) attachedScopes.delete(scope.key); + const rootKey = scopeRootKey(scope.projectRoot); + if (rootKey + && !allScopes().some((other) => scopeRootKey(other.projectRoot) === rootKey)) { + cachedProjectCostsByRoot.delete(rootKey); + } + }, + }; + } + return { start, stop, @@ -3593,7 +3969,11 @@ export function createUsageTrackingService({ getAdeUsageStats, getUsageRollup, setAccountRollupFetcher, + applyAccountRollups, poll, + attachProjectScope, + /** The producer identity every snapshot from this instance is stamped with. */ + producerId, dispose: () => { disposed = true; ledgerAbortController.abort(); @@ -3602,6 +3982,16 @@ export function createUsageTrackingService({ }; } +/** One project's face on a machine-level tracker. See `attachProjectScope`. */ +export type UsageTrackingProjectScope = ReturnType; + +/** + * What a host consumes from usage tracking: either the machine-level tracker + * directly (desktop in-process, tests) or one project's scope on a shared one + * (the brain). Consumers depend on this, never on which of the two they hold. + */ +export type UsageTrackingHost = UsageTrackingService | UsageTrackingProjectScope; + // ── Exported for testing ───────────────────────────────────────── export const _testing = { MIN_POLL_INTERVAL_MS, @@ -3653,3 +4043,9 @@ export const _testing = { setDynamicTokenPricingForTest, pollCodexViaCliRpc, }; + +export { + attachSharedUsageTrackingScope, + clearSharedUsageTrackingServicesForTesting, + peekSharedUsageTrackingService, +} from "./sharedUsageTracking"; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 6d8155623..fc1955202 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -5216,6 +5216,74 @@ describe("preload OAuth bridge", () => { } }); + /** + * Every rebind performed in here — the lazy session read, a project switch, a + * remote open, a disconnect — used to mutate the module's binding silently. + * That binding decides which usage feed this window accepts and which runtime + * answers its reads, so a renderer that was never told kept rendering the + * previous runtime's answer until main happened to push a binding of its own, + * which it does not do for the bindings established in here. It is published + * once per logical rebind: main's push for the binding we already adopted is + * not a second one. + */ + it("publishes an in-preload rebind once and does not repeat it for main's push", async () => { + const binding = { + kind: "local", + key: "local:/repo", + rootPath: "/repo", + displayName: "Project", + }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding }; + } + return undefined; + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + const bindingChanged = vi.fn(); + const unsubscribeBinding = bridge.app.onProjectBindingChanged(bindingChanged); + + // The lazy in-preload path: nothing in main pushed anything, the window + // simply read its own session. + await bridge.app.getWindowSession(); + expect(bindingChanged).toHaveBeenCalledTimes(1); + expect(bindingChanged).toHaveBeenCalledWith(binding); + + // Main publishes the same binding for the same rebind: already adopted. + const bindingListener = on.mock.calls.find( + ([channel]) => channel === IPC.appProjectBindingChanged, + )?.[1]; + expect(typeof bindingListener).toBe("function"); + bindingListener({}, { ...binding }); + expect(bindingChanged).toHaveBeenCalledTimes(1); + + // A binding that really is different still gets through, exactly once. + bindingListener({}, null); + expect(bindingChanged).toHaveBeenCalledTimes(2); + expect(bindingChanged).toHaveBeenLastCalledWith(null); + + unsubscribeBinding(); + }); + it("does not notify project binding listeners for same-binding event polling gaps", async () => { vi.useFakeTimers(); try { @@ -5269,13 +5337,21 @@ describe("preload OAuth bridge", () => { await vi.advanceTimersByTimeAsync(0); - expect(bindingChanged).not.toHaveBeenCalled(); + // The lazy session read discovers the binding this window was opened on, + // and that IS a change from "unbound" — it is published exactly once. + // What must not produce a second notification is the polling gap below: + // the binding never moved, only the event cursor did. + expect(bindingChanged).toHaveBeenCalledTimes(1); + expect(bindingChanged).toHaveBeenCalledWith(binding); expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeStreamEvents, { id: "target-1", projectId: "project-1", request: { cursor: 0, limit: 100, replay: false }, }); + await vi.advanceTimersByTimeAsync(10_000); + expect(bindingChanged).toHaveBeenCalledTimes(1); + unsubscribeEvents(); unsubscribeBinding(); } finally { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index b09e296dc..174d28369 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1165,7 +1165,71 @@ const projectBindingChangedCallbacks = new Set< (binding: OpenProjectBinding | null) => void >(); -function rememberProjectBinding(binding: OpenProjectBinding | null): void { +/** + * The binding the renderer has been told about, as `kind:key`. + * + * `currentProjectBinding` moves for reasons the renderer must not see — it is + * nulled for the duration of a project transition and restored when one fails — + * so "what did we last publish" is tracked separately from "what is bound right + * now". Publishing is edge-triggered off this value, which is also what + * deduplicates a rebind we performed in here against main's own + * `appProjectBindingChanged` push for the same rebind: whichever arrives first + * publishes, the second is a no-op. + */ +let publishedProjectBindingKey: string | null = null; +let publishingProjectBindingChange = false; + +function projectBindingPublishKey(binding: OpenProjectBinding | null): string | null { + return binding ? `${binding.kind}:${binding.key}` : null; +} + +/** + * Tell the renderer the binding changed. + * + * Everything in here that rebinds — the lazy `refreshProjectBinding`, a project + * switch, a remote open, a manual disconnect — used to mutate + * `currentProjectBinding` silently. That binding decides which event feed is + * accepted (see `subscribeUsageUpdateEvents`) and which runtime answers reads, + * so a renderer that never heard about the change kept rendering the previous + * runtime's answer until main happened to push one of its own. Only main's push + * ever reached the renderer, and main does not push for the bindings we + * establish in here. + */ +function publishProjectBindingChange(): void { + const nextKey = projectBindingPublishKey(currentProjectBinding); + if (nextKey === publishedProjectBindingKey) return; + publishedProjectBindingKey = nextKey; + // A listener that rebinds re-enters this; the loop below drains to the final + // state rather than nesting fan-outs, so listeners see each binding once and + // in order. + if (publishingProjectBindingChange) return; + publishingProjectBindingChange = true; + try { + let publishedKey: string | null | undefined; + while (publishedKey !== publishedProjectBindingKey) { + publishedKey = publishedProjectBindingKey; + const binding = currentProjectBinding; + clearProjectScopedReadCaches(); + for (const callback of [...projectBindingChangedCallbacks]) { + try { + callback(binding); + } catch (error) { + console.warn( + "[preload] project binding listener failed", + error instanceof Error ? error.message : String(error), + ); + } + } + } + } finally { + publishingProjectBindingChange = false; + } +} + +function rememberProjectBinding( + binding: OpenProjectBinding | null, + options: { publish?: boolean } = {}, +): void { const previousKey = currentProjectBinding?.key ?? null; const nextKey = binding?.key ?? null; projectBindingVersion += 1; @@ -1179,6 +1243,7 @@ function rememberProjectBinding(binding: OpenProjectBinding | null): void { if (binding) { ensureRemoteRuntimeEventPump(); } + if (options.publish !== false) publishProjectBindingChange(); } /** @@ -1189,27 +1254,16 @@ function rememberProjectBinding(binding: OpenProjectBinding | null): void { * Detaching from nothing (a projectless/machine-tab window) clears the value: * there is no remote runtime to protect, so chat reads must fall through to the * local service. + * + * Deliberately unpublished: this null is an in-flight state, not a binding the + * window settled on. Publishing it would drop the renderer to "no project" for + * the length of every open/switch — and put it straight back when the open was + * cancelled. The settled binding is published by whatever ends the transition + * (main's push, or the restore below, which is a no-op when nothing changed). */ function detachProjectBindingForTransition(): void { lastProjectRuntimeBindingKind = currentProjectBinding?.kind ?? null; - rememberProjectBinding(null); -} - -function notifyProjectBindingChangedCallbacks( - binding: OpenProjectBinding | null, -): void { - rememberProjectBinding(binding); - clearProjectScopedReadCaches(); - for (const callback of [...projectBindingChangedCallbacks]) { - try { - callback(binding); - } catch (error) { - console.warn( - "[preload] project binding listener failed", - error instanceof Error ? error.message : String(error), - ); - } - } + rememberProjectBinding(null, { publish: false }); } function localProjectBindingForRoot(rootPath: string): OpenProjectBinding { @@ -3877,9 +3931,15 @@ const adeBridge = { _event: Electron.IpcRendererEvent, payload: OpenProjectBinding | null, ) => { + // `cb` is not called here: `rememberProjectBinding` publishes to every + // registered callback, this one included. Calling it directly as well + // would deliver main's push twice to whichever subscriber owns this + // listener, and not at all a second time to the others. rememberProjectBinding(payload); + // Unconditional, unlike the publish above: main re-sends the binding on + // project changes that keep the same binding (reopening the project it + // already had), and those still invalidate project-scoped reads. clearProjectScopedReadCaches(); - cb(payload); }; ipcRenderer.on(IPC.appProjectBindingChanged, listener); return () => { @@ -4332,7 +4392,9 @@ const adeBridge = { return runProjectRuntimeTransition(async () => { const generation = ++openRemoteProjectGeneration; activeRemoteProjectOpenGeneration = generation; - rememberProjectBinding(null); + // In-flight, not settled — see `detachProjectBindingForTransition`. + // The success and failure paths below both publish the settled answer. + rememberProjectBinding(null, { publish: false }); const openPromise = ipcRenderer.invoke(IPC.remoteRuntimeOpenProject, { id, projectId, diff --git a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx index 9be46a0ec..857d61979 100644 --- a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx +++ b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx @@ -171,13 +171,19 @@ export function HeaderUsageControl({ }); const { snapshot, refreshing, bindingRevision, refreshNow } = usage; - // Tick the "updated Xs ago" label only while the popup is open. + // Tick the "updated Xs ago" label only while the popup is open — it is the + // only place the label is drawn. + // + // `snapshot` is a dependency so the clock is re-seeded when one arrives + // rather than only when the popover opened. Two windows handed the same + // snapshot then measure its age from the same instant, instead of each from + // whenever its own popover happened to open. useEffect(() => { if (!open) return; setNowMs(Date.now()); const timer = window.setInterval(() => setNowMs(Date.now()), 5_000); return () => window.clearInterval(timer); - }, [open]); + }, [open, snapshot]); // Drop the previous runtime's answer the moment the binding changes, so the // old project's meters are not drawn for the fraction of a second the new diff --git a/apps/desktop/src/renderer/components/usage/usage.test.tsx b/apps/desktop/src/renderer/components/usage/usage.test.tsx index 80e51d404..8a21b4333 100644 --- a/apps/desktop/src/renderer/components/usage/usage.test.tsx +++ b/apps/desktop/src/renderer/components/usage/usage.test.tsx @@ -232,6 +232,31 @@ function makeHeaderUsageSnapshot(): UsageSnapshot { }; } +/** + * A header snapshot as the host now stamps them: one percent across every + * window so a rendered chip identifies which snapshot is on screen, plus the + * producer/seq pair that decides ordering. + */ +function makeStampedHeaderSnapshot({ + percent, + producerId, + seq, + lastPolledAt, +}: { + percent: number; + producerId: string; + seq: number; + lastPolledAt: string; +}): UsageSnapshot { + const base = makeHeaderUsageSnapshot(); + return { + ...base, + windows: base.windows.map((window) => ({ ...window, percentUsed: percent })), + lastPolledAt, + revision: { producerId, seq }, + }; +} + function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -1046,6 +1071,132 @@ describe("usage components", () => { expect(screen.queryByRole("button", { name: /Codex wk 91%, 5h 91%/ })).toBeNull(); }); + /** + * The two-window bug, reproduced inside one render tree. + * + * A stale response that happens to carry a *future* wall clock used to + * latch whichever instance received it: ordering was a `lastPolledAt` + * comparison, so every genuinely newer push afterwards looked older and was + * dropped. The two mounts then disagreed forever — which is exactly what + * two windows on one machine were doing. Ordering is by producer/seq now, + * so the stale response is refused on arrival and both mounts stay on the + * snapshot the host actually last produced. + */ + it("keeps two header mounts on one snapshot when a stale future-stamped refresh lands", async () => { + const updateListeners = new Set<(snapshot: UsageSnapshot) => void>(); + vi.mocked(window.ade.usage.onUpdate).mockImplementation((cb) => { + updateListeners.add(cb); + return () => { + updateListeners.delete(cb); + }; + }); + const push = (snapshot: UsageSnapshot) => { + for (const listener of [...updateListeners]) listener(snapshot); + }; + + const nowMs = Date.now(); + const iso = (offsetMs: number) => new Date(nowMs + offsetMs).toISOString(); + const cached = makeStampedHeaderSnapshot({ + percent: 19, + producerId: "brain", + seq: 1, + lastPolledAt: iso(-4 * 3_600_000), + }); + const pushed = makeStampedHeaderSnapshot({ + percent: 33, + producerId: "brain", + seq: 2, + lastPolledAt: iso(-3 * 3_600_000), + }); + // Older by seq, newer by clock: the shape that used to win. + const staleRefresh = makeStampedHeaderSnapshot({ + percent: 77, + producerId: "brain", + seq: 1, + lastPolledAt: iso(60_000), + }); + const latest = makeStampedHeaderSnapshot({ + percent: 51, + producerId: "brain", + seq: 3, + lastPolledAt: iso(-2 * 3_600_000), + }); + vi.mocked(window.ade.usage.getSnapshot).mockResolvedValue(cached); + vi.mocked(window.ade.usage.noteDemand).mockResolvedValue(null); + vi.mocked(window.ade.usage.refresh).mockResolvedValue(staleRefresh); + + render( + <> + + + , + ); + + await waitFor(() => { + expect(screen.getAllByRole("button", { name: /Codex wk 19%, 5h 19%/ })).toHaveLength(2); + }); + + // Both popovers open, so both render the "updated … ago" line. + for (const trigger of screen.getAllByRole("button", { name: /Codex wk/ })) { + fireEvent.click(trigger); + } + + await act(async () => { + push(pushed); + }); + expect(screen.getAllByRole("button", { name: /Codex wk 33%, 5h 33%/ })).toHaveLength(2); + + // Only the first mount asks for a refresh; only it sees the stale answer. + fireEvent.click(screen.getAllByTitle("Refresh usage")[0]); + await waitFor(() => expect(window.ade.usage.refresh).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole("button", { name: /Codex wk 77%/ })).toBeNull(); + + await act(async () => { + push(latest); + }); + + expect(screen.getAllByRole("button", { name: /Codex wk 51%, 5h 51%/ })).toHaveLength(2); + const ages = screen.getAllByText(/^updated /); + expect(ages).toHaveLength(2); + expect(ages[0].textContent).toBe(ages[1].textContent); + }); + + /** + * A background window is throttled and its push subscription can be swept, + * so waking has to re-read rather than wait for a push that may never come. + * One wake is one read: `visibilitychange` and `focus` arrive together. + */ + it("re-reads the cached snapshot exactly once when the window becomes visible", async () => { + const nowMs = Date.now(); + const cached = makeStampedHeaderSnapshot({ + percent: 19, + producerId: "brain", + seq: 1, + lastPolledAt: new Date(nowMs - 3_600_000).toISOString(), + }); + const missed = makeStampedHeaderSnapshot({ + percent: 44, + producerId: "brain", + seq: 2, + lastPolledAt: new Date(nowMs - 60_000).toISOString(), + }); + vi.mocked(window.ade.usage.getSnapshot) + .mockResolvedValueOnce(cached) + .mockResolvedValue(missed); + + render(); + expect(await screen.findByRole("button", { name: /Codex wk 19%, 5h 19%/ })).toBeTruthy(); + expect(window.ade.usage.getSnapshot).toHaveBeenCalledTimes(1); + + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(new Event("focus")); + }); + + expect(window.ade.usage.getSnapshot).toHaveBeenCalledTimes(2); + expect(await screen.findByRole("button", { name: /Codex wk 44%, 5h 44%/ })).toBeTruthy(); + }); + it("does not poll usage while the drawer stays closed", async () => { vi.useFakeTimers(); render(); diff --git a/apps/desktop/src/renderer/components/usage/usageSnapshotOrdering.test.ts b/apps/desktop/src/renderer/components/usage/usageSnapshotOrdering.test.ts new file mode 100644 index 000000000..e1a8bba50 --- /dev/null +++ b/apps/desktop/src/renderer/components/usage/usageSnapshotOrdering.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import type { UsageProviderStatus, UsageSnapshot } from "../../../shared/types"; +import { shouldApplyUsageSnapshot } from "./usageSnapshotOrdering"; + +function providerStatus(updatedAt: string): UsageProviderStatus { + return { state: "ok", lastSuccessAt: updatedAt, updatedAt }; +} + +function snapshot(overrides: Partial = {}): UsageSnapshot { + return { + windows: [], + pacing: { + status: "on-track", + projectedWeeklyPercent: 0, + weekElapsedPercent: 0, + expectedPercent: 0, + deltaPercent: 0, + etaHours: null, + willLastToReset: true, + resetsInHours: 0, + }, + costs: [], + extraUsage: [], + lastPolledAt: "2026-05-21T12:00:00.000Z", + errors: [], + ...overrides, + }; +} + +describe("shouldApplyUsageSnapshot", () => { + it("never applies a missing snapshot", () => { + expect(shouldApplyUsageSnapshot(null, snapshot())).toBe(false); + expect(shouldApplyUsageSnapshot(null, null)).toBe(false); + }); + + it("applies anything when nothing is on screen yet", () => { + expect(shouldApplyUsageSnapshot(snapshot(), null)).toBe(true); + }); + + describe("both sides stamped", () => { + it("advances within one producer", () => { + const current = snapshot({ revision: { producerId: "brain-a", seq: 4 } }); + const next = snapshot({ revision: { producerId: "brain-a", seq: 5 } }); + expect(shouldApplyUsageSnapshot(next, current)).toBe(true); + }); + + it("accepts a re-emitted snapshot at the same seq", () => { + const current = snapshot({ revision: { producerId: "brain-a", seq: 4 } }); + const next = snapshot({ revision: { producerId: "brain-a", seq: 4 } }); + expect(shouldApplyUsageSnapshot(next, current)).toBe(true); + }); + + it("rejects a lower seq from the same producer", () => { + const current = snapshot({ revision: { producerId: "brain-a", seq: 9 } }); + const next = snapshot({ revision: { producerId: "brain-a", seq: 8 } }); + expect(shouldApplyUsageSnapshot(next, current)).toBe(false); + }); + + it("always accepts a different producer, whatever its seq", () => { + const current = snapshot({ revision: { producerId: "brain-a", seq: 900 } }); + const next = snapshot({ revision: { producerId: "remote-b", seq: 1 } }); + expect(shouldApplyUsageSnapshot(next, current)).toBe(true); + }); + + /** + * The concrete two-window failure. A local-clock snapshot stamped ahead of + * the remote producer's clock used to latch the window: both remote + * snapshots afterwards compared "older" and were dropped, so this window + * sat on stale numbers while its sibling moved on. + */ + it("accepts both remote snapshots after a future-stamped local one", () => { + const local = snapshot({ + lastPolledAt: "2026-05-21T12:00:30.000Z", + revision: { producerId: "local-main", seq: 3 }, + }); + const remoteEarly = snapshot({ + lastPolledAt: "2026-05-21T12:00:10.000Z", + revision: { producerId: "remote-host", seq: 1 }, + }); + const remoteLate = snapshot({ + lastPolledAt: "2026-05-21T12:02:10.000Z", + revision: { producerId: "remote-host", seq: 2 }, + }); + + expect(shouldApplyUsageSnapshot(remoteEarly, local)).toBe(true); + expect(shouldApplyUsageSnapshot(remoteLate, remoteEarly)).toBe(true); + }); + }); + + describe("one side stamped", () => { + it("lets a stamped push replace an unstamped cache", () => { + const current = snapshot({ lastPolledAt: "2026-05-21T23:00:00.000Z" }); + const next = snapshot({ + lastPolledAt: "2026-05-21T12:00:00.000Z", + revision: { producerId: "brain-a", seq: 1 }, + }); + expect(shouldApplyUsageSnapshot(next, current)).toBe(true); + }); + + it("rejects an unstamped cache once a stamped snapshot is on screen", () => { + const current = snapshot({ revision: { producerId: "brain-a", seq: 7 } }); + const next = snapshot({ lastPolledAt: "2026-05-20T12:00:00.000Z" }); + expect(shouldApplyUsageSnapshot(next, current)).toBe(false); + }); + }); + + describe("neither side stamped (legacy cache)", () => { + it("applies a newer poll", () => { + const current = snapshot({ lastPolledAt: "2026-05-21T12:00:00.000Z" }); + const next = snapshot({ lastPolledAt: "2026-05-21T12:05:00.000Z" }); + expect(shouldApplyUsageSnapshot(next, current)).toBe(true); + }); + + it("applies an equal poll", () => { + const current = snapshot({ lastPolledAt: "2026-05-21T12:00:00.000Z" }); + const next = snapshot({ lastPolledAt: "2026-05-21T12:00:00.000Z" }); + expect(shouldApplyUsageSnapshot(next, current)).toBe(true); + }); + + it("rejects an older poll with nothing else moving", () => { + const current = snapshot({ lastPolledAt: "2026-05-21T12:05:00.000Z" }); + const next = snapshot({ lastPolledAt: "2026-05-21T12:00:00.000Z" }); + expect(shouldApplyUsageSnapshot(next, current)).toBe(false); + }); + + it("accepts an unparsable next stamp instead of freezing the meters", () => { + const current = snapshot({ lastPolledAt: "2026-05-21T12:05:00.000Z" }); + const next = snapshot({ lastPolledAt: "not-a-date" }); + expect(shouldApplyUsageSnapshot(next, current)).toBe(true); + }); + + it("accepts anything once the current stamp is unparsable", () => { + const current = snapshot({ lastPolledAt: "not-a-date" }); + const next = snapshot({ lastPolledAt: "2026-05-21T12:00:00.000Z" }); + expect(shouldApplyUsageSnapshot(next, current)).toBe(true); + }); + + it("accepts an older poll whose cost scan advanced", () => { + const current = snapshot({ + lastPolledAt: "2026-05-21T12:05:00.000Z", + costsLastPolledAt: "2026-05-21T11:00:00.000Z", + }); + const next = snapshot({ + lastPolledAt: "2026-05-21T12:00:00.000Z", + costsLastPolledAt: "2026-05-21T12:00:00.000Z", + }); + expect(shouldApplyUsageSnapshot(next, current)).toBe(true); + }); + + it("accepts an older poll that gained a cost scan the current one never had", () => { + const current = snapshot({ lastPolledAt: "2026-05-21T12:05:00.000Z" }); + const next = snapshot({ + lastPolledAt: "2026-05-21T12:00:00.000Z", + costsLastPolledAt: "2026-05-21T12:00:00.000Z", + }); + expect(shouldApplyUsageSnapshot(next, current)).toBe(true); + }); + + it("rejects an older poll whose cost scan did not move", () => { + const current = snapshot({ + lastPolledAt: "2026-05-21T12:05:00.000Z", + costsLastPolledAt: "2026-05-21T12:00:00.000Z", + }); + const next = snapshot({ + lastPolledAt: "2026-05-21T12:00:00.000Z", + costsLastPolledAt: "2026-05-21T12:00:00.000Z", + }); + expect(shouldApplyUsageSnapshot(next, current)).toBe(false); + }); + + it("accepts an older poll whose provider status advanced", () => { + const current = snapshot({ + lastPolledAt: "2026-05-21T12:05:00.000Z", + providerStatus: { claude: providerStatus("2026-05-21T11:00:00.000Z") }, + }); + const next = snapshot({ + lastPolledAt: "2026-05-21T12:00:00.000Z", + providerStatus: { + claude: providerStatus("2026-05-21T11:00:00.000Z"), + codex: providerStatus("2026-05-21T12:04:00.000Z"), + }, + }); + expect(shouldApplyUsageSnapshot(next, current)).toBe(true); + }); + + it("rejects an older poll whose provider status is unchanged", () => { + const status = { claude: providerStatus("2026-05-21T11:00:00.000Z") }; + const current = snapshot({ lastPolledAt: "2026-05-21T12:05:00.000Z", providerStatus: status }); + const next = snapshot({ lastPolledAt: "2026-05-21T12:00:00.000Z", providerStatus: status }); + expect(shouldApplyUsageSnapshot(next, current)).toBe(false); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/usage/usageSnapshotOrdering.ts b/apps/desktop/src/renderer/components/usage/usageSnapshotOrdering.ts index 68943ec29..9412985b9 100644 --- a/apps/desktop/src/renderer/components/usage/usageSnapshotOrdering.ts +++ b/apps/desktop/src/renderer/components/usage/usageSnapshotOrdering.ts @@ -1,19 +1,92 @@ +/** + * Which of two usage snapshots is the later one. + * + * This used to be a wall-clock comparison on `lastPolledAt` alone, which is + * only sound when every snapshot a window sees was stamped by the same clock. + * It is not: a window can be fed by the local main process, by the brain + * daemon, and by a remote host, and those clocks are unrelated. One + * future-stamped snapshot from any of them latched the window permanently — + * every genuinely newer push afterwards compared "older" and was dropped. Two + * windows on one machine then showed different numbers forever, which is the + * bug this file exists to close. + * + * The producer now stamps `revision` (`producerId` + a per-producer `seq`), so + * ordering is decided by a counter that only ever moves forward, and only + * within one producer. A snapshot from a *different* producer is always + * accepted: two producers' sequences are incomparable, and the newest thing + * this window was handed is the best answer it has. + * + * The unstamped path below is the legacy one — an on-disk cache written before + * `revision` existed. A stamped live snapshot always replaces that cache. An + * unstamped cache must not replace a stamped snapshot already on screen. + */ import type { UsageSnapshot } from "../../../shared/types"; -function snapshotLastPolledMs(snapshot: UsageSnapshot | null): number | null { - if (!snapshot) return null; - const timestamp = Date.parse(snapshot.lastPolledAt); - return Number.isFinite(timestamp) ? timestamp : null; +function parseMs(timestamp: string | null | undefined): number | null { + if (typeof timestamp !== "string") return null; + const parsed = Date.parse(timestamp); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * The newest `providerStatus[*].updatedAt` in a snapshot. + * + * A poll that only re-checked one provider advances this without necessarily + * advancing `lastPolledAt`, so on the legacy path it is a second piece of + * evidence that the snapshot is fresher than what is on screen. + */ +function latestProviderStatusMs(snapshot: UsageSnapshot): number | null { + const statuses = snapshot.providerStatus; + if (!statuses) return null; + let latest: number | null = null; + for (const status of Object.values(statuses)) { + const updated = parseMs(status?.updatedAt); + if (updated == null) continue; + if (latest == null || updated > latest) latest = updated; + } + return latest; +} + +/** True when `next` advanced past `current`, treating "absent before" as advanced. */ +function advanced(next: number | null, current: number | null): boolean { + if (next == null) return false; + if (current == null) return true; + return next > current; } export function shouldApplyUsageSnapshot( nextSnapshot: UsageSnapshot | null, currentSnapshot: UsageSnapshot | null, ): boolean { - if (!currentSnapshot) return true; if (!nextSnapshot) return false; - const nextTimestamp = snapshotLastPolledMs(nextSnapshot); - const currentTimestamp = snapshotLastPolledMs(currentSnapshot); - if (currentTimestamp != null && nextTimestamp == null) return false; - return currentTimestamp == null || nextTimestamp == null || nextTimestamp >= currentTimestamp; + if (!currentSnapshot) return true; + + const nextRevision = nextSnapshot.revision; + const currentRevision = currentSnapshot.revision; + if (nextRevision && currentRevision) { + // Sequences are only comparable inside one producer. Across producers the + // arriving snapshot wins; `>=` inside a producer keeps a re-emitted + // snapshot (the same seq returned by a read) from being dropped. + if (nextRevision.producerId !== currentRevision.producerId) return true; + return nextRevision.seq >= currentRevision.seq; + } + // Exactly one side is stamped. A stamped push replaces an unstamped cache. + // An unstamped cache must not replace a stamped snapshot already on screen. + if (nextRevision && !currentRevision) return true; + if (!nextRevision && currentRevision) return false; + + // Legacy: neither side is stamped. + const nextPolled = parseMs(nextSnapshot.lastPolledAt); + // An unparsable stamp is accepted rather than rejected: it carries no + // ordering, and refusing it would let one bad value freeze the meters. + if (nextPolled == null) return true; + const currentPolled = parseMs(currentSnapshot.lastPolledAt); + if (currentPolled == null || nextPolled >= currentPolled) return true; + + // `lastPolledAt` went backwards, but another freshness marker moved forward, + // so this snapshot still carries news the one on screen does not. + if (advanced(parseMs(nextSnapshot.costsLastPolledAt), parseMs(currentSnapshot.costsLastPolledAt))) { + return true; + } + return advanced(latestProviderStatusMs(nextSnapshot), latestProviderStatusMs(currentSnapshot)); } diff --git a/apps/desktop/src/renderer/components/usage/useUsageSnapshot.ts b/apps/desktop/src/renderer/components/usage/useUsageSnapshot.ts index 37333b68b..c81ef2cbf 100644 --- a/apps/desktop/src/renderer/components/usage/useUsageSnapshot.ts +++ b/apps/desktop/src/renderer/components/usage/useUsageSnapshot.ts @@ -75,7 +75,12 @@ export function useUsageSnapshot({ if (!cancelled) applySnapshot(next); }); - const load = async () => { + /** + * Read the host's cached snapshot. Not a provider poll — the host answers + * from the machine-scoped tracker it already has — so it is cheap enough to + * repeat on a wake. + */ + const readCached = async () => { const requestedGeneration = bindingGenerationRef.current; let current: UsageSnapshot | null = null; try { @@ -87,6 +92,12 @@ export function useUsageSnapshot({ } if (cancelled || requestedGeneration !== bindingGenerationRef.current) return; if (current) applySnapshot(current); + }; + + const load = async () => { + const requestedGeneration = bindingGenerationRef.current; + await readCached(); + if (cancelled || requestedGeneration !== bindingGenerationRef.current) return; if (!noteDemand) return; try { const demanded = await bridge.noteDemand?.(); @@ -107,8 +118,38 @@ export function useUsageSnapshot({ if (readSnapshot) void load(); }); + /** + * Catch up when the window wakes. + * + * A background window is throttled and its runtime event subscription can + * be swept, so the pushes that keep this in step can simply stop arriving — + * and nothing else re-reads. That is the second half of "two windows, one + * machine, different numbers": whichever window was in the background is + * the one showing the older figure. Waking re-reads the host's cached + * snapshot; the ordering guard drops it if it is not newer. + * + * A read, never a poll — no interval is added here. Coalesced on the read + * itself, because a single wake fires `visibilitychange` and `focus` + * together. + */ + let wakeReadPending = false; + const readOnWake = () => { + if (!readSnapshot || cancelled || wakeReadPending) return; + wakeReadPending = true; + void readCached().finally(() => { + wakeReadPending = false; + }); + }; + const onVisibilityChange = () => { + if (document.visibilityState === "visible") readOnWake(); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + window.addEventListener("focus", readOnWake); + return () => { cancelled = true; + document.removeEventListener("visibilitychange", onVisibilityChange); + window.removeEventListener("focus", readOnWake); unsubscribe?.(); unsubscribeBinding?.(); }; diff --git a/apps/desktop/src/shared/types/usage.ts b/apps/desktop/src/shared/types/usage.ts index 740f9ff82..1d71deb19 100644 --- a/apps/desktop/src/shared/types/usage.ts +++ b/apps/desktop/src/shared/types/usage.ts @@ -626,6 +626,14 @@ export type UsageSnapshot = { costsLastPolledAt?: string; lastPolledAt: string; errors: string[]; + /** + * Producer-stamped ordering. `producerId` identifies the service instance that + * built the snapshot; `seq` increases by one on every snapshot that instance + * emits or returns. Consumers order by seq only within one producerId; a + * snapshot from a different producer is always accepted. Optional only for + * snapshots read from an older on-disk cache. + */ + revision?: { producerId: string; seq: number }; }; // --------------------------------------------------------------------------- diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index a4fbf8a7f..4fde71ed6 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -7810,7 +7810,8 @@ final class ADETests: XCTestCase { }, "lastPolledAt": "2026-07-10T18:00:00.000Z", "errors": ["claude: API returned 429"], - "pacing": { "status": "on-track" } + "pacing": { "status": "on-track" }, + "revision": { "producerId": "brain-1", "seq": 12 } } """ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5aed3ee9f..c631730b7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -304,7 +304,7 @@ The desktop app is a **client of the runtime**. It owns a trusted main process, | Directory | Role | |-----------|------| | `apps/desktop/src/main/` | Node process with full OS access. Hosts windows, registers IPC handlers, routes runtime-backed APIs through local/remote runtime pools, spawns the local ADE runtime when needed, and owns Electron-only services that cannot run inside the runtime. Entry: `main.ts`. | -| `apps/desktop/src/preload/` | Typed bridge. Entry: `preload.ts`. Uses `contextBridge.exposeInMainWorld("ade", { ... })`. Runtime-backed APIs route through `LocalRuntimeConnectionPool` (local) or `RemoteConnectionPool` (paired/SSH-bound window); file APIs are strict once a local/remote runtime is bound, while usage/budget reads only route to runtime for remote-bound windows. Usage push delivery follows the active binding too: unbound windows accept main-process usage events, while bound windows accept only the runtime event stream, so a dormant local tracker cannot overwrite the active project's snapshot. During project switches, mutating runtime/sync calls that target the ambiguous active binding are blocked, read-only calls avoid refreshing stale bindings, active remote opens can be awaited before retrying reads, and remote lane preview URLs are localized through desktop-owned TCP forwards. Chat history reads are the exception to the local-IPC fallback: `isRemoteProjectRuntimeContext()` gives a synchronous, transition-safe answer to "is this window's runtime remote?" (live binding → in-flight remote open → the kind snapshotted by `detachProjectBindingForTransition()`), and a remote context returns `unavailable: true` rather than letting the local chat service answer a remote session id with a false `sessionFound: false` that would wipe the transcript. History runtime actions use one object envelope (`sessionId` plus caps/cursor) across preload and ADE Code; the action registry still normalizes the legacy positional form for packaged-client compatibility. If a packaged local window is temporarily bound to an isolated runtime whose sync service is disabled, only the exact machine-level sync-unavailable/register-project failures retry through main-process sync IPC; remote-bound failures never fall back locally. Explicitly targeted work can pass an `OpenProjectBinding` pin through `callPinnedRuntimeAction` to route to the captured project during a switch, used by detached draft launches and rollback. The same pin is the per-session and detached-draft runtime routing mechanism: chat/session calls (including metadata regeneration), the PTY and terminal surface (`pty.create` / `resumeSession` / `sendToSession` / `write` / `resize` / `dispose` / `setDataSubscriptions` / `onData` / `onExit`, `terminal.preview`), plus machine-owned supporting APIs (AI discovery, slash commands, file search, attachments, lane management, parallel launch state, session deltas, and computer-use snapshots) accept an optional `OpenProjectBinding` so foreign work — a CLI or shell session as much as a chat — stays on its owning machine without rebinding the window's tab. The same trailing pin covers the machine-owned tool panels a chat drives: the iOS Simulator domain (`getStatus` / `listDevices` / `listLaunchTargets` / `launch` / `attachToChatSession` / `shutdown` / `screenshot` / `getScreenSnapshot` and the rest, through `callIosSimulatorActionOr`), App Control (`callAppControlActionOr`), the computer-use artifact mutations (`callComputerUseArtifactActionOr`: `deleteArtifacts` / `recoverArtifact` / `readArtifactPreview`), and the cross-machine handoff trio (`agentChat.prepareCrossMachineHandoff` / `validateCrossMachineSource` / `markCrossMachineHandoff`). The domain-bound wrappers exist so the domain string is not retyped at roughly fifty call sites — a typo there is a silent "unknown action" at runtime — while each method stays statically greppable. Pinned event subscriptions poll the selected runtime when Electron's bound event stream cannot represent a foreign machine, and preload releases a main-side subscription explicitly once its last pump stops reading; `iosSimulator.onEvent` and `appControl.onEvent` take the pin too, because a pinned panel that gets status reads without live updates is describing one simulator with another machine's stream. `builtInBrowser.onEvent` is the deliberate exception: the built-in browser is hosted by *this* desktop's main process (it owns a `WebContentsView`) and the runtime daemon only proxies calls into it over the desktop bridge socket, so a pin naming another *local* checkout still drives this machine's browser and must keep the local IPC stream; only a `kind: "remote"` pin switches to the pinned runtime stream. Preload read caches are namespaced by binding for the same reason: one preload process serves every machine a window talks to, so a cache keyed by arguments alone is machine-blind and one machine's rows could be served to — or overwritten by — another's. `boundReadCacheKey()` prefixes each cached read's key with `projectBindingKey(currentProjectBinding)` (space-separated, parsed back by `parseBoundReadCacheArgs`), the iOS Simulator status/device caches are keyed caches on the same grounds, and a pinned `getStatus` / `listDevices` bypasses the cache entirely through `callPinnedRuntimeAction`. Required foreign ownership fails closed; `callPinnedOrBoundRuntimeActionOr` retains the unchanged bound path only when no pin is required. The exposed object is contract-checked — `contextBridge.exposeInMainWorld("ade", adeBridge)` publishes a `const adeBridge = {...} satisfies Window["ade"]` (declared in `preload/global.d.ts`), so a preload signature that drifts from the declared contract, a dropped `pin` parameter above all, is a compile error rather than a renderer quietly talking to the wrong machine. | +| `apps/desktop/src/preload/` | Typed bridge. Entry: `preload.ts`. Uses `contextBridge.exposeInMainWorld("ade", { ... })`. Runtime-backed APIs route through `LocalRuntimeConnectionPool` (local) or `RemoteConnectionPool` (paired/SSH-bound window); file APIs are strict once a local/remote runtime is bound. Usage/budget reads follow the bound runtime when one exists; a window with no local project still goes through desktop usage IPC, which proxies to a booted brain project scope and falls back to the in-process tracker only when no brain scope is running. Usage push delivery follows the active binding too: unbound windows accept main-process usage events (main relays the brain's snapshots onto that channel), while bound windows accept only the runtime event stream. Preload publishes its own rebinds to the renderer so the usage feed and reads switch together instead of waiting for a later main-process push. During project switches, mutating runtime/sync calls that target the ambiguous active binding are blocked, read-only calls avoid refreshing stale bindings, active remote opens can be awaited before retrying reads, and remote lane preview URLs are localized through desktop-owned TCP forwards. Chat history reads are the exception to the local-IPC fallback: `isRemoteProjectRuntimeContext()` gives a synchronous, transition-safe answer to "is this window's runtime remote?" (live binding → in-flight remote open → the kind snapshotted by `detachProjectBindingForTransition()`), and a remote context returns `unavailable: true` rather than letting the local chat service answer a remote session id with a false `sessionFound: false` that would wipe the transcript. History runtime actions use one object envelope (`sessionId` plus caps/cursor) across preload and ADE Code; the action registry still normalizes the legacy positional form for packaged-client compatibility. If a packaged local window is temporarily bound to an isolated runtime whose sync service is disabled, only the exact machine-level sync-unavailable/register-project failures retry through main-process sync IPC; remote-bound failures never fall back locally. Explicitly targeted work can pass an `OpenProjectBinding` pin through `callPinnedRuntimeAction` to route to the captured project during a switch, used by detached draft launches and rollback. The same pin is the per-session and detached-draft runtime routing mechanism: chat/session calls (including metadata regeneration), the PTY and terminal surface (`pty.create` / `resumeSession` / `sendToSession` / `write` / `resize` / `dispose` / `setDataSubscriptions` / `onData` / `onExit`, `terminal.preview`), plus machine-owned supporting APIs (AI discovery, slash commands, file search, attachments, lane management, parallel launch state, session deltas, and computer-use snapshots) accept an optional `OpenProjectBinding` so foreign work — a CLI or shell session as much as a chat — stays on its owning machine without rebinding the window's tab. The same trailing pin covers the machine-owned tool panels a chat drives: the iOS Simulator domain (`getStatus` / `listDevices` / `listLaunchTargets` / `launch` / `attachToChatSession` / `shutdown` / `screenshot` / `getScreenSnapshot` and the rest, through `callIosSimulatorActionOr`), App Control (`callAppControlActionOr`), the computer-use artifact mutations (`callComputerUseArtifactActionOr`: `deleteArtifacts` / `recoverArtifact` / `readArtifactPreview`), and the cross-machine handoff trio (`agentChat.prepareCrossMachineHandoff` / `validateCrossMachineSource` / `markCrossMachineHandoff`). The domain-bound wrappers exist so the domain string is not retyped at roughly fifty call sites — a typo there is a silent "unknown action" at runtime — while each method stays statically greppable. Pinned event subscriptions poll the selected runtime when Electron's bound event stream cannot represent a foreign machine, and preload releases a main-side subscription explicitly once its last pump stops reading; `iosSimulator.onEvent` and `appControl.onEvent` take the pin too, because a pinned panel that gets status reads without live updates is describing one simulator with another machine's stream. `builtInBrowser.onEvent` is the deliberate exception: the built-in browser is hosted by *this* desktop's main process (it owns a `WebContentsView`) and the runtime daemon only proxies calls into it over the desktop bridge socket, so a pin naming another *local* checkout still drives this machine's browser and must keep the local IPC stream; only a `kind: "remote"` pin switches to the pinned runtime stream. Preload read caches are namespaced by binding for the same reason: one preload process serves every machine a window talks to, so a cache keyed by arguments alone is machine-blind and one machine's rows could be served to — or overwritten by — another's. `boundReadCacheKey()` prefixes each cached read's key with `projectBindingKey(currentProjectBinding)` (space-separated, parsed back by `parseBoundReadCacheArgs`), the iOS Simulator status/device caches are keyed caches on the same grounds, and a pinned `getStatus` / `listDevices` bypasses the cache entirely through `callPinnedRuntimeAction`. Required foreign ownership fails closed; `callPinnedOrBoundRuntimeActionOr` retains the unchanged bound path only when no pin is required. The exposed object is contract-checked — `contextBridge.exposeInMainWorld("ade", adeBridge)` publishes a `const adeBridge = {...} satisfies Window["ade"]` (declared in `preload/global.d.ts`), so a preload signature that drifts from the declared contract, a dropped `pin` parameter above all, is a compile error rather than a renderer quietly talking to the wrong machine. | | `apps/desktop/src/renderer/` | React 18 SPA. No Node access, no filesystem access, no direct process/network. Everything goes through `window.ade`. Entry: `main.tsx`. | | `apps/desktop/src/shared/` | Types, IPC channel constants (`ipc.ts`), model registry (`modelRegistry.ts`), keybindings, and cross-client derivations such as `chatScheduledWork.ts`, `externalSessionAffordances.ts` (the desktop/ADE Code Continue/Copy policy for provider-native imports), `prChecksRollup.ts` (the one definition of whether a commit's CI actually verified it, imported by the desktop service, the renderer, and the `ade code` TUI so no surface counts check rows on its own), and `chatEventCompaction.ts` (the one cap table for heavy chat-event payloads, imported by `agentChatService` for the stored transcript and by the ade-cli sync host for the mobile/web wire — two implementations with two cap tables is exactly what it replaced, and they had drifted). The project/machine model lives here too: `projectIdentity.ts` is the single definition of a binding key (`local:` / `remote::`) that every per-project cache and the repo tab join are keyed by, `machineIdentity.ts` is the single definition of "the machine ADE is running on" (`THIS_MACHINE_ID` / `THIS_MACHINE_NAME` / `isThisMachineId` / `machineDisplayName`, plus `machineNameForBinding(binding)` for the absolute name of the machine a routing binding targets — a null binding is the tab's own machine, the unpinned path, so it names "This computer" exactly like a local binding, while a remote binding prefers the runtime's own name and falls back to the project tab's display name; machines are named absolutely and "remote" is never used as a machine name), and `laneDivergence.ts` is the pure push-time guard against stranding another machine's unpushed commits. Imported by desktop, `apps/ade-cli`, and mobile contract generation paths. New runtime-facing types live in `shared/types/remoteRuntime.ts` and `shared/types/core.ts`. | | `apps/desktop/src/generated/` | Build-time generated code (e.g., bootstrap SQL snapshots). | @@ -984,7 +984,7 @@ High-frequency events flow from main → renderer via `webContents.send(channel, | `ade.sync.*` events | syncService | Top-bar Connections panel | | `ade.ai.opencodeOAuthStatus` / `ade.ai.piAuthStatus` | openCodeAuthService / piAuthService | Settings › Providers connect + sign-in flows | -Runtime-backed events reach the renderer through `ade.runtime.event` instead, and their subscriptions are held by `apps/desktop/src/main/services/ipc/runtimeEventSubscriptionRegistry.ts`. Subscriptions are keyed by **(sender, requestKey)** — the request key being `::` — because one renderer legitimately runs several pumps at once (the active binding, one pinned PTY pump per foreign lane, one pinned chat pump), and keying by sender alone would let each new pump tear down its siblings. Since key-per-sender no longer doubles as garbage collection, a stale entry is reclaimed by idle expiry: every live pump refreshes its subscription on each poll (750 ms–5 s normally, 30 s at the slowest failure backoff), a 20 s sweep drops anything unrefreshed for 60 s or whose sender is destroyed, and the renderer's explicit `ade.runtime.events.release` is the fast path so a switched-away binding stops streaming immediately rather than at expiry. A release covers the whole `(binding, category)` prefix, because a pump's replay flag flips from `live` to `replay` once it is caught up and it can therefore own both key variants. Removal has exactly one implementation (`removeRuntimeEventSubscription`), used by release, the ended callback, remote disconnect, sender death, and the sweep, so disposal and registry pruning cannot drift apart; disconnecting a remote target drops **every** subscription that window holds against it, not just the newest. +Runtime-backed events reach the renderer through `ade.runtime.event` instead, and their subscriptions are held by `apps/desktop/src/main/services/ipc/runtimeEventSubscriptionRegistry.ts`. Subscriptions are keyed by **(sender, requestKey)** — the request key being `::` — because one renderer legitimately runs several pumps at once (the active binding, one pinned PTY pump per foreign lane, one pinned chat pump), and keying by sender alone would let each new pump tear down its siblings. Since key-per-sender no longer doubles as garbage collection, a stale entry is reclaimed by idle expiry: every live pump refreshes its subscription on each poll (750 ms–5 s normally, 30 s at the slowest failure backoff), a 60 s sweep drops anything unrefreshed for 180 s or whose sender is destroyed, and the renderer's explicit `ade.runtime.events.release` is the fast path so a switched-away binding stops streaming immediately rather than at expiry. A release covers the whole `(binding, category)` prefix, because a pump's replay flag flips from `live` to `replay` once it is caught up and it can therefore own both key variants. Removal has exactly one implementation (`removeRuntimeEventSubscription`), used by release, the ended callback, remote disconnect, sender death, and the sweep, so disposal and registry pruning cannot drift apart; disconnecting a remote target drops **every** subscription that window holds against it, not just the newest. Renderer telemetry events flow back to main: `renderer.route_change`, `renderer.tab_change`, `renderer.window_error`, `renderer.unhandled_rejection`, `renderer.event_loop_stall`. @@ -1042,7 +1042,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `tests/` | `testService.ts` | Test-suite execution + run history. | | `updates/` | `autoUpdateService.ts`, `autoUpdateVersions.ts` | Electron auto-update wrapper around `electron-updater`. Owns the renderer-visible `AutoUpdateSnapshot` (`idle \| checking \| downloading \| ready \| installing \| error`, plus `currentVersion` / `latestKnownVersion` for the truthful-version surfaces), uses `compareUpdateVersions` (the SemVer-aware comparator in `autoUpdateVersions.ts`) to dedupe / supersede staged installers and to reconcile `pendingInstallUpdate` against the running version on next boot. A staged (`ready`) update does not pause those checks (an in-flight `quitAndInstall()` is the exception, because status stays `ready` until the native handoff): a strictly newer feed answer wipes the cached installer and downloads in its place, a same-or-older answer is ignored, and a failed check leaves the staged update untouched. Packaged builds schedule startup/periodic checks and downloads; source/dev launches construct the service without auto-check timers so missing `app-update.yml` never surfaces as a renderer error. ADE manually starts downloads after a cache-volume capacity preflight, checks the installed-app volume again before staging, classifies disk/quota/network/verification/permission/installer failures in the shared snapshot, preserves verified downloads when safe, and bounds the native installer handoff with a watchdog. A `ready` snapshot is not treated as proof the ZIP/EXE still exists: if the updater cache or `downloadedFile` is gone, the service re-downloads before uninstalling the runtime or calling native `quitAndInstall`, and it retries once when Squirrel reports a vanished-archive `network connection was lost`. The install is transactional: `quitAndInstall()` re-checks the staged version, and a consent that aborts before the native updater takes over lands in `snapshot.parked` (a typed `AutoUpdateInstallAbortReason`) so the exceptional shell banner offers a retry instead of silently losing the update; ordinary ready state remains in the top-right control. Restarting automatically is a separate machine-local `AutoUpdatePreferences` policy and defaults off. When enabled, its default-on idle safety waits for no active agent turns or work sessions (`RuntimeActivitySummary.idle`) before the grace period and renderer-visible countdown (`autoApplyPending`); users can opt into starting the countdown immediately instead. Cancel suppresses the next countdown (`autoApplySuppressedUntil`), disabling the preference clears it, and `ADE_DISABLE_AUTO_UPDATE_APPLY=1` is the process-level kill switch. `autoUpdateVersions.ts` also builds the changelog (`buildReleaseNotesUrl`) and GitHub release (`buildGithubReleaseUrl`) links. See [desktop auto-update disk-space behavior](./features/onboarding-and-settings/desktop-auto-update.md). | | `storage/` | `diskPressure.ts`, `volume.ts`, `storageInsightsService.ts`, `historyCompression.ts`, `storageLedger.ts`, `storageDbBreakdown.ts`, `storageMaintenanceJournal.ts` | Disk-full/recovery hardening + the storage doctor. `diskPressure` samples all ADE storage roots, classifies pressure with recovery hysteresis, and gates write-producing operation classes via `canPerform(kind)` (enforced at each start boundary in `agentChatService` / `ptyService` and the compressor). `storageInsightsService` builds the categorized Settings > Storage snapshot and preview-confirmed, link-safe cleanup, and runs the scheduled **storage doctor** maintenance sweep (`runMaintenanceNow` + post-boot/daily timers) that compresses history, reaps safe staging/backups/iOS build data, and invokes the kvDb DB-maintenance hooks. `storageLedger` is the declared bounding policy for every table/directory (with a CI coverage cross-check against `ADE_LAYOUT_DEFINITIONS`); `storageDbBreakdown` maps `dbstat` rows into the project-database breakdown; `storageMaintenanceJournal` reads/writes the 30-run doctor journal. `historyCompression` losslessly gzip-compresses inactive old transcripts/logs after byte-identity verification and exposes the transparent `.gz` read/reinflate helpers. Constructed in both `main.ts` and the `ade` runtime `bootstrap.ts`. See [features/storage-and-recovery/README.md](./features/storage-and-recovery/README.md). | -| `usage/` | `usageTrackingService.ts`, `providerQuotaParsers.ts`, `usageStatsStore.ts`, `usageLedgerWorkerClient.ts`, `budgetCapService.ts`, `ledgers/localUsageLedgers.ts`, `usagePricing.ts`, `githubActivityStats.ts`, `accountUsageRollup.ts`, `accountUsageRollupStore.ts`, `accountUsageSource.ts`, `accountUsageLiveRefresh.ts` | Live provider quota/cost accounting, budget enforcement, and retrospective activity stats. `usageTrackingService.ts` owns polling, pacing, provider/GitHub cache orchestration, and `getAdeUsageStats`; `providerQuotaParsers.ts` normalizes Claude and Codex quota payload variants and classifies Codex windows by advertised duration rather than assuming the provider's primary/secondary positions. The stats read returns cached expensive sources plus live project-DB aggregates immediately, marks the result `refreshing` when stale, and revalidates provider ledgers / GitHub in the background. Expensive local-ledger aggregation runs through `usageLedgerWorkerClient.ts` in a separate process so it cannot block terminal input, project switching, or sync; packaged desktop/CLI builds ship a sidecar while the static runtime uses the equivalent embedded entrypoint. The worker streams NDJSON — a roster header then one line per provider as that provider finishes — so a timeout yields the providers that did land instead of discarding eight finished scans along with the ninth, and every budget in front of it (`USAGE_REFRESH_HISTORY_TIMEOUT_MS` for renderer IPC, `USAGE_REFRESH_HISTORY_REMOTE_TRANSPORT_TIMEOUT_MS` for the remote JSON-RPC leg) is derived from `LEDGER_WORKER_TIMEOUT_MS` so it outlives the worker rather than racing it. `usagePricing.ts` resolves per-model token rates, preferring the maintained public rate list over ADE's static table; `githubActivityStats.ts` owns the `gh` shell-outs behind the page's commit/PR/code-movement numbers and fails soft. `usageStatsStore.ts` aggregates AI calls, sessions, lanes, code movement, artifacts, automations, workers, streaks, and the local-only cross-client `usage_events` ledger. Local provider scanners live under `usage/ledgers/`. The `account*` modules add the account scope: each machine publishes day × provider × model aggregates (never a transcript record) into the CRR-replicated `usage_machine_rollups` / `usage_machine_rollup_meta`, `accountUsageLiveRefresh.ts` opportunistically pulls fresher rollups from reachable peers over `usage.getUsageRollup`, and `accountUsageSource.ts` counts two machines that read one shared transcript home only once, keyed on a `.ade-usage-source` marker id with digested roots as the fallback. Historical cost/tokens/code merge; live quota windows do not, because provider rate limits are per provider account rather than per machine. Budget caps can match a rule scope while `usd-per-run` evaluates usage records keyed to the active run id. For runtime-backed projects, the machine brain is the sole quota poller and the renderer consumes its pushed snapshot; `main.ts` does not start a competing project-context tracker. Threshold state remains shared at module level for the unbound/local contexts, and `main.ts` adds a final IPC-level dedup gate with a 10-minute TTL per `provider:threshold:resetCycle` key. | +| `usage/` | `usageTrackingService.ts`, `sharedUsageTracking.ts`, `bootedUsageScope.ts`, `providerQuotaParsers.ts`, `usageStatsStore.ts`, `usageLedgerWorkerClient.ts`, `budgetCapService.ts`, `ledgers/localUsageLedgers.ts`, `usagePricing.ts`, `githubActivityStats.ts`, `accountUsageRollup.ts`, `accountUsageRollupStore.ts`, `accountUsageSource.ts`, `accountUsageLiveRefresh.ts` | Live provider quota/cost accounting, budget enforcement, and retrospective activity stats. `usageTrackingService.ts` owns polling, pacing, provider/GitHub cache orchestration, and `getAdeUsageStats`; `sharedUsageTracking.ts` is the one-poller-per-ADE-home attach/detach so every project scope in the brain shares one timer, demand lease, and snapshot; `bootedUsageScope.ts` is the shared "any booted local project" picker used by unbound IPC and the main-process usage-event relay; `providerQuotaParsers.ts` normalizes Claude and Codex quota payload variants and classifies Codex windows by advertised duration rather than assuming the provider's primary/secondary positions. The stats read returns cached expensive sources plus live project-DB aggregates immediately, marks the result `refreshing` when stale, and revalidates provider ledgers / GitHub in the background. Expensive local-ledger aggregation runs through `usageLedgerWorkerClient.ts` in a separate process so it cannot block terminal input, project switching, or sync; packaged desktop/CLI builds ship a sidecar while the static runtime uses the equivalent embedded entrypoint. The worker streams NDJSON — a roster header then one line per provider as that provider finishes — so a timeout yields the providers that did land instead of discarding eight finished scans along with the ninth, and every budget in front of it (`USAGE_REFRESH_HISTORY_TIMEOUT_MS` for renderer IPC, `USAGE_REFRESH_HISTORY_REMOTE_TRANSPORT_TIMEOUT_MS` for the remote JSON-RPC leg) is derived from `LEDGER_WORKER_TIMEOUT_MS` so it outlives the worker rather than racing it. `usagePricing.ts` resolves per-model token rates, preferring the maintained public rate list over ADE's static table; `githubActivityStats.ts` owns the `gh` shell-outs behind the page's commit/PR/code-movement numbers and fails soft. `usageStatsStore.ts` aggregates AI calls, sessions, lanes, code movement, artifacts, automations, workers, streaks, and the local-only cross-client `usage_events` ledger. Local provider scanners live under `usage/ledgers/`. The `account*` modules add the account scope: each machine publishes day × provider × model aggregates (never a transcript record) into the CRR-replicated `usage_machine_rollups` / `usage_machine_rollup_meta`, `accountUsageLiveRefresh.ts` opportunistically pulls fresher rollups from reachable peers over `usage.getUsageRollup`, and `accountUsageSource.ts` counts two machines that read one shared transcript home only once, keyed on a `.ade-usage-source` marker id with digested roots as the fallback. Historical cost/tokens/code merge; live quota windows do not, because provider rate limits are per provider account rather than per machine. Budget caps can match a rule scope while `usd-per-run` evaluates usage records keyed to the active run id. For runtime-backed projects, the machine brain is the sole quota poller (`attachSharedUsageTrackingScope`) and the renderer consumes its pushed snapshot; production `main.ts` does not start a competing project-context tracker. Unbound windows still receive those snapshots: main subscribes to one booted brain scope and broadcasts `ade.usage.event`. Each snapshot carries `revision.producerId`/`seq`; the compact header and open panel order by that counter within one producer and accept a snapshot from a different producer. Threshold state remains shared at module level for the unbound/local contexts, and `main.ts` adds a final IPC-level dedup gate with a 10-minute TTL per `provider:threshold:resetCycle` key. | | `perf/` | `perfLog.ts`, `perfIpc.ts`, `metricsSampler.ts`, `chatTextProbe.ts`, `aggregator.ts` | Opt-in local performance harness. `ADE_PERF_RUN_ID` opens a JSONL event log, samples Electron process metrics (including main-process event-loop delay and V8 heap size), records IPC durations, accepts renderer perf marks/web-vitals/`streamSmoothness` windows, records `chatTextFlush` events from the assistant-text coalescer, and aggregates each run into `summary.json`. `perfLog.ts` holds the single `PERF_EVENT_KINDS` list that both the `PerfEventKind` type and the `isPerfEventKind` IPC guard derive from, so a new kind cannot be added to one and forgotten in the other. More than one process can append to the same log — Electron main and the `ade` runtime daemon both host chat sessions — so `appendEvent` must stay one `appendFileSync` of one already newline-terminated string (one `O_APPEND` write the kernel will not interleave). | **Cross-cutting personal-chat paths.** Personal chat reuses `chat/agentChatService.ts` with `surface: "personal"`, a light session profile, a neutral general-assistant prompt, project/lane environment variables removed, and project slash-command/ADE-guidance injection disabled. The hidden runtime also disables project push publication. Desktop Browser calls from that surface pass `tabCollection: "personal"`; `builtInBrowserService.ts` uses it only to select an independent visible tab collection. The persistent authentication partition remains global. See [Personal chats](./features/personal-chats/README.md) for the complete source map and invariants. @@ -1966,7 +1966,7 @@ The normative privacy, consent, taxonomy, quota, configuration, and instrumentat - **IPC tracing** — every handler emits `ipc.invoke.begin` / `ipc.invoke.done` / `ipc.invoke.failed` with call ID, channel, window ID, duration, summarized args. Mandatory for new handlers. - **Renderer lifecycle** — `renderer.route_change`, `renderer.tab_change`, `renderer.window_error`, `renderer.unhandled_rejection`, `renderer.event_loop_stall`. Mandatory for new surfaces that introduce novel lifecycle transitions. - **Startup tasks** — `project.startup_task_enabled`, `project.startup_task_skipped`, `project.startup_task_begin`, `project.startup_task_done` with durations. -- **Usage tracking** — `usageTrackingService.ts` + `usageStatsStore.ts` + `usage/ledgers/*` + `budgetCapService.ts` account for provider quotas/cost and retrospective ADE activity. The top-bar Usage popup (`HeaderUsageControl` → `UsageLimitsBand` + collapsible `BudgetCapEditor`) shows live quota windows, and Settings > Usage renders the same band inline; that page and the empty Work composer otherwise use the cached cross-client activity projection. After a successful meaningful mutation, desktop IPC, ADE action RPC, and paired sync-command ingress record one local `usage_events` row with client attribution; reads, polling, background work, and failed calls do not count. `main.ts` keeps a dormant usage tracker available while no runtime project is bound so the main menu can show machine-level Claude/Codex usage. Once a project runtime is bound, the machine brain is the only poller; preload forwards only that binding's runtime events, and the compact header and open panel reject older same-binding snapshots and reset their snapshot/provider state when the binding changes. +- **Usage tracking** — `usageTrackingService.ts` + `sharedUsageTracking.ts` + `usageStatsStore.ts` + `usage/ledgers/*` + `budgetCapService.ts` account for provider quotas/cost and retrospective ADE activity. The top-bar Usage popup (`HeaderUsageControl` → `UsageLimitsBand` + collapsible `BudgetCapEditor`) shows live quota windows, and Settings > Usage renders the same band inline; that page and the empty Work composer otherwise use the cached cross-client activity projection. After a successful meaningful mutation, desktop IPC, ADE action RPC, and paired sync-command ingress record one local `usage_events` row with client attribution; reads, polling, background work, and failed calls do not count. The brain polls provider quota once per machine. Production `main.ts` does not start a second project-context tracker; it relays the brain's snapshots onto `ade.usage.event` so Welcome/Hub/remote-machine windows stay live, and those windows' usage IPC proxies to a booted brain scope. The in-process tracker is the fallback producer only when no brain scope is running (and in in-process tests). Bound windows take the runtime event stream; preload publishes its own rebinds so the renderer switches feeds with the binding. The compact header and open panel order snapshots by `revision` (`producerId` + `seq`) and reset when the binding changes. - **Local perf runs** — `scripts/perf-launch.mjs` / `scripts/run-perf-scenario.mjs` launch ADE with a run id, feed renderer scenarios, and collect JSONL events plus `summary.json` under `~/.ade/perf-runs//`. This is local-only diagnostics, not external telemetry. `perf-launch.mjs` first stops any listening dev runtime daemon: `ensureRuntime` reuses a healthy daemon untouched, and a daemon only ever sees `ADE_PERF_RUN_ID` if it inherits it at spawn, so a reused daemon is exactly how a run ends up with zero `chatTextFlush` events. Beyond the process/IPC/web-vitals basics, a run summary carries `chatText` (assistant-text flush cadence — flushes, chars per flush, deltas coalesced, gap percentiles, per-session breakdown, and a count per flush reason), `mainLoop` (main-process event-loop delay), and `streamSmoothness` (how smoothly streaming text actually painted). Read `streamSmoothness` carefully: `advancedRatio` is frames-advanced over frames-total and therefore display-dependent — a 240 Hz machine floors it near 0.25 for a stream a 60 Hz machine scores at 1.0 — so compare runs only within the same `estimatedHz`, or use the display-independent `advancedPerSecond` / `advancedRatio60`. - **Privacy-bounded product analytics** — configured builds manually capture a strict allowlist of PostHog events for a once-only install milestone, app opens, activation, key normalized screen arrivals, successful feature actions, truthful persisted work-session completions, update-prompt decisions, coarse error categories, daily aggregate usage, and analytics-budget health. Canonical desktop/runtime/hosted-client events, direct native-mobile UI events (`ade_mobile_*`), and public-site events (`ade_marketing_*`) use separate namespaces so marketing visits and the phone's own installation identity cannot inflate product activation or retention. Ordinary events salt/hash project and session identifiers, disable GeoIP and person profiles, and reject arbitrary properties. Once ADE knows a signed-in account, the shared service may send one quota-counted `$identify` that links anonymous history to a one-way account hash and sets only plan, platform, and app version; explicit sign-out rotates the anonymous identity. It never sends prompts, code, file or terminal content, repository names or paths, command arguments, URLs, branch names, raw account IDs, email addresses, error messages, stack traces, or recordings. Session replay, autocapture, automatic pageviews, surveys, and feature flags are disabled or absent. - **Quota controls** — analytics is lazy and batched, with a hard 200-event installation-wide UTC-day budget, tighter per-event and per-minute caps, deduplication windows, bounded queues, and a summarized budget event. Persisted `usage_events` are exported only after successful user mutations; reads, render loops, polling, streams, terminal bytes, and retries do not generate product events. Native mobile and public-web clients apply still-lower local ceilings. Budget `sent_count` is the legacy wire name for attempts accepted/enqueued locally, not confirmed PostHog delivery. diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 2cc839d7f..116881d4e 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -843,7 +843,8 @@ Renderer — settings: 5 min; CLIs not detected on the machine are hidden from the header, while installed-but-unauthenticated providers stay visible in the panel as "Not signed in". The header and panel subscribe to usage `onUpdate`, - reject an older snapshot within the same project binding, and clear then + order snapshots by `revision.producerId`/`seq` (accepting a different + producer outright), and clear then reload both quota and provider-connection state when the binding changes. This keeps the compact percentages and the open panel on the same live machine-brain snapshot even across fast project or machine switches. @@ -865,9 +866,11 @@ Renderer — settings: `saveBudgetConfig`. Threshold crossings (25 / 50 / 75 / 100 %) emit `UsageThresholdEvent`s for local usage handling. - `apps/desktop/src/renderer/components/usage/usageSnapshotOrdering.ts` — - shared ordering guard for the compact header and full quota panel. It accepts - the first snapshot for a binding and newer/equal poll timestamps, while each - component explicitly resets the guard when the project binding changes. + shared ordering guard for the compact header and full quota panel. It orders + by `revision.seq` within one `producerId` and always accepts a snapshot from + a different producer, so unrelated wall clocks cannot latch the meters. + Unstamped on-disk cache snapshots stay on the legacy `lastPolledAt` path. + Each component resets the guard when the project binding changes. - `apps/desktop/src/renderer/components/settings/AdeUsageSection.tsx` — Settings > Usage. One scrolling page, deliberately not split by where a number comes from: dividing live limits from history means "am I spending a @@ -910,7 +913,9 @@ Renderer — settings: - `apps/desktop/src/renderer/components/usage/useUsageSnapshot.ts` — the single subscription to the host's live usage snapshot (`getSnapshot` / `onUpdate` / `onProjectBindingChanged`, the binding-generation guard, and - `shouldApplyUsageSnapshot` ordering), owned by the popover and passed down. + `shouldApplyUsageSnapshot` revision ordering), owned by the popover and passed + down. Preload publishes its own rebinds, so this hook sees a binding change + even when main does not push one. The popover and the quota band each used to keep a private copy with its own guard and then hand it back up through a callback — two readers and two guards over one number, which is the arrangement where a late response is discarded @@ -927,6 +932,12 @@ Renderer — settings: - `apps/desktop/src/renderer/components/usage/UsagePaceBar.tsx` and `UsageSegmented.tsx` — the quota pace bar and the segmented control used by both usage surfaces. +- `apps/desktop/src/main/services/usage/sharedUsageTracking.ts` — one usage + tracker per ADE home. Every project scope in the brain attaches to it + (`attachSharedUsageTrackingScope`); the last detach disposes the poller. +- `apps/desktop/src/main/services/usage/bootedUsageScope.ts` — picks any + already-booted local project root so unbound usage IPC and the main-process + usage-event relay borrow the same brain scope. - `apps/desktop/src/main/services/usage/usageTrackingService.ts` — owns the live quota snapshot plus the retrospective `getAdeUsageStats(args)` projection. `args.scope` selects `account` (every machine on the ADE account, @@ -944,10 +955,12 @@ Renderer — settings: local activity are reported as separate labeled groups (never max-merged). Live quota polling is adaptive and coalesced, retains unexpired last-good provider windows with source/freshness metadata, and stays independent from - the expensive provider-ledger and GitHub history scans. Runtime-backed - projects use the machine brain as the single quota owner; the desktop does - not create a second project-context tracker that could race the runtime event - stream. + the expensive provider-ledger and GitHub history scans. Each published + snapshot carries `revision`. Runtime-backed projects use the machine brain as + the single quota owner; production desktop does not create a second + project-context tracker that could race the runtime event stream. Unbound + windows proxy usage IPC to a booted brain scope and receive the brain's + snapshots via main's `ade.usage.event` relay. It returns cached provider/GitHub results and current DB aggregates without awaiting expensive scans, exposes freshness metadata (`fresh` / `refreshing`), and coalesces stale provider/GitHub revalidation in the background diff --git a/docs/features/onboarding-and-settings/usage-tracking.md b/docs/features/onboarding-and-settings/usage-tracking.md index dc8ab6b0e..3d452dcbd 100644 --- a/docs/features/onboarding-and-settings/usage-tracking.md +++ b/docs/features/onboarding-and-settings/usage-tracking.md @@ -13,6 +13,37 @@ on 2026-07-10. The relevant upstream references are [`providers.md`](https://github.com/steipete/CodexBar/blob/8489002e19eed002016b29faa7de0f8c5371c65c/docs/providers.md), and [`refresh-loop.md`](https://github.com/steipete/CodexBar/blob/8489002e19eed002016b29faa7de0f8c5371c65c/docs/refresh-loop.md). +## One poller per machine + +Provider quota is a machine fact, not a project fact. The ADE brain polls it +once per machine and every project scope in that process attaches to the same +poller: one poll timer, one demand lease, one snapshot. Two ADE windows on one +computer — on two projects, or one on a project and one on Welcome or the Hub — +read the same numbers, and a refresh from any of them benefits all of them. + +Every snapshot carries a producer revision: `revision.producerId` names the +service instance that built it and `revision.seq` counts the snapshots that +instance has handed out. Consumers order by `seq` within one `producerId` and +always accept a snapshot from a different producer, which is what stops two +unrelated wall clocks from being compared. A snapshot returned to a caller was +always also emitted to every subscriber, so no window ever holds a value the +others could not receive. + +Project-scoped answers stay per project. Each scope brings its own project +database and repository root, so ADE's own token/cost stats, GitHub activity, +and the `project` scope of the Usage page are still about the project that +asked — the transcript ledgers are simply walked once for the whole machine and +projected per project root. + +A window with no local project — Welcome, the Hub, an Account page, a +remote-machine tab — reads through the brain as well, borrowing a project scope +the brain has already booted (`bootedUsageScopeRoot`). Desktop usage IPC +proxies those reads to that scope, and `main.ts` relays the brain's usage +events onto `ade.usage.event` so the window stays live without a runtime +binding of its own. Bound windows ignore that channel and keep the runtime +event stream. The in-process tracker is the fallback producer only when no +brain scope is running, which is also the only time that tracker polls. + ## ADE versus CodexBar | Concern | ADE before ADE-117 | CodexBar reference | ADE after ADE-117 | diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index 62b964be3..8a71502e9 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -86,7 +86,7 @@ relay payload E2E encryption is planned security work. See the trust boundary in window runs several pumps at once (active binding, one pinned PTY pump per foreign lane, one pinned chat pump) and keying by sender alone would make each new pump tear down its siblings. Stale entries are reclaimed by idle expiry - (refreshed on every poll, swept every 20 s at a 60 s idle threshold) with the + (refreshed on every poll, swept every 60 s at a 180 s idle threshold) with the renderer's release as the fast path. Every caller — release, the ended callback, remote disconnect, sender death, the sweep — removes through one function, so disposal and pruning cannot drift apart, and cleanup functions are @@ -641,9 +641,14 @@ relay payload E2E encryption is planned security work. See the trust boundary in is a compile error rather than a renderer silently talking to the wrong machine. - Remote - project usage/budget reads route through the remote runtime; local project - usage/budget reads stay on desktop usage IPC. File actions are strict once a + Remote project usage/budget reads route through the remote runtime. + Local-bound windows take usage/budget from the machine brain. A window + with no local project (Welcome, Hub, a remote-machine tab) still goes + through desktop usage IPC, which proxies those reads to a booted brain + project scope and falls back to the in-process tracker only when no brain + scope is running. Main relays the brain's usage events onto + `ade.usage.event` so those unbound windows stay live; bound windows ignore + that channel and keep the runtime event stream. File actions are strict once a local or remote runtime is bound. During a project switch, preload records a pending local binding for the target root and includes `rootPath` on local runtime action/sync/event calls so early diff --git a/docs/features/remote-runtime/internal-architecture.md b/docs/features/remote-runtime/internal-architecture.md index 13e098a2c..816de9836 100644 --- a/docs/features/remote-runtime/internal-architecture.md +++ b/docs/features/remote-runtime/internal-architecture.md @@ -45,7 +45,7 @@ A window runs one pump for its active binding (`preload.ts`) plus, for every bin Each `stream_events` response carries a per-runtime `eventEpoch` UUID minted when the daemon's `eventBuffer` is constructed. The pump compares it against the last seen epoch for its binding; if it changes (daemon restart, ssh reconnect to a fresh process) the cursor and dedup set reset and the next poll starts from `cursor=0`. Responses can also include `gap: true` with `oldestCursor` when the requested cursor is older than the bounded replay buffer; preload clears the dedupe set and notifies the project-binding refresh callbacks so renderer projections re-hydrate from authoritative reads instead of assuming no events were missed. The `startedAtMs` "drop events older than the pump start" filter is only applied to **local** bindings — remote pumps rely on the epoch reset instead, so older events backfilled after a reconnect are still delivered. -The remote event buffer categories are intentionally narrow: `orchestrator`, `dag_mutation`, `runtime`, and `pty`. Preload dispatches `runtime` events by their payload `type` so domain-specific updates such as agent chat, terminal, lane, PR, file-watch, process, test, project-state, usage, automation, conflict, GitHub, Linear, feedback, Computer Use, iOS Simulator, and App Control changes still reach their dedicated remote subscribers without expanding the wire-level category enum. ade-cli wires these source-tagged payloads into the runtime event buffer in `bootstrap.ts` so a remote-bound window sees the same event fanout as the local host. Headless runtimes start `usageTrackingService` during `createAdeRuntime()` after the ADE action registry is bound, so the usage poller and threshold events run only once the runtime can answer the matching usage/budget actions. +The remote event buffer categories are intentionally narrow: `orchestrator`, `dag_mutation`, `runtime`, and `pty`. Preload dispatches `runtime` events by their payload `type` so domain-specific updates such as agent chat, terminal, lane, PR, file-watch, process, test, project-state, usage, automation, conflict, GitHub, Linear, feedback, Computer Use, iOS Simulator, and App Control changes still reach their dedicated remote subscribers without expanding the wire-level category enum. ade-cli wires these source-tagged payloads into the runtime event buffer in `bootstrap.ts` so a remote-bound window sees the same event fanout as the local host. Headless runtimes attach the shared machine usage tracker (`attachSharedUsageTrackingScope`) during `createAdeRuntime()` after the ADE action registry is bound, so the usage poller and threshold events run only once the runtime can answer the matching usage/budget actions, and every project scope in that process shares one poller. ## SSH transport