Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/main/rate-limits/antigravity-usage-error.test.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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()
})
})
105 changes: 105 additions & 0 deletions src/main/rate-limits/antigravity-usage-error.ts
Original file line number Diff line number Diff line change
@@ -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'
Comment on lines +26 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This widens sign-out detection from the single observed phrase to eight generic strings; 'no credentials', 'please log in' and 'authentication required' are broad enough to plausibly appear in an unrelated agy log line, and the classifier runs on stdout + stderr before parseAntigravityUsageStdout, so a false positive would blank a valid reading and tell a signed-in user to sign in — the misdirection this PR sets out to remove. Is there a captured transcript showing these fragments occur only on a sign-out path?

] 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))) {
Comment on lines +100 to +101

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use prose fallback only when no structured diagnostic exists.

When a valid diagnostic has an unrecognized status, this code still applies prose matching. For example, not logged in\nAGY_ERROR: {"status":"INTERNAL"} becomes missing-credentials. The fetcher then tells the user to sign in instead of reporting the structured failure.

Parse the diagnostics once. If diagnostics exist but none classify, return null. Add a test with an unrecognized diagnostic and conflicting prose.

return { failureKind: 'missing-credentials', signedOut: true }
}
return null
}
33 changes: 25 additions & 8 deletions src/main/rate-limits/antigravity-usage-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<UsageRateLimitFailureKind, string>> = {
'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.'
Expand Down Expand Up @@ -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) {
Comment on lines +157 to +158

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Sign-out text overrides quota If the CLI returns a valid quota reading alongside diagnostic text containing a broad phrase such as “please sign in,” this branch reports missing credentials before checking the reading. It discards the quota and tells a signed-in user to sign in. The same ordering means a model reply containing that phrase bypasses the unsupported-command check, so later polls can keep spending turns. Check for a reading or model turn before using prose to classify sign-out.

return unavailable(
'Antigravity usage is not available. Sign in with `agy` to report this account’s quota.',
'missing-credentials',
failure.failureKind,
now()
)
}
Expand All @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions src/main/rate-limits/antigravity-usage-snapshot.ts
Original file line number Diff line number Diff line change
@@ -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'
}
}
63 changes: 63 additions & 0 deletions src/main/rate-limits/service-antigravity-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
5 changes: 5 additions & 0 deletions src/main/rate-limits/service/service-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type ClaudeAuthPreparationResolver,
type OpenCodeGoRateLimitConfig,
type MiniMaxRateLimitConfig,
type AntigravityUsageEnabledResolver,
type GeminiCliOAuthEnabledResolver,
type InactiveCodexAccountInfo,
type InactiveClaudeAccountInfo,
Expand Down Expand Up @@ -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
}
Expand Down
21 changes: 16 additions & 5 deletions src/main/rate-limits/service/service-full-cycle-preparation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'),
Expand All @@ -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())
Comment on lines +168 to +171

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Hidden meter prompts full refreshes When the meter starts hidden, this branch supplies an idle snapshot on each cycle. The active-window refresh planner treats any idle provider as a reason to refresh all providers, so focusing or showing the window can repeatedly trigger work even though the Antigravity probe is disabled. Excluding the disabled provider from that decision would avoid the extra refreshes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report idle when a previous Antigravity reading exists.

When the user hides the meter after a successful refresh, this branch returns the previous snapshot with status: 'ok'. After a failed refresh, it retains status: 'error' and the error message. Neither case reports that polling is disabled.

Preserve the quota fields and updatedAt, but return status: 'idle' and error: null for disabled usage.

The PR objectives require hiding the meter to retain the last reading and report idle.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The gate stops the agy spawn, but this branch leaves antigravity in a state that defeats the activation debounce: idle when there is no prior reading, or a frozen ok whose updatedAt never advances when there is. getActiveWindowRefreshPlan returns { kind: 'full' } for idle (and for ok past MIN_REFETCH_MS), so every window focus/show/restore now runs fetchAll() for every provider — more background work than before the gate, for exactly the users the gate is meant to help.

Technical details
# Switched-off Antigravity keeps the refresh plan in "full" mode

## Affected sites
- `src/main/rate-limits/service/service-full-cycle-preparation.ts:168-171` — gate off resolves to `previousState.antigravity ?? antigravityUsageDisabledSnapshot()`; the previous snapshot is reused unchanged, so `updatedAt` is frozen.
- `src/main/rate-limits/antigravity-usage-snapshot.ts:16` — publishes `status: 'idle'`.
- `src/main/rate-limits/service/service-polling.ts:93-96` — `!limits || status === 'idle' || status === 'fetching'` returns `{ kind: 'full' }`; the `MIN_REFETCH_MS` debounce at lines 97-102 is only reached for `ok`/`unavailable`.
- `src/main/rate-limits/service/service-configuration.ts:96-98` — `focus`/`show`/`restore` call `refreshIfWindowActive()`.
- No test drives `attach` + a window event with `setAntigravityUsageEnabledResolver(() => false)`; `service-antigravity-usage.test.ts` only calls `service.refresh()`, which bypasses the plan.

## Required outcome
- A slot the user has switched off must not force a full refetch on every window activation while healthy providers remain debounced to `MIN_REFETCH_MS`.

## Suggested approach (optional)
- Have `getActiveWindowRefreshPlan` skip providers reported disabled by a resolver, or give the disabled snapshot a terminal status the plan debounces (`unavailable` is debounced but renders as `--`, so check the desired segment appearance), and add a `focus`-driven regression test.

).then(
(value) => ({ status: 'fulfilled', value }) as const,
(reason) => ({ status: 'rejected', reason }) as const
)
Expand Down
Loading
Loading