Skip to content

feat: background quota refresh for idle accounts#10

Open
iceteaSA wants to merge 25 commits into
cortexkit:mainfrom
iceteaSA:feat/bg-quota-poll-v2
Open

feat: background quota refresh for idle accounts#10
iceteaSA wants to merge 25 commits into
cortexkit:mainfrom
iceteaSA:feat/bg-quota-poll-v2

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

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 streamGenerateContent and there is nothing like x-codex-* or anthropic-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 checkedAt and skip if it is younger than max(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 /2 floor matters at the 1-minute minimum interval, where the naive interval − 60s collapses to zero and disables dedup entirely.

Fenced lockacquireFencedFileLock, 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 acquireFencedFileLock already 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 — getAccountsForQuotaCheckquotaManager.refreshAccountsupdateQuotaCache (identity stamps intact) → one pushSidebarQuotaSnapshot per 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, async dispose() 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:

  • The fail-closed test throws the lock for real, via a NUL byte in the lock path — the kernel rejects the mkdir, acquireFencedFileLock throws, and the tick skips. A second poller on a valid path proves the timer survives.
  • The wiring tests fail when the lifecycle.register(...) line is removed. Verified by removing it.
  • The re-entry guard test fails when the guard is commented out. Verified by commenting it out.

Live, on an idle host: checkedAt climbed 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.

unit           1909 pass, 0 fail
e2e            42 pass, 0 fail, zero leaked temp roots
typecheck      clean
format + lint  clean, 0 warnings
smoke          packed-tarball TUI install resolves

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with 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 refresh with freshness gate + fenced lock; one sidebar snapshot per tick; configurable via background_quota_refresh and background_quota_refresh_interval_minutes.
    • Windowed quota via retrieveUserQuotaSummary (weekly/5h): derive most‑constrained per‑pool remaining/reset; tolerant bucket parsing; endpoint failover; parallel RUQS+CLI fetches; honor managedProjectId 403 gate with fallback to projectId.
    • Two‑pool schema (gemini / non‑gemini) across core, CLI, plugin, and TUI: normalize legacy keys; gemini-claude-* and gpt-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}.
    • Account dialog OAuth: URL → code → optional label; atomic pending consumption; persists and reloads the live runtime; refreshes sidebar on open; clearer error messages.
  • Bug Fixes

    • Quota correctness: writes keyed by refresh token with expectedRefreshToken guard; cachedQuotaAccountId stamp persisted and checked; mismatched stamps drop cached quota to prevent cross‑account attribution.
    • Loader/runtime and UI: fetch delegates to the live runtime; serialized reloads; CLI transport errors surfaced; closeDebugLog() flushes logs deterministically; preserve reset times and per‑family active indices/cooldowns through removals; publish real active‑account flag + health.
    • Back‑compat and resilience: normalize legacy quota keys for soft‑quota/rotation; tolerant RUQS parsing and modelCount; endpoint failover on 5xx/429; project managedProjectId sourced from the stored account record.

Written for commit 5fddd9e. Summary will update on new commits.

Review in cubic

iceteaSA and others added 24 commits July 24, 2026 16:25
(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

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 51 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/core/src/quota-manager.ts
Comment thread packages/opencode/src/plugin/quota.ts
Comment thread packages/opencode/src/tui-compiled/sidebar-state.ts
Comment thread packages/core/src/quota-types.ts
Comment thread packages/opencode/src/tui.tsx Outdated
Comment thread packages/opencode/src/plugin/commands.ts
Comment thread packages/opencode/src/plugin/background-quota-refresh.ts Outdated
Comment thread packages/opencode/src/plugin/background-quota-refresh.ts
Comment thread packages/opencode/src/plugin/background-quota-refresh.ts Outdated
Comment thread packages/opencode/src/plugin/command-data.ts Outdated
@iceteaSA

Copy link
Copy Markdown
Contributor Author

All 14 addressed in 9aa16ec..f020a3c. The two that turned out to be real bugs rather than polish:

The poller defeated the stale-quota guard. It wrote cachedQuotaAccountId into both identity fields on every snapshot, so redactAccountForSidebar's cachedQuotaAccountId !== currentQuotaAccountId comparison could never be true. The guard exists to stop one account's quota rendering on another after a reorder, and for poller-written snapshots it was structurally dead. Now derived from the live refresh token, matching the two correct call sites.

Transport failures were being reported as "no CLI quota available." Chasing the CLI-error finding turned up a blank catch {} per endpoint inside fetchGeminiCliQuota, so socket hangs, DNS failures and timeouts all surfaced as a permanent-looking "not configured" status. It now collects per-endpoint errors and throws when every endpoint fails a transport-level attempt; a 403 still returns empty buckets, because that one is authoritative. The single caller already had a .catch(), and a CLI failure still doesn't discard the summary result.

Also fixed: the legacy-key blank (pre-upgrade cachedQuota keys weren't just invisible — killswitch and soft-quota read the same map, so a configured threshold sat inert until the first refresh; now normalized at read time, with the writer emitting canonical keys); per-window rows surviving an apply; the managed-project fallback firing only on 403; the poller using the injected transport; a runTick throw no longer stopping polling for the process lifetime; collapsed-row tone from the worst pool; resolveNextIndex using the storage-snapshot position; guarded listAccounts() on OAuth failure paths; and a resetTime merge that dropped the earlier reset when the first pool won.

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 20 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/opencode/src/plugin/command-data.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread packages/opencode/src/plugin/commands.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread packages/opencode/src/plugin/quota.ts
Comment thread packages/opencode/src/plugin/lifecycle.test.ts
Comment thread packages/opencode/src/sidebar-state.test.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread packages/core/src/project.ts Outdated
Comment thread packages/opencode/src/plugin/index.ts Outdated
Comment thread packages/opencode/src/plugin/command-data.ts
Comment thread packages/core/src/account-manager.ts Outdated
Comment thread packages/opencode/src/plugin/background-quota-refresh.ts Outdated
@iceteaSA
iceteaSA force-pushed the feat/bg-quota-poll-v2 branch from 7b24de5 to edf4a6b Compare July 25, 2026 20:04

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread test/environment.test.ts Outdated
@iceteaSA
iceteaSA force-pushed the feat/bg-quota-poll-v2 branch 4 times, most recently from 78d4fa8 to e9b8dc8 Compare July 26, 2026 07:23
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
@iceteaSA
iceteaSA force-pushed the feat/bg-quota-poll-v2 branch from e9b8dc8 to 5fddd9e Compare July 26, 2026 08:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant