From 2c6e7d4dd43d6a76ee4bd2857a0b231ead9c053f Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 30 Sep 2026 23:17:41 -0700 Subject: [PATCH] fix(rate-limits): only probe agy when the Antigravity meter is shown, and classify its errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quota probe spawns agy, which starts the CLI's language server for ~2.5 s. Anyone with agy on PATH paid that every 15-minute cycle whether or not they were showing Antigravity usage. It is now gated on the status bar item, which is the switch the user already has; default-on matches the bar's own default. Sign-out detection keyed on one English sentence, so a reword would report a signed-out account as an unreadable payload. agy also prints a structured AGY_ERROR diagnostic, and that is now the primary signal — UNAUTHENTICATED/401, PERMISSION_DENIED/403 and RESOURCE_EXHAUSTED/429 are told apart, so a signed-in but rate-limited account is no longer told to sign in. The sentences remain as a fallback for paths that print no structured error. --- .../antigravity-usage-error.test.ts | 78 +++++++++++++ .../rate-limits/antigravity-usage-error.ts | 105 ++++++++++++++++++ .../rate-limits/antigravity-usage-fetcher.ts | 33 ++++-- .../rate-limits/antigravity-usage-snapshot.ts | 18 +++ .../service-antigravity-usage.test.ts | 63 +++++++++++ .../service/service-configuration.ts | 5 + .../service/service-full-cycle-preparation.ts | 21 +++- src/main/rate-limits/service/service-state.ts | 2 + src/main/rate-limits/service/service-types.ts | 3 + .../startup/main-process-account-services.ts | 5 + 10 files changed, 320 insertions(+), 13 deletions(-) create mode 100644 src/main/rate-limits/antigravity-usage-error.test.ts create mode 100644 src/main/rate-limits/antigravity-usage-error.ts create mode 100644 src/main/rate-limits/antigravity-usage-snapshot.ts diff --git a/src/main/rate-limits/antigravity-usage-error.test.ts b/src/main/rate-limits/antigravity-usage-error.test.ts new file mode 100644 index 000000000000..e737c8cb42de --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-error.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { classifyAntigravityUsageFailure } from './antigravity-usage-error' + +/** Captured verbatim from agy 1.2.11 when the account had exhausted its weekly pool. */ +const REAL_RESOURCE_EXHAUSTED = + 'error: Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 167h51m3s.\n' + + 'AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 167h51m3s.","status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","retryable":true,"error_id":"6baa4ce9-c373-4086-bb0d-fbb8421bacf3-1"}' + +function agyError(fields: Record): string { + return `AGY_ERROR: ${JSON.stringify(fields)}` +} + +describe('classifyAntigravityUsageFailure', () => { + it('reads the real RESOURCE_EXHAUSTED diagnostic as rate limiting, not as a sign-out', () => { + const failure = classifyAntigravityUsageFailure(REAL_RESOURCE_EXHAUSTED) + + expect(failure).toEqual({ failureKind: 'rate-limited', signedOut: false }) + }) + + it.each([ + ['status', { status: 'UNAUTHENTICATED' }], + ['http code', { error_code: 401 }] + ])('reads an unauthenticated %s as a missing sign-in', (_label, fields) => { + expect(classifyAntigravityUsageFailure(agyError(fields))).toEqual({ + failureKind: 'missing-credentials', + signedOut: true + }) + }) + + it('separates an unentitled account from a signed-out one', () => { + // Why this matters: telling a signed-in user to sign in sends them to re-auth that fixes + // nothing. + expect(classifyAntigravityUsageFailure(agyError({ status: 'PERMISSION_DENIED' }))).toEqual({ + failureKind: 'no-subscription', + signedOut: false + }) + }) + + it('reads a 5xx as a server failure', () => { + expect(classifyAntigravityUsageFailure(agyError({ error_code: 503 }))).toEqual({ + failureKind: 'server', + signedOut: false + }) + }) + + it.each([ + 'You are not logged into Antigravity.', + 'Error: not signed in. Run `agy` to continue.', + 'please sign in to continue', + 'AUTHENTICATION REQUIRED' + ])('falls back to the sentence when no structured error is printed: %s', (output) => { + // Why a fallback at all: the structured line is the stable signal, but not every path prints + // one, and a signed-out account must never be reported as an unreadable payload. + expect(classifyAntigravityUsageFailure(output)).toEqual({ + failureKind: 'missing-credentials', + signedOut: true + }) + }) + + it('prefers the structured status over a sentence that disagrees with it', () => { + const output = `not logged in\n${agyError({ status: 'RESOURCE_EXHAUSTED' })}` + + expect(classifyAntigravityUsageFailure(output)).toEqual({ + failureKind: 'rate-limited', + signedOut: false + }) + }) + + it.each([ + ['a successful read', '{"status":"SUCCESS","command":{"name":"usage"}}'], + ['unrelated noise', 'I0926 quota_manager.go:45] doRefreshQuota: starting reload'], + ['an unparsable structured line', 'AGY_ERROR: {not json'], + ['an unrecognised status', agyError({ status: 'SOMETHING_NEW' })], + ['empty output', ''] + ])('returns null for %s rather than inventing a cause', (_label, output) => { + expect(classifyAntigravityUsageFailure(output)).toBeNull() + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-error.ts b/src/main/rate-limits/antigravity-usage-error.ts new file mode 100644 index 000000000000..9d7eca07cf0b --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-error.ts @@ -0,0 +1,105 @@ +import type { UsageRateLimitFailureKind } from '../../shared/rate-limit-types' + +/** + * Classifies why the Antigravity CLI could not answer a quota read. + * + * Why not a prose match alone: agy prints a structured diagnostic beside the human sentence, and + * the structure is the stable part. A line like + * + * ``` + * AGY_ERROR: {"short_error":"RESOURCE_EXHAUSTED (code 429): Individual quota reached…", + * "status":"RESOURCE_EXHAUSTED","error_code":429,"code_kind":"http","retryable":true} + * ``` + * + * survives rewording of the sentence, which a phrase match does not. The sentences are still read, + * but only as a fallback for builds or paths that print no structured error. + */ + +const AGY_ERROR_PREFIX = 'AGY_ERROR:' + +/** + * Sentences agy has been seen to print for a missing session, lower-cased. + * + * Kept deliberately short and generic: these are a fallback for when no structured status is + * present, so matching the stable fragment beats matching a whole sentence that may gain a suffix. + */ +const SIGNED_OUT_PHRASES = [ + 'not logged into antigravity', + 'not logged in', + 'not signed in', + 'please sign in', + 'please log in', + 'sign in to antigravity', + 'no credentials', + 'authentication required' +] as const + +export type AntigravityUsageFailure = { + failureKind: UsageRateLimitFailureKind + /** True when the account exists but cannot currently be read, so Orca should not blame sign-in. */ + signedOut: boolean +} + +type StructuredAgyError = { status?: unknown; error_code?: unknown } + +function parseStructuredErrors(output: string): StructuredAgyError[] { + const parsed: StructuredAgyError[] = [] + for (const line of output.split('\n')) { + const index = line.indexOf(AGY_ERROR_PREFIX) + if (index === -1) { + continue + } + const payload = line.slice(index + AGY_ERROR_PREFIX.length).trim() + if (!payload.startsWith('{')) { + continue + } + try { + const value: unknown = JSON.parse(payload) + if (typeof value === 'object' && value !== null) { + parsed.push(value) + } + } catch { + continue + } + } + return parsed +} + +function classifyStructured(error: StructuredAgyError): AntigravityUsageFailure | null { + const status = typeof error.status === 'string' ? error.status.toUpperCase() : '' + const code = typeof error.error_code === 'number' ? error.error_code : null + if (status === 'UNAUTHENTICATED' || code === 401) { + return { failureKind: 'missing-credentials', signedOut: true } + } + if (status === 'PERMISSION_DENIED' || code === 403) { + // The session is real; the account simply is not entitled to what was asked for. + return { failureKind: 'no-subscription', signedOut: false } + } + if (status === 'RESOURCE_EXHAUSTED' || code === 429) { + return { failureKind: 'rate-limited', signedOut: false } + } + if (code !== null && code >= 500) { + return { failureKind: 'server', signedOut: false } + } + return null +} + +/** + * Reads agy's combined stdout/stderr for a reason the quota could not be read. + * + * Returns null when nothing recognisable is present, which the caller reports as an unreadable + * payload rather than inventing a cause. + */ +export function classifyAntigravityUsageFailure(output: string): AntigravityUsageFailure | null { + for (const error of parseStructuredErrors(output)) { + const classified = classifyStructured(error) + if (classified) { + return classified + } + } + const lowered = output.toLowerCase() + if (SIGNED_OUT_PHRASES.some((phrase) => lowered.includes(phrase))) { + return { failureKind: 'missing-credentials', signedOut: true } + } + return null +} diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index 9a1d8faf7f85..bcd00382c66d 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -9,13 +9,20 @@ import { antigravityCommandName } from './antigravity-usage-command' import { parseAntigravityUsageStdout, stdoutShowsModelTurn } from './antigravity-usage-response' +import { classifyAntigravityUsageFailure } from './antigravity-usage-error' -/** - * Observed verbatim in agy's own log when the keyring holds no session. agy exits 0 and prints this - * instead of a usage envelope, so the text is the only thing that separates "signed out" from - * "answered nothing". - */ -const NOT_SIGNED_IN_MARKER = 'not logged into antigravity' +const GENERIC_FAILURE_REASON = + 'Antigravity usage is not available. The Antigravity CLI could not read this account’s quota.' + +/** Why a table: each kind is a different user action, and a single sentence would fit none of them. */ +const ANTIGRAVITY_FAILURE_REASONS: Partial> = { + 'rate-limited': + 'Antigravity usage is not available right now. The Antigravity API is rate-limiting this account.', + 'no-subscription': + 'Antigravity usage is not available. This account is signed in but not entitled to Antigravity quota.', + server: + 'Antigravity usage is not available right now. The Antigravity API returned a server error.' +} const UNSUPPORTED_USAGE_COMMAND_REASON = 'Antigravity usage is not available. This version of the Antigravity CLI answers `/usage` as a prompt instead of a command, so Orca stopped asking rather than spend quota on it. Update `agy` and restart Orca.' @@ -147,10 +154,11 @@ export async function fetchAntigravityRateLimits( } const output = `${result.stdout}\n${result.stderr}` - if (output.toLowerCase().includes(NOT_SIGNED_IN_MARKER)) { + const failure = classifyAntigravityUsageFailure(output) + if (failure?.signedOut) { return unavailable( 'Antigravity usage is not available. Sign in with `agy` to report this account’s quota.', - 'missing-credentials', + failure.failureKind, now() ) } @@ -162,6 +170,15 @@ export async function fetchAntigravityRateLimits( usageCommandUnsupported = true return unavailable(UNSUPPORTED_USAGE_COMMAND_REASON, 'usage-unavailable', now()) } + if (!reading && failure) { + // Why these stay 'error' and not 'unavailable': the account is signed in and the read failed + // for a reason that can clear on its own, so the segment should keep retrying. + return failed( + ANTIGRAVITY_FAILURE_REASONS[failure.failureKind] ?? GENERIC_FAILURE_REASON, + failure.failureKind, + now() + ) + } if (!reading) { // Why a non-zero exit is reported only here: `runProcess` treats the exit code as data, and agy // exits 0 for a signed-out read, so the code only adds detail once the payload is missing. diff --git a/src/main/rate-limits/antigravity-usage-snapshot.ts b/src/main/rate-limits/antigravity-usage-snapshot.ts new file mode 100644 index 000000000000..5be522740c8b --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-snapshot.ts @@ -0,0 +1,18 @@ +import type { ProviderRateLimits } from '../../shared/rate-limit-types' +/** + * What the provider reports when the user is not showing Antigravity usage at all. + * + * Why a snapshot rather than leaving the slot null: the state shape is published to every reader + * (status bar, Accounts, mobile), and an absent entry reads as "still loading" instead of + * "switched off". + */ +export function antigravityUsageDisabledSnapshot(now: number = Date.now()): ProviderRateLimits { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: now, + error: null, + status: 'idle' + } +} diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index 1ffe7cabc34d..8e65e53740be 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -133,3 +133,66 @@ describe('RateLimitService Antigravity usage', () => { expect(state.gemini?.status).toBe('ok') }) }) + +describe('Antigravity usage gating', () => { + beforeEach(() => { + resetRateLimitProviderMocks() + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 7)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now())) + }) + + it('never spawns agy when the usage meter is hidden', async () => { + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 30)) + const service = new RateLimitService() + service.setAntigravityUsageEnabledResolver(() => false) + + await service.refresh() + + // Why: the probe starts the agy language server for ~2.5 s. A user not showing the meter + // should not pay that every cycle. + expect(fetchAntigravityRateLimits).not.toHaveBeenCalled() + expect(service.getState().antigravity?.status).toBe('idle') + // Why the rest must be untouched: gating one provider cannot gate the cycle. + expect(service.getState().gemini?.status).toBe('ok') + }) + + it('spawns agy when the meter is shown', async () => { + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 30)) + const service = new RateLimitService() + service.setAntigravityUsageEnabledResolver(() => true) + + await service.refresh() + + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().antigravity?.status).toBe('ok') + }) + + it('fetches when no resolver is configured', async () => { + // Why default-on: the status bar shows Antigravity by default, so an unconfigured service + // must still fill the meter rather than silently reporting nothing. + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 30)) + const service = new RateLimitService() + + await service.refresh() + + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) + }) + + it('keeps the last reading when the meter is hidden mid-session', async () => { + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 44)) + const service = new RateLimitService() + let enabled = true + service.setAntigravityUsageEnabledResolver(() => enabled) + await service.refresh() + expect(service.getState().antigravity?.session?.usedPercent).toBe(44) + + enabled = false + await service.refresh() + + // Why kept: hiding the meter is not evidence the quota changed, and re-showing it should not + // flash an empty segment while the next poll runs. + expect(service.getState().antigravity?.session?.usedPercent).toBe(44) + expect(fetchAntigravityRateLimits).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts index 2a788b1cf935..7db5d24a4a75 100644 --- a/src/main/rate-limits/service/service-configuration.ts +++ b/src/main/rate-limits/service/service-configuration.ts @@ -10,6 +10,7 @@ import { type ClaudeAuthPreparationResolver, type OpenCodeGoRateLimitConfig, type MiniMaxRateLimitConfig, + type AntigravityUsageEnabledResolver, type GeminiCliOAuthEnabledResolver, type InactiveCodexAccountInfo, type InactiveClaudeAccountInfo, @@ -52,6 +53,10 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco this.geminiCliOAuthEnabledResolver = resolver } + setAntigravityUsageEnabledResolver(resolver: AntigravityUsageEnabledResolver): void { + this.antigravityUsageEnabledResolver = resolver + } + setNetworkProxySettingsResolver(resolver: () => NetworkProxySettings): void { this.networkProxySettingsResolver = resolver } 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 5395ace5fbff..33fb9b58364f 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -7,6 +7,7 @@ import { fetchCursorRateLimits } from '../cursor-fetcher' import { readCursorAuthSession } from '../cursor-auth' import { fetchZcodeRateLimits } from '../zcode-usage-fetcher' import { fetchAntigravityRateLimits } from '../antigravity-usage-fetcher' +import { antigravityUsageDisabledSnapshot } from '../antigravity-usage-snapshot' import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher' import { createHash } from 'node:crypto' import { fetchOpenCodeGoUsage } from '../opencode-go-usage-source-selection' @@ -129,7 +130,10 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ ? this.withFetchingStatus(null, 'opencode-go') : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), - antigravity: this.withFetchingStatus(previousState.antigravity, 'antigravity'), + antigravity: + (this.antigravityUsageEnabledResolver?.() ?? true) + ? this.withFetchingStatus(previousState.antigravity, 'antigravity') + : previousState.antigravity, minimax: miniMaxConfigChanged ? this.withFetchingStatus(null, 'minimax') : this.withFetchingStatus(previousState.minimax, 'minimax'), @@ -155,10 +159,17 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ (reason) => ({ status: 'rejected', reason }) as const ) - // Why its own promise: the Antigravity read spawns `agy` and waits ~2.5 s for the CLI to start - // its language server and refresh the quota. Inside the awaited tuple that latency would be - // added to every other provider's cycle. - const antigravityResultPromise = fetchAntigravityRateLimits({ signal }).then( + // Why gated: the probe spawns `agy`, which starts the CLI's language server for ~2.5 s. A user + // who has agy installed but is not showing Antigravity usage should not pay that every cycle. + // Default-on matches the status bar's own default, so the meter still fills itself in. + const antigravityUsageEnabled = this.antigravityUsageEnabledResolver?.() ?? true + // Why its own promise: that ~2.5 s inside the awaited tuple would be added to every other + // provider's cycle. + const antigravityResultPromise = ( + antigravityUsageEnabled + ? fetchAntigravityRateLimits({ signal }) + : Promise.resolve(previousState.antigravity ?? antigravityUsageDisabledSnapshot()) + ).then( (value) => ({ status: 'fulfilled', value }) as const, (reason) => ({ status: 'rejected', reason }) as const ) diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index 7af82c6c3135..eb810f1e21bc 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -13,6 +13,7 @@ import { type ClaudeAuthPreparationResolver, type OpenCodeGoRateLimitConfig, type MiniMaxRateLimitConfig, + type AntigravityUsageEnabledResolver, type GeminiCliOAuthEnabledResolver, type NormalizedCodexAccountSelectionTarget, type NormalizedClaudeAccountSelectionTarget, @@ -101,6 +102,7 @@ export abstract class RateLimitServiceState { protected openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null protected miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null protected geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null + protected antigravityUsageEnabledResolver: AntigravityUsageEnabledResolver | null = null protected inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null protected inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null protected networkProxySettingsResolver: (() => NetworkProxySettings) | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index a3998c13abe4..c80f6bb68f14 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -62,6 +62,9 @@ export type MiniMaxResolvedConfig = { } export type GeminiCliOAuthEnabledResolver = () => boolean + +/** Whether the user is actually showing Antigravity usage, so the `agy` probe is worth spawning. */ +export type AntigravityUsageEnabledResolver = () => boolean export type ActiveRateLimitProvider = ProviderRateLimits['provider'] export type ActiveProviderState = { provider: ActiveRateLimitProvider diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 6abf73d66106..5903b939d290 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -130,6 +130,11 @@ export function initializeMainProcessAccountServices(): void { } }) state.rateLimits.setGeminiCliOAuthEnabledResolver(() => store.getSettings().geminiCliOAuthEnabled) + // Why the status bar item and not a setting of its own: showing the meter is the only thing that + // makes the `agy` spawn worth paying for, and it is the switch the user already has. + state.rateLimits.setAntigravityUsageEnabledResolver(() => + store.getUI().statusBarItems.includes('antigravity') + ) state.rateLimits.setNetworkProxySettingsResolver(() => store.getSettings()) state.keybindings = new KeybindingService({ homePath: app.getPath('home'),