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.
This commit is contained in:
Neil
2026-10-24 17:44:55 -07:00
committed by GitHub
parent e8a18a03d5
commit 6017bc99b4
9 changed files with 132 additions and 11 deletions
@@ -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
@@ -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')
}
@@ -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' })
@@ -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<Record<UsageRateLimitFailureKind, string>> = {
'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)) {
@@ -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()
}
})
})
@@ -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(
@@ -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([
@@ -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
@@ -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