mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 20:13:02 +00:00
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:
1 parent
a5a5b742e4
commit
833473c5f6
2 files changed
+155
-7
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user