diff --git a/src/web-server/usage/native-quota-collector.ts b/src/web-server/usage/native-quota-collector.ts index 58fe41cd..308cc939 100644 --- a/src/web-server/usage/native-quota-collector.ts +++ b/src/web-server/usage/native-quota-collector.ts @@ -800,11 +800,27 @@ function markPausedAndSyncCache( return { ...row, paused: true }; } +/** + * True when the cached row was fetched BEFORE its own next_reset boundary and + * that boundary has now passed — the row describes the previous quota window, + * so its values (and the reset time itself) are visibly wrong in the bar. + * The cachedAt guard means a post-reset payload that still reports a past + * reset cannot cause a refetch loop: once re-fetched, normal TTL applies. + */ +function isCachedRowStaleByReset(state: ProviderState, now: number): boolean { + const nextReset = state.cachedRow?.next_reset; + if (!nextReset) return false; + const resetMs = Date.parse(nextReset); + if (!Number.isFinite(resetMs)) return false; + return resetMs <= now && state.cachedAt < resetMs; +} + /** * Pick the non-default profile the rotating live slot should refresh this pass: - * the stalest profile whose cached row is missing or past its TTL, skipping - * profiles inside a breaker/cooldown window (their collector would refuse the - * fetch anyway, wasting the slot). Returns null when every profile is fresh. + * the stalest profile whose cached row is missing, past its TTL, or past its + * own quota reset, skipping profiles inside a breaker/cooldown window (their + * collector would refuse the fetch anyway, wasting the slot). Returns null + * when every profile is fresh. */ function pickRotatingLiveProfile( map: Map, @@ -820,9 +836,9 @@ function pickRotatingLiveProfile( if (state && (now < state.breakerOpenUntil || now < state.cooldownUntil)) continue; const cachedRow = state?.cachedRow ?? null; const cachedAt = state?.cachedAt ?? 0; - if (cachedRow) { + if (cachedRow && state) { const ttl = cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS; - if (now - cachedAt < ttl) continue; + if (now - cachedAt < ttl && !isCachedRowStaleByReset(state, now)) continue; } if (cachedAt < pickedAt) { pickedAt = cachedAt; @@ -864,9 +880,13 @@ async function collectClaudeRowForProfile( // Serve from cache while within TTL — force bypasses the short-circuit. Parked // rows (no creds -> quotaStatus 'unsupported') use a short TTL so a fresh login // is picked up within seconds instead of staying dimmed for the full quota TTL. + // A row whose own next_reset has passed is stale regardless of TTL — the + // quota snapped back at the boundary and the cached values are visibly wrong. if (!force && state.cachedRow) { const ttl = state.cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS; - if (now - state.cachedAt < ttl) return serveCached(state); + if (now - state.cachedAt < ttl && !isCachedRowStaleByReset(state, now)) { + return serveCached(state); + } } // Breaker open or cooldown active -> zero network, serve stale (may be null). @@ -1012,9 +1032,12 @@ async function collectCodexRowForProfile( // Serve from cache while within TTL — force bypasses the short-circuit. Parked // rows (no auth -> quotaStatus 'unsupported') use a short TTL so a fresh login // is picked up within seconds instead of staying dimmed for the full quota TTL. + // A row whose own next_reset has passed is stale regardless of TTL. if (!force && state.cachedRow) { const ttl = state.cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS; - if (now - state.cachedAt < ttl) return serveCached(state); + if (now - state.cachedAt < ttl && !isCachedRowStaleByReset(state, now)) { + return serveCached(state); + } } // Breaker open or cooldown active -> skip network, go to LOCAL fallback. diff --git a/tests/unit/web-server/native-quota-collector.test.ts b/tests/unit/web-server/native-quota-collector.test.ts index 42feb053..193c52bb 100644 --- a/tests/unit/web-server/native-quota-collector.test.ts +++ b/tests/unit/web-server/native-quota-collector.test.ts @@ -1827,3 +1827,128 @@ describe('multi-profile: rotating live slot for non-default profiles', () => { expect(rows.find((r) => r.profile === 'personal')?.needsReauth).toBe(true); }); }); + +// ============================================================================ +// Quota reset invalidates cached rows +// +// A cached row whose next_reset has passed no longer describes the current +// window — the quota snapped back at the reset boundary. Serving it for the +// rest of the 10-min TTL makes the bar visibly wrong right after a reset, so +// a passed reset marks the row stale (guarded: only when the row was fetched +// BEFORE the reset, so a post-reset payload that still reports a past reset +// cannot cause a refetch loop). +// ============================================================================ + +describe('multi-profile: quota reset invalidates cached rows', () => { + function quotaResettingAt(resetIso: string): ClaudeQuotaResult { + const base = successQuota(); + return { + ...base, + coreUsage: { + fiveHour: { ...base.coreUsage!.fiveHour!, resetAt: resetIso }, + weekly: base.coreUsage!.weekly, + }, + }; + } + + it('re-fetches the default Claude profile once its next_reset passes, before TTL expiry', async () => { + const clock = { now: Date.parse('2026-06-09T10:00:00.000Z') }; + const resetIso = '2026-06-09T10:01:00.000Z'; // 60s ahead + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async () => quotaResettingAt(resetIso), + }); + + await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(1); + + // 90s later: past the reset but far inside the 10-min TTL. + clock.now += 90_000; + await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(2); + }); + + it('does not refetch-loop when a post-reset payload still reports a past reset', async () => { + const clock = { now: Date.parse('2026-06-09T10:00:00.000Z') }; + const resetIso = '2026-06-09T10:01:00.000Z'; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async () => quotaResettingAt(resetIso), + }); + + await getNativeAccountRows(deps); + clock.now += 90_000; + await getNativeAccountRows(deps); // refetch fires; payload STILL says 10:01 + expect(deps.claudeFetchCount()).toBe(2); + + // Another pass within TTL: the row was fetched after the reset passed, so + // the stale-by-reset rule must not apply again. + clock.now += 30_000; + await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(2); + }); + + it('rotating slot treats a non-default profile with a passed reset as stale', async () => { + const clock = { now: Date.parse('2026-06-09T10:00:00.000Z') }; + const resetIso = '2026-06-09T10:01:00.000Z'; + const farFuture = '2026-06-16T00:00:00.000Z'; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'personal'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async (_t, accountId) => + quotaResettingAt(accountId === 'ccs:personal' ? resetIso : farFuture), + }); + + // Pass 1: work (default) + personal (rotating slot, never fetched). + await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(2); + + // 90s later: personal's reset passed; work is fresh. The rotating slot + // must pick personal again even though its row is inside the 10-min TTL. + clock.now += 90_000; + await getNativeAccountRows(deps); + expect(deps.claudeFetchCount()).toBe(3); + }); + + it('Codex rows honour the same reset-staleness rule', async () => { + const clock = { now: Date.parse('2026-06-09T10:00:00.000Z') }; + const codexQuota: CodexQuotaResult = { + ...codexSuccessQuota(), + coreUsage: { + fiveHour: { + label: 'Primary', + remainingPercent: 60, + resetAfterSeconds: 60, + resetAt: '2026-06-09T10:01:00.000Z', + }, + weekly: codexSuccessQuota().coreUsage!.weekly, + }, + }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: [], + codexProfiles: ['personal'], + codexDefault: 'personal', + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + codexNetworkFetch: async () => codexQuota, + }); + + await getNativeAccountRows(deps); + expect(deps.codexNetworkCount()).toBe(1); + + clock.now += 90_000; + await getNativeAccountRows(deps); + expect(deps.codexNetworkCount()).toBe(2); + }); +});