mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 08:15:17 +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 };
|
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:
|
* 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
|
* the stalest profile whose cached row is missing, past its TTL, or past its
|
||||||
* profiles inside a breaker/cooldown window (their collector would refuse the
|
* own quota reset, skipping profiles inside a breaker/cooldown window (their
|
||||||
* fetch anyway, wasting the slot). Returns null when every profile is fresh.
|
* collector would refuse the fetch anyway, wasting the slot). Returns null
|
||||||
|
* when every profile is fresh.
|
||||||
*/
|
*/
|
||||||
function pickRotatingLiveProfile(
|
function pickRotatingLiveProfile(
|
||||||
map: Map<string, ProviderState>,
|
map: Map<string, ProviderState>,
|
||||||
@@ -820,9 +836,9 @@ function pickRotatingLiveProfile(
|
|||||||
if (state && (now < state.breakerOpenUntil || now < state.cooldownUntil)) continue;
|
if (state && (now < state.breakerOpenUntil || now < state.cooldownUntil)) continue;
|
||||||
const cachedRow = state?.cachedRow ?? null;
|
const cachedRow = state?.cachedRow ?? null;
|
||||||
const cachedAt = state?.cachedAt ?? 0;
|
const cachedAt = state?.cachedAt ?? 0;
|
||||||
if (cachedRow) {
|
if (cachedRow && state) {
|
||||||
const ttl = cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS;
|
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) {
|
if (cachedAt < pickedAt) {
|
||||||
pickedAt = cachedAt;
|
pickedAt = cachedAt;
|
||||||
@@ -864,9 +880,13 @@ async function collectClaudeRowForProfile(
|
|||||||
// Serve from cache while within TTL — force bypasses the short-circuit. Parked
|
// 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
|
// 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.
|
// 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) {
|
if (!force && state.cachedRow) {
|
||||||
const ttl = state.cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS;
|
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).
|
// 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
|
// 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
|
// 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.
|
// 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) {
|
if (!force && state.cachedRow) {
|
||||||
const ttl = state.cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS;
|
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.
|
// 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);
|
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