feat: background quota refresh for idle accounts#10
Conversation
(cherry picked from commit 99fc9fbfceacabc4e06834cf8121ded69ed8ed02)
(cherry picked from commit d10b553882d37c2996627de447d933db65517da2)
…eal API The Antigravity quota API exposes exactly two pools per account: - gemini — all Gemini models (pro, flash, every version, image, agent, AND tab_* autocomplete models) share one remainingFraction + resetTime. - non-gemini — Claude (sonnet+opus) AND gpt-oss share the other. This replaces the prior 4-key model (claude / gemini-pro / gemini-flash / gpt-oss) with those two keys throughout the stack. Core: - QuotaGroup type collapsed to 'gemini' | 'non-gemini' - ModelQuotaGroup in model-registry updated; getQuotaGroupForModel() now falls back to prefix matching (gemini*/tab_* → gemini, claude*/gpt-oss* → non-gemini) - classifyQuotaGroup / resolveQuotaGroup / aggregateQuota updated - ManagedAccount gains cachedQuotaAccountId (opaque refresh-token identity stamp) so a later projection can detect stale snapshot after index shift - updateQuotaCache accepts expectedRefreshToken for concurrent-add safety and stamps the cached quota with the identity hash OpenCode plugin: - command-data: 2-key SUPPORTED_QUOTA_KEYS / QUOTA_GROUP_LABELS; toCommandAccountRow drops cachedQuota on identity mismatch (KNOWN mismatch → drop; UNKNOWN/no-stamp → fail open); refreshQuota persists cachedQuotaAccountId; writeSidebar pushes 2 keys - Upstream 4ca327b's refresh-token-keyed identity plumbing (mutateLiveAndStorage, refresh-loop token-keyed persistence, accountIneligible guard) preserved and reconciled with the a13 identity-stamp mechanism - SidebarState: SidebarQuotaKey → 'gemini' | 'non-gemini'; normalizeAccount iterates only the 2 new keys (tolerant read: legacy multi-key snapshots ignored, not crashed) - killswitch: QUOTA_GROUP_BY_FAMILY + quotaGroupForModel mapped to 2 pools; tab_* models resolve to gemini pool - TUI: QUOTA_LABELS ('Gm' / 'NG'), QUOTA_ORDER (gemini first), collapsed row picks gemini bar first; data-model comment updated - auth-menu + quota-status formatters: 2-key layout - oauth-methods: 2-group display in 'Check quotas' output No invented windows: the API has no window field — resetTime countdowns only. Coexistence with upstream 4ca327b: Upstream rewired command-data's refresh loop and mutateLiveAndStorage to key by refresh token (canonical identity) rather than array index, for safety against concurrent OAuth adds. This rework keeps that mechanism intact and adds the a13 cachedQuotaAccountId stamp on top: identity is computed once (sha256(refreshToken)[:16]), stamped at persist time and in the live AccountManager, and checked at projection time in toCommandAccountRow. One coherent identity mechanism, not two competing ones.
Add two focused unit tests for the cachedQuotaAccountId identity-stamp
mechanism introduced in the 2-pool quota rework:
1. KNOWN mismatch: a cached quota stamped under account A's identity must
NOT render on account B — the stale snapshot is dropped and the row
shows empty quota.
2. UNKNOWN (no stamp): a legacy snapshot without any identity stamp must
still render (fail open) — the stamp was added in a later version and
its absence must not prevent a cold-start account from showing quota.
Also fix the AccountModelFamily doc comment which was wrongly updated
during the rework: it carries the ROUTING families ('claude' /
'gemini-flash' / 'gemini-pro'), not the quota-pool groups ('gemini' /
'non-gemini'). Restored to match transform/types.ts ModelFamily.
The collapsed compact row now renders both quota pools on one line, middle-dot separated, in fixed order Gm then NG: Gm: 70% · NG: 25% ● When a pool has no cached data, its slot renders an em dash rather than disappearing, so the two-pool shape stays stable: Gm: 4% · NG: — ● The single-pool usedLabel() helper is removed; poolText() handles per-pool formatting with the existing QUOTA_LABELS mapping.
- P1#1: AccountManager.loadFromDisk and buildStorageSnapshot now propagate cachedQuotaAccountId so the stamp survives a save->load roundtrip. The previous buildStorageSnapshot omission meant persisted snapshots looked legacy/unstamped after every debounced save, opening the door to cross-account misattribution when the stamp check fell back to fail-open. - P1#3: triggerAsyncQuotaRefreshForAccount now captures the refresh token BEFORE awaiting quotaManager.refreshAccount, resolves the live index for that token on resolve, and passes the captured token as expectedRefreshToken so the write cannot land on a different account that shifted into the same slot while the refresh was in flight. - P2#5: getQuotaGroupForModel, resolveQuotaGroup, classifyQuotaGroup, and quotaGroupForModel all now check Claude / GPT-OSS substrings BEFORE the gemini substring so a 'gemini-claude-*' alias (a Claude route exposed under a 'gemini-' namespace) attributes to the non-gemini pool rather than the gemini pool. - P2#6: redactAccountForSidebar carries cachedQuotaAccountId and currentQuotaAccountId; when the snapshot stamp does not match the current account identity, the cachedQuota is dropped at projection rather than rendered onto the wrong account. All live quota->sidebar writers (quota wrapper, refreshSidebar, auth-loader install) now pass both stamps. - Adds a save->loadFromDisk stamp roundtrip test, a remove-during-refresh race test, a gemini-claude classification test, three stamp-mismatch sidebar tests, and a contrasting non-gemini value in the killswitch family-max/model-scoping fixtures so a regression that ignores 'model' would no longer pass. - Regenerates src/tui-compiled/sidebar-state.ts from the source.
- P1#2: accountOAuth now accepts an onAfterPersist hook the plugin entry wires to authLoader.reload, so a successful OAuth add refreshes the live AccountManager + fetch interceptor immediately instead of waiting for the next auth reload. Reuses the auth-loader install path (load -> replaceAccountRuntime -> fetch rebuild -> sidebar publish) rather than inventing a second reload path. authLoader exposes .reload alongside the callable loader shape the host contract requires (auth.auth.loader). - P2#4: takePending now consumes the entry atomically with the take (deleted inside takePending BEFORE the async exchange starts) so two concurrent add-oauth-finish calls for the same session cannot both exchange the same auth code. The previous peek-then- finally-del pattern was the long-deferred 'Should' from a11. - P2#7: accountOAuth.authorize / .exchange now go through dependencies.oauth.* (the same seam every other sub-factory uses) rather than the concrete authorizeAntigravity / exchangeAntigravity imports, so injected overrides (e2e harness, custom hosts) reach the OAuth add path. - P2#10: account-command-oauth.finish separates the four error stages - callback parse, exchange, persist, listAccounts - so a persistence failure no longer reports as 'OAuth exchange failed due to a network error'. The post-persist listAccounts failure still reports success with an empty accounts payload (the on-disk write already landed). - P3: commands.ts now imports the shared AccountCommandOAuthService type from account-command-oauth instead of declaring its own. Adds onAfterPersist invocation tests, the concurrent finish() race test, the persistence-vs-exchange error stage tests, and asserts exchange is NOT called when no pending session exists.
- P2#8: writeSidebar now preserves each pool's q.resetAt as an ISO resetTime string so the sidebar can render a reset countdown for the gemini + non-gemini pools. The previous toFraction only carried remainingPercent, so every dialog re-render dropped the freshest reset deadline. - P2#9: mutateLiveAndStorage.remove now captures the live current account's refresh token per family BEFORE the storage mutation and resolves the post-removal storage index by token, so a non-current account removal persists the same current the live AccountManager.removeAccountByIndex preserves. The previous hardcoded activeIndex: 0 re-elected whichever account shifted into slot 0 on every restart. - Pins both fixes with new tests: the refreshed-account stamp assertion (command-data.test.ts) and the non-current-removal remap test that asserts the persisted activeIndex follows the shift instead of resetting to 0. - Regenerates src/tui-compiled/plugin/command-data.ts from source.
- accounts.test.ts 'model takes precedence over family' now uses a cross-family pair (claude family + gemini model, gemini family + claude/gpt-oss model) so a regression that drops the model-vs- family check would no longer pass. - quota-status.test.ts gains a legacy/unknown-key fixture asserting that the 'claude', 'gemini-antigravity', and 'gemini-cli' keys left over from the 3/4-key model are dropped by the collapsed two-pool projection while the supported keys still render. - command-dialogs.tsx empty OAuth code-prompt submit now toasts a validation message and reopens the prompt instead of silently dropping the user back to the account list. - command-dialogs.test.tsx asserts the OAuth URL surfaces in the Copy-URL option's description so the user can paste the mocked authorize URL into a browser. - Regenerates src/tui-compiled/tui/command-dialogs.tsx from source.
- Add QuotaWindowEntry to QuotaGroupSummary + AccountMetadataV3 cachedQuota type - Add fetchQuotaSummary() calling v1internal:retrieveUserQuotaSummary - Map RUQS groups to pools by bucketId prefix (gemini-* → gemini, 3p-* → non-gemini) - Derive pool remainingFraction/resetTime from most-constrained window - Swap primary quota fetch to RUQS; fall back to fetchAvailableModels on 403/failure - Use managedProjectId from refresh token parts as primary; fall back to projectId - Fix quota.test.ts event count for the added fallback fetch call
- AccountBlock iterates per-window entries, one QuotaRow per window - Pool prefix (Gm/NG) on first window row; indented gutter labels for rest - Gutter labels: 7d (weekly) / 5h (5h) matching the fleet's convention - Collapsed row shows pool + binding-window label + used% - SidebarQuotaEntry gains windows array (redacted) - SidebarAccountRedactionInput.cachedQuota supports windows shape - Tolerant read: pre-windows snapshots render a single QuotaRow as before
- Copy real Pro (weekly+5h) and Free (weekly-only) RUQS response dumps - aggregateQuotaSummary tests: pool mapping, window order, most-constrained derivation - fetchQuotaSummary tests: managedProjectId primary, 403→projectId fallback, missing ID - Add quotaSummaryWindow fixture to mock-antigravity-server for e2e coverage
- normalizeQuota: deserialize windows array from on-disk record (was stripping it) - QuotaRow label width: 3→5 chars to fit pool+gutter labels (Gm 7d, Gm 5h, etc.) - AccountBlock: pool prefix on every window row (not just first) - Adopt tui-windows-frames.test.tsx (reviewer's render harness, 4 frame tests) - e2e rpc-tui-flow: enqueue quotaSummaryWindow fixture instead of legacy quotaSummary
Centralize pool→sidebar projection in projectQuotaPoolForSidebar (sidebar-state.ts).
redactAccountForSidebar uses it for the canonical {remainingPercent, resetAt, windows}
conversion. toCommandAccountRow now carries windows from cachedQuota entries.
writeSidebar projects them through into the sidebar input shape.
Add 5 producer-seam tests: windowed→redacted, legacy tolerance, Free weekly-only,
full write→read round-trip, identity-mismatch drops entire cachedQuota.
Bare refresh tokens have no packed project IDs — the real managedProjectId lives on the persisted account record, not in the refresh string. Fall back to account.managedProjectId (mirroring the existing pattern at quota.ts:511). Add regression test: fetchQuotaSummary posts managedProjectId from options. e2e mock: quotaSummaryWindow fixture enforces 403 when the posted project does not match the managedProjectId — catches exactly this class of bug. rpc-tui-flow account now has distinct projectId vs managedProjectId.
…data Add standalone projectId/managedProjectId fields to ManagedAccount, populated from the authoritative stored account record during construction. These survive bare-refresh-token rotations where parts.* may be lost. getAccountsForQuotaCheck + buildStorageSnapshot now fall back to the record-level fields via a.parts.* ?? a.*. updateFromAuth keeps both parts.* and record-level fields in sync. Regression tests: getAccountsForQuotaCheck returns managedProjectId from bare-token accounts; save→reload round-trip preserves it.
P1 cortexkit#1: returned fetch captured the current runtime's interceptor by reference, so the host kept routing requests through the OLD AccountManager after an OAuth-driven reload. Replace with a call-time delegating wrapper that reads the live runtime on every call. P1 cortexkit#4: AuthLoaderHandle.load was advertised on the type but never attached to the callable. Add load so the contract matches the documented public surface. P1 cortexkit#5: installRuntime's prior-runtime dispose was fire-and-forget (void), so two overlapping reloads could interleave teardown. Await the dispose and serialize reloads through a shared promise chain so each install waits for the previous one to settle.
…moval P1 cortexkit#2: removeAccount() persisted a hardcoded 0 activeIndex when the captured current token was the one removed. The live AccountManager keeps the cursor at the removed slot, so use the clamped removed index instead of falling back to 0 — restart-time reload now re-elects the same account the live manager kept current. P2 cortexkit#3: removeAccount() collapsed both per-family active cursors onto the same captured token, silently unelecting whichever family the captured token didn't belong to. Expose getActiveIndexByFamily on the AccountManager + view, capture per-family tokens in the mutator, and persist each family's remapped index independently. P2 cortexkit#6: the sidebar refresher built dialog rows from the post-mutation command data, which does not carry the running cooldown. A toggle / remove / setCurrent action momentarily cleared any rate-limited account's cooldown. Look up the live account's coolingDownUntil by index and preserve it on the projection.
…tches P2 cortexkit#7: fetchQuotaSummary used only options.endpoints[0] for every project attempt, so a 5xx on the primary endpoint permanently lost the request. Iterate the endpoint list per project attempt so a 500 or 429 on one endpoint falls through to the next — matches the legacy fetchers' failover convention. P2 cortexkit#8: aggregateQuotaSummary derived the pool from group.buckets[0] unconditionally, silently dropping the whole group when an unrecognized bucketId led the array. Use the first RECOGNIZED bucket for pool derivation so the recognized windows are kept. P2 cortexkit#9: modelCount parsed the raw description prose and counted non-comma / non-colon tokens, so the 'Models within this group:' prefix was counted as a model. Strip the prefix before splitting. P2 cortexkit#10: the windowed summary fetch and the gemini-CLI quota fetch ran sequentially — two 10s timeouts back-to-back, ~20s per account on modal open. Run them concurrently via Promise.all.
P3 #11: the e2e mock's quotaSummaryWindow fixture dropped fixture headers before writing the body. Apply them via the shared applyHeaders seam so cache-bust and trace-header tests have a path. P3 #12: the quota-manager 'back-compat' test loaded a windows-shaped fixture rather than exercising a windows-less legacy shape through the real read path. Rewire it through a hand-rolled RetrieveUserQuotaSummaryResponse that omits per-window data. P3 #13: account-manager 'remove-during-refresh' test never attempted the quota write, so the expectedRefreshToken guard wasn't exercised. Call updateQuotaCache(0, …, refreshTokenForA) after the removal so the assertion covers the actual cross-account guard. P3 #14: the sidebar-state 'half-missing stamp' test covered only the cachedQuotaAccountId-only case. Add the symmetric currentQuotaAccountId-only case so legacy snapshots missing the persisted stamp don't silently drop quota. P3 #15: account-command-oauth failure messages said 'Please try again' even though the pending entry was consumed and the same code could not be redeemed. Update the messages to 'start a new OAuth flow' so the operator knows the OAuth session is gone. P3 #16: tighten the account-command-oauth test assertions — pin the failed-exchange response, pin the full persisted success object (not just label), assert 'Work account' renders in the add-oauth-finish dialog, and restore an explicit unknown-key legacy fixture for the renderer. The tui-compiled mirror of command-data.ts is regenerated by the build:tui gate so the bundled TUI payload matches the source.
The fences test asserts the EXACT sequence of fetch starts so a regression that bypasses the lifecycle drain guard surfaces. With the new concurrent quota path (summary + gemini-cli run in parallel), each refreshAccounts call now performs 2 fetches instead of 1, so the expected list grows by 1.
- persist 0 (not -1) when removing the current last account, matching the core's buildStorageSnapshot clamp; auth-doctor treats negative activeIndex as corruption - remove dead activeIndex() from CommandDataAccountManagerView interface and all adapters (replaced by getActiveIndexByFamily) - carry coolingDownUntil on CommandAccountRow so the sidebar refresher uses the row's own cooldown instead of matching by unstable numeric index - distinguish malformed quota-summary JSON bodies (400 INVALID_ARGUMENT) from the missing-project 403 PERMISSION_DENIED gate in the mock server
There was a problem hiding this comment.
All reported issues were addressed across 51 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
All 14 addressed in The poller defeated the stale-quota guard. It wrote Transport failures were being reported as "no CLI quota available." Chasing the CLI-error finding turned up a blank Also fixed: the legacy-key blank (pre-upgrade Refresh-on-open now respects backoff (the explicit Refresh button keeps forcing, deliberately). Worth flagging on the tests: four of the first-round fixes shipped with tests that passed when their fix was reverted, including an e2e that passed with the poller disabled. Those are rewritten, and every new or modified test in this range was reverse-verified — revert the fix, confirm RED, restore. 1924 unit / 43 e2e. |
There was a problem hiding this comment.
All reported issues were addressed across 20 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
There was a problem hiding this comment.
All reported issues were addressed across 5 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
There was a problem hiding this comment.
All reported issues were addressed across 10 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
There was a problem hiding this comment.
All reported issues were addressed across 16 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
7b24de5 to
edf4a6b
Compare
There was a problem hiding this comment.
All reported issues were addressed across 9 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
78d4fa8 to
e9b8dc8
Compare
Antigravity inference responses carry no quota headers, so quota is poll-only: today it refreshes only when a request finds a stale cache or when the quota dialog is opened. An account left idle keeps whatever numbers it had when it was last used, indefinitely. Adds a 5-minute jittered background refresh, bounded in three layers: the sidebar 'checkedAt' freshness gate (the cross-process bound -- N processes on one machine converge on ~one poll per interval), an advisory fenced file lock (an optimization, not the correctness mechanism), and fail-closed skip when the lock throws. There is deliberately no fallback mutex: a fallback for a failed lock is a second, worse lock. The poll reuses the existing refresh path end to end and publishes one sidebar snapshot per tick, with identity stamps and the live active-account flag intact. Also in this range, from review: - transport failures inside fetchGeminiCliQuota were swallowed by a blank catch and reported as a permanent-looking 'no CLI quota available'; they now propagate so a socket hang is distinguishable from an unconfigured CLI (a 403 still returns empty buckets) - pre-upgrade cachedQuota keys are normalized at read time -- killswitch and soft-quota read the same map, so a configured threshold sat inert until the first refresh - quota windows render shortest-first, sorted by duration - the sidebar publishes the real health score and active-account flag instead of constants - the quota dialog persists what it refreshes instead of discarding it - captured plan tier is published for external readers of the state file
e9b8dc8 to
5fddd9e
Compare
Stacked on #8 — the diff below includes #8's commits until it merges, at which point this narrows to the three commits
a18055c,91e6e4a,9aa16ec. Review #8 first.Why
Antigravity responses carry no quota headers — I dumped every response header on a live
streamGenerateContentand there is nothing likex-codex-*oranthropic-ratelimit-*to piggyback on. Quota is poll-only, and today it is only polled when a request finds a stale cache or when someone opens/antigravity-quota. An account you are not actively using therefore shows bars from whenever you last used it, indefinitely.This adds a 5-minute jittered background refresh so idle accounts stay current.
Design
Three layers, in order:
Freshness gate — read the sidebar's
checkedAtand skip if it is younger thanmax(intervalMs − 60s, intervalMs / 2). This is the only hard cross-process bound: N opencode processes on one machine converge on roughly one poll per interval regardless of the lock. The/2floor matters at the 1-minute minimum interval, where the naiveinterval − 60scollapses to zero and disables dedup entirely.Fenced lock —
acquireFencedFileLock, held elsewhere means skip. An optimization, not the correctness mechanism.Lock throws → fail closed. Skip the tick, jitter the next. There is deliberately no fallback mutex: a fallback for "the lock failed" is a second, worse lock, and it cannot be made single-winner without a fencing token — which is what
acquireFencedFileLockalready is. An earlier attempt at this feature hardened exactly such a marker through three review rounds before the design was cut; the invariant comment in the module says so, to stop the next reader re-deriving it.The poll reuses the existing path end to end —
getAccountsForQuotaCheck→quotaManager.refreshAccounts→updateQuotaCache(identity stamps intact) → onepushSidebarQuotaSnapshotper tick, after the cache update. No new fetch or write surface, and per-window data lands exactly as it does through the modal.One instance per plugin,
unref()'d timer, asyncdispose()that awaits the in-flight tick, registered in the producer phase so it stops before the sidebar drain.Config
{ "background_quota_refresh": true, // default on "background_quota_refresh_interval_minutes": 5 // 1–60 }The e2e harness disables it in quick mode so tests stay deterministic.
Verification
18 tests in the module, including the ones that would otherwise be theatre:
mkdir,acquireFencedFileLockthrows, and the tick skips. A second poller on a valid path proves the timer survives.lifecycle.register(...)line is removed. Verified by removing it.Live, on an idle host:
checkedAtclimbed to 3.9 minutes, reset on its own, climbed again — one autonomous refresh 4.3 minutes after the previous, no request sent and no dialog opened, with windows intact in the written snapshot.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Summary by cubic
Adds a jittered background quota refresh for idle accounts and migrates quota to a windowed two‑pool model (gemini / non‑gemini) with per‑window UI. Also wires the account‑dialog OAuth flow end to end with immediate runtime reload and quota safety via identity stamps.
New Features
background_quota_refreshandbackground_quota_refresh_interval_minutes.retrieveUserQuotaSummary(weekly/5h): derive most‑constrained per‑pool remaining/reset; tolerant bucket parsing; endpoint failover; parallel RUQS+CLI fetches; honormanagedProjectId403 gate with fallback toprojectId.gemini-claude-*andgpt-oss*map to non‑gemini;tab_*and all Gemini IDs map to gemini; TUI renders per‑window rows and shows both pools in the collapsed row; publish captured plan tier{id, paidId?, capturedAt}.Bug Fixes
expectedRefreshTokenguard;cachedQuotaAccountIdstamp persisted and checked; mismatched stamps drop cached quota to prevent cross‑account attribution.closeDebugLog()flushes logs deterministically; preserve reset times and per‑family active indices/cooldowns through removals; publish real active‑account flag + health.managedProjectIdsourced from the stored account record.Written for commit 5fddd9e. Summary will update on new commits.