mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user