fix(bar): treat cached quota rows as stale once their reset passes

A cached row whose next_reset has passed describes the previous quota
window — the quota snapped back at the boundary, so serving it for the rest
of the 10-min TTL shows wrong percentages and an already-elapsed reset time
in the bar. Both the per-profile TTL short-circuit and the rotating-slot
eligibility now mark such rows stale. Guarded by cachedAt < resetAt so a
post-reset payload that still reports a past reset cannot refetch-loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
poomscandClaude Fable 5 committed 2026-08-08 14:43:52 +07:00
1 parent a5a5b742e4
commit 833473c5f6
2 files changed
+155 -7

No files matched your search

+30 -7
View File
@@ -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<string, ProviderState>,
@@ -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.
@@ -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);
});
});