From 6017bc99b427b7c7e15095085844f24bfb81ed56 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:44:55 -0700 Subject: [PATCH] fix(rate-limits): cool down usage reads that open agy's browser sign-in (#33297) Antigravity usage: when agy's read times out or fails while showing its interactive browser sign-in prompt, Orca marks it signed out and skips automated re-reads for 1h (manual refresh still bypasses), instead of opening a new sign-in page on every poll. A valid reading always wins; fast-exit signed-out reads poll as before. Fixes #26484. --- .../rate-limits/antigravity-usage-command.ts | 3 ++ .../rate-limits/antigravity-usage-error.ts | 5 +++ .../antigravity-usage-fetcher.test.ts | 39 ++++++++++++++++ .../rate-limits/antigravity-usage-fetcher.ts | 29 +++++++++++- .../service-antigravity-usage.test.ts | 44 ++++++++++++++++++- .../service/service-fetch-policy.ts | 9 +++- .../service/service-full-cycle-preparation.ts | 10 +++-- .../rate-limits/service/service-polling.ts | 2 +- .../service/service-provider-cycles.ts | 2 +- 9 files changed, 132 insertions(+), 11 deletions(-) diff --git a/src/main/rate-limits/antigravity-usage-command.ts b/src/main/rate-limits/antigravity-usage-command.ts index 039c387d79f..5694d149182 100644 --- a/src/main/rate-limits/antigravity-usage-command.ts +++ b/src/main/rate-limits/antigravity-usage-command.ts @@ -54,6 +54,9 @@ export const ANTIGRAVITY_VERSION_ARGS: readonly string[] = ['--version'] /** Why bound it: the probe runs before every quota read, so a wedged CLI must not stall the cycle. */ export const ANTIGRAVITY_VERSION_TIMEOUT_MS = 5_000 +/** Why: each read that reaches agy's interactive sign-in opens a browser page (#26484). */ +export const ANTIGRAVITY_SIGN_IN_PROMPT_COOLDOWN_MS = 60 * 60 * 1000 + /** Cap on captured output; the envelope is a single JSON line well under a kilobyte. */ export const ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES = 512 * 1024 diff --git a/src/main/rate-limits/antigravity-usage-error.ts b/src/main/rate-limits/antigravity-usage-error.ts index 456527c2d11..eb0caebdfde 100644 --- a/src/main/rate-limits/antigravity-usage-error.ts +++ b/src/main/rate-limits/antigravity-usage-error.ts @@ -81,3 +81,8 @@ export function classifyAntigravityUsageFailure(output: string): AntigravityUsag } return null } + +// agy 1.4.2 prints this after opening a browser OAuth page, then waits up to 60 s for it. +export function stderrShowsInteractiveSignIn(stderr: string): boolean { + return stderr.includes('Authentication required. Please visit the URL') +} diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts index 82995461e99..4a4cc9f1aac 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.test.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -224,6 +224,45 @@ describe('fetchAntigravityRateLimits', () => { expect(result.error).toContain('did not answer in time') }) + it('cools down a read that reached agy\u2019s browser sign-in', async () => { + // Why pinned: agy 1.4.2 opens this OAuth page and waits 60 s, so Orca's 30 s kill lands first (#26484). + const result = await harness({ + result: processResult({ + timedOut: true, + stderr: + 'Authentication required. Please visit the URL to log in:\n https://accounts.google.com/o/oauth2/auth?...\n\nWaiting for authentication (timeout 60s)...\n' + }) + }).fetch() + + expect(result.status).toBe('unavailable') + expect(result.usageMetadata?.failureKind).toBe('missing-credentials') + expect(result.usageMetadata?.retryAtMs).toBe(1_700_000_000_000 + 60 * 60 * 1000) + }) + + it('keeps the reading when the browser sign-in completes within the read', async () => { + const result = await harness({ + result: processResult({ + stdout: USAGE_ENVELOPE, + stderr: 'Authentication required. Please visit the URL to log in:\n' + }) + }).fetch() + + expect(result.status).toBe('ok') + expect(result.usageMetadata?.retryAtMs).toBeUndefined() + }) + + it('does not cool down a signed-out read that fails fast without a browser', async () => { + const result = await harness({ + result: processResult({ + code: 1, + stderr: "Error: authentication required. Run 'agy' to log in, then retry.\n" + }) + }).fetch() + + expect(result.usageMetadata?.failureKind).toBe('missing-credentials') + expect(result.usageMetadata?.retryAtMs).toBeUndefined() + }) + it('reports an unreadable payload as a parse failure carrying the exit code', async () => { const result = await harness({ result: processResult({ code: 2, stdout: 'unknown command /usage' }) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index 3719c95cc41..ba2ce732480 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -6,6 +6,7 @@ import type { ProviderRateLimits, UsageRateLimitFailureKind } from '../../shared import { ANTIGRAVITY_MIN_USAGE_VERSION, ANTIGRAVITY_PROBE_ENV, + ANTIGRAVITY_SIGN_IN_PROMPT_COOLDOWN_MS, ANTIGRAVITY_USAGE_ARGS, ANTIGRAVITY_USAGE_MAX_OUTPUT_BYTES, ANTIGRAVITY_USAGE_TIMEOUT_MS, @@ -15,7 +16,10 @@ import { } from './antigravity-usage-command' import { parseAntigravityUsageStdout, stdoutShowsModelTurn } from './antigravity-usage-response' -import { classifyAntigravityUsageFailure } from './antigravity-usage-error' +import { + classifyAntigravityUsageFailure, + stderrShowsInteractiveSignIn +} from './antigravity-usage-error' const FAILURE_REASONS: Partial> = { 'rate-limited': @@ -174,6 +178,28 @@ export async function fetchAntigravityRateLimits( return failed(spawnFailureMessage(error), 'cli-unavailable', now()) } + const reading = parseAntigravityUsageStdout(result.stdout) + // Why: a completed browser sign-in still yields a reading; only a failed read cools down. + if ( + !reading && + (result.timedOut || result.code !== 0) && + stderrShowsInteractiveSignIn(result.stderr) + ) { + const at = now() + return { + ...unavailable( + 'Antigravity usage is not available. Sign in with `agy`; Orca checks again within an hour, or when you refresh.', + 'missing-credentials', + at + ), + usageMetadata: { + source: 'cli', + attemptedSources: ['cli'], + failureKind: 'missing-credentials', + retryAtMs: at + ANTIGRAVITY_SIGN_IN_PROMPT_COOLDOWN_MS + } + } + } if (result.timedOut) { return failed( 'Antigravity usage is not available. The Antigravity CLI did not answer in time.', @@ -182,7 +208,6 @@ export async function fetchAntigravityRateLimits( ) } - const reading = parseAntigravityUsageStdout(result.stdout) // Why the successful read is checked first: a real reading can never be evidence of a prompt, so // ordering it ahead of the turn check makes a false latch impossible. if (!reading && stdoutShowsModelTurn(result.stdout)) { diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index 8e65e53740b..71fea306bb0 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -5,9 +5,13 @@ import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { + asRateLimitWindow, errorProvider, + FakeRateLimitWindow, + mockFreshBackgroundProviderFetches, okProvider, - resetRateLimitProviderMocks + resetRateLimitProviderMocks, + unavailableProvider } from './rate-limit-service-test-harness' vi.mock('./claude-fetcher', () => ({ @@ -195,4 +199,42 @@ describe('Antigravity usage gating', () => { expect(service.getState().antigravity?.session?.usedPercent).toBe(44) expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) }) + + it('waits out the browser sign-in cooldown, then recovers on the next poll', async () => { + vi.useFakeTimers() + try { + mockFreshBackgroundProviderFetches() + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) + vi.mocked(fetchAntigravityRateLimits) + .mockImplementationOnce(async () => ({ + ...unavailableProvider('antigravity', 'Sign in with `agy`'), + usageMetadata: { + failureKind: 'missing-credentials', + retryAtMs: Date.now() + 60 * 60 * 1000 + } + })) + .mockImplementation(async () => okProvider('antigravity', 30)) + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) + + // Why: each read inside the cooldown would open another browser sign-in page (#26484). + window.emit('focus') + await vi.advanceTimersByTimeAsync(58 * 60 * 1000) + expect(vi.mocked(fetchCodexRateLimits).mock.calls.length).toBeGreaterThan(1) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(32 * 60 * 1000) + expect(vi.mocked(fetchAntigravityRateLimits).mock.calls.length).toBeGreaterThan(1) + expect(service.getState().antigravity?.status).toBe('ok') + + service.stop() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/rate-limits/service/service-fetch-policy.ts b/src/main/rate-limits/service/service-fetch-policy.ts index d0d482258fa..2201ccf933e 100644 --- a/src/main/rate-limits/service/service-fetch-policy.ts +++ b/src/main/rate-limits/service/service-fetch-policy.ts @@ -4,6 +4,7 @@ import { MIN_REFETCH_MS, normalizeClaudeConfigDir, type ClaudeRuntimeAuthPreparation, + type ActiveRateLimitProvider, type ClaudeStatusLineRateLimits, type NormalizedClaudeAccountSelectionTarget, type ProviderRateLimits @@ -58,8 +59,12 @@ export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchT ) } - protected shouldSkipAutomatedClaudeFetch(limits: ProviderRateLimits | null): boolean { - return this.isRetryAfterActive(limits) + // Why: Claude's Retry-After and agy's browser sign-in cooldown bind automated checks; user refreshes may pass. + protected shouldSkipAutomatedFetch( + provider: ActiveRateLimitProvider, + limits: ProviderRateLimits | null + ): boolean { + return (provider === 'claude' || provider === 'antigravity') && this.isRetryAfterActive(limits) } protected resolveClaudeFetchApply( diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 233782ba2dd..f1cb6cb7c56 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -123,7 +123,9 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ } const miniMaxGeneration = this.minimaxFetchGeneration - const antigravityUsageEnabled = this.antigravityUsageEnabledResolver?.() ?? true + const antigravityFetchAllowed = + (this.antigravityUsageEnabledResolver?.() ?? true) && + (options?.force || !this.shouldSkipAutomatedFetch('antigravity', previousState.antigravity)) const zcodePlanConfigResult = this.resolveZcodePlanConfig() const zcodePlanApiKey = zcodePlanConfigResult.config.apiKey @@ -157,7 +159,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ ? this.withFetchingStatus(null, 'opencode-go') : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), - antigravity: antigravityUsageEnabled + antigravity: antigravityFetchAllowed ? this.withFetchingStatus(previousState.antigravity, 'antigravity') : (previousState.antigravity ?? antigravityUsageDisabledSnapshot()), minimax: miniMaxConfigChanged @@ -193,7 +195,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ // Hidden meters avoid the CLI spawn; the separate promise keeps other providers responsive. const antigravityResultPromise = ( - antigravityUsageEnabled + antigravityFetchAllowed ? fetchAntigravityRateLimits({ signal }) : Promise.resolve(previousState.antigravity ?? antigravityUsageDisabledSnapshot()) ).then( @@ -213,7 +215,7 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ // Why: automated checks must honor the endpoint's Retry-After even when live display is available. const claudeFetchGated = - !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) + !options?.force && this.shouldSkipAutomatedFetch('claude', previousState.claude) const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = await Promise.allSettled([ diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts index 2b30a6420b3..50c35081a02 100644 --- a/src/main/rate-limits/service/service-polling.ts +++ b/src/main/rate-limits/service/service-polling.ts @@ -95,7 +95,7 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue return { kind: 'full' } } if (limits.status === 'ok' || limits.status === 'unavailable') { - if (provider === 'claude' && this.shouldSkipAutomatedClaudeFetch(limits)) { + if (this.shouldSkipAutomatedFetch(provider, limits)) { continue } const checkedAt = provider === 'claude' ? this.lastClaudeCheckCompletedAt : limits.updatedAt diff --git a/src/main/rate-limits/service/service-provider-cycles.ts b/src/main/rate-limits/service/service-provider-cycles.ts index a655b3a4fa5..13e68ea3c60 100644 --- a/src/main/rate-limits/service/service-provider-cycles.ts +++ b/src/main/rate-limits/service/service-provider-cycles.ts @@ -86,7 +86,7 @@ export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFul return } // Why: automated checks must honor the endpoint's Retry-After even when live display is available. - if (!options?.force && this.shouldSkipAutomatedClaudeFetch(this.state.claude)) { + if (!options?.force && this.shouldSkipAutomatedFetch('claude', this.state.claude)) { return } const claudeTarget = this.claudeFetchTarget