From aa35428dde933f2825ad221e59df1bc99894368f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 01:54:05 +0000 Subject: [PATCH] usage limits are read live, and a cached reading belongs to one account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `limitsCache` was keyed on nothing. Switching account changes which credential `readOauthLogin` returns, and the cached entry was served anyway — so the panel showed the previous account's percentages under the new account's name for a minute, and on a failed probe for as long as the probe kept failing. Reported maxed out on one account, switched, and the numbers did not move. So the limits have no TTL now: every request probes. They are per-account and they are the number somebody opens the panel to read, so a stale one is worse than a slow one. The store stays, for the failure path only, and keyed on the account it was measured for. A flaky request degrades to that account's own last reading rather than to a blank panel or — much worse — to somebody else's numbers. A reading that cannot be attributed gets no fallback at all rather than a shared one. The client's five-minute stale window went with it: that query is only the instant first paint, and holding it meant painting the old account's stored row until the refresh landed. The transcript scan keeps its cache. It reads ~90MB and its answer is the same whichever account is signed in — it counts what this machine wrote, not what an account is allowed — so a switch does not invalidate it. Co-Authored-By: Claude Opus 5 --- src/servers/api/chat/claude-usage.ts | 46 +++++++++++++------ .../src/apps/Chat/useClaudeUsage.ts | 8 ++-- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/servers/api/chat/claude-usage.ts b/src/servers/api/chat/claude-usage.ts index f0421d47..3f94b931 100644 --- a/src/servers/api/chat/claude-usage.ts +++ b/src/servers/api/chat/claude-usage.ts @@ -78,11 +78,15 @@ const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage'; /** * How long a reading is reused. * - * Both halves are expensive in different ways — the endpoint rate-limits, and the scan reads ~90MB of JSONL — - * and neither changes meaningfully inside a minute. Cached SEPARATELY so a network failure does not throw - * away a good scan, and so opening and shutting the popover is not a request per flick. + * The transcript scan reads ~90MB of JSONL and its answer does not change meaningfully inside a minute, so + * it is still served from cache. It is also the same answer whichever account is signed in — it counts what + * this machine wrote, not what an account is allowed — which is why switching account does not need to + * invalidate it. + * + * **The limits have no TTL and are never served from cache.** They are per-account and they are the number + * somebody opens this panel to read, so a stale one is worse than a slow one. The store is kept for the + * failure path only — see `lastGoodLimits`. */ -const LIMITS_TTL_MS = 60_000; const SCAN_TTL_MS = 60_000; const claudeDir = (): string => process.env.CLAUDE_CONFIG_DIR?.trim() || join(getOwnerHomeDir(''), '.claude'); @@ -142,7 +146,21 @@ export async function readAccountEmail(): Promise { type LimitsResult = { limits: UsageLimit[]; problem?: string }; -let limitsCache: { at: number; result: LimitsResult } | null = null; +/* + Last good reading PER ACCOUNT, and used only when a probe fails. + + This was a TTL cache keyed on nothing, and the consequence was the whole feature lying. Switching account + changes which credential `readOauthLogin` returns, but a cached entry was served regardless — so the panel + showed the previous account's percentages under the new account's name for the length of the TTL, and the + failure fallback served them for as long as the probe kept failing. Reported by the owner: maxed out on one + account, switched, and the numbers did not move. + + So there is no TTL any more. Every request probes, because these percentages are the entire point of the + panel and a stale one is worse than a slow one. What survives is the fallback, now keyed on the account it + was measured for, so a flaky request degrades to that account's own last reading rather than to a blank + panel or — far worse — to somebody else's numbers. +*/ +const lastGoodLimits = new Map(); const asPercent = (value: unknown): number => { const n = Number( @@ -303,20 +321,22 @@ async function collectLimits(): Promise windowStillOpen(l, now)) ?? []; - return { limits: stale, plan: login.plan, measuredAt: limitsCache?.at ?? now, problem: fresh.problem }; + // A failed probe falls back to THIS account's last good reading rather than to nothing — minutes old at + // worst, and a panel that blanks on one flaky request is worse than one slightly behind. Windows that have + // since reset are dropped: a stale 78% would misreport an allowance that is now untouched. + const previous = account ? lastGoodLimits.get(account) : undefined; + const stale = previous?.result.limits.filter((l) => windowStillOpen(l, now)) ?? []; + return { limits: stale, plan: login.plan, measuredAt: previous?.at ?? now, problem: fresh.problem }; } // ── Local transcript scan ── diff --git a/src/workspaces/officerdev/src/apps/Chat/useClaudeUsage.ts b/src/workspaces/officerdev/src/apps/Chat/useClaudeUsage.ts index c96f9385..cb1a72ce 100644 --- a/src/workspaces/officerdev/src/apps/Chat/useClaudeUsage.ts +++ b/src/workspaces/officerdev/src/apps/Chat/useClaudeUsage.ts @@ -51,9 +51,11 @@ export function useClaudeUsage(enabled: boolean) { queryKey: KEY, queryFn: () => client.get('/chat/usage'), enabled, - // The stored rows only change when a turn ends, and a turn ending does not happen while you are reading - // this panel. The refresh below is what keeps it honest, not a stale timer. - staleTime: 5 * 60_000, + // No stale window. This query is only the instant first paint — the refresh below is the real answer + // — and a five-minute one meant that after switching account the panel painted the OLD account's + // stored row and held it until the refresh landed, which is exactly the moment somebody decides the + // numbers are wrong. + staleTime: 0, refetchOnWindowFocus: false, retry: false, });