From 47aaf44f80a764765157aef10999386798eb83a2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 11:36:21 +0000 Subject: [PATCH 1/3] fix(rate-limits): read Antigravity quota from the local Agy LanguageServer Stop copying Gemini usage onto Antigravity. Refresh now discovers the current Agy LanguageServer from its CLI log, POSTs RetrieveUserQuotaSummary on loopback, and maps native session/weekly buckets into ProviderRateLimits. If no local LanguageServer is running, Antigravity usage is unavailable. Gemini results and failures never populate Antigravity. The status bar shows a checked Antigravity item without Gemini OAuth and no longer routes clicks to Gemini Accounts. Fixes #9122 --- .../antigravity-usage-fetcher.test.ts | 161 ++++++++++ .../rate-limits/antigravity-usage-fetcher.ts | 299 ++++++++++++++++++ .../antigravity-usage-mirror.test.ts | 70 ---- .../rate-limits/antigravity-usage-mirror.ts | 29 -- .../service-antigravity-usage.test.ts | 44 ++- .../service/service-full-cycle-application.ts | 20 +- .../service/service-full-cycle-preparation.ts | 85 ++--- .../status-bar-provider-visibility.test.ts | 63 +--- .../status-bar-provider-visibility.ts | 12 +- .../usage-provider-settings-target.test.ts | 2 +- .../usage-provider-settings-target.ts | 5 +- 11 files changed, 559 insertions(+), 231 deletions(-) create mode 100644 src/main/rate-limits/antigravity-usage-fetcher.test.ts create mode 100644 src/main/rate-limits/antigravity-usage-fetcher.ts delete mode 100644 src/main/rate-limits/antigravity-usage-mirror.test.ts delete mode 100644 src/main/rate-limits/antigravity-usage-mirror.ts diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts new file mode 100644 index 000000000000..f58569e49d51 --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' +import { + ANTIGRAVITY_USAGE_UNAVAILABLE, + fetchAntigravityRateLimits, + mapAntigravityQuotaSummary, + parseAntigravityLanguageServerLog +} from './antigravity-usage-fetcher' + +const GROUPED_SUMMARY = { + response: { + groups: [ + { + displayName: 'Gemini Models', + buckets: [ + { + bucketId: 'gemini-weekly', + displayName: 'Weekly Limit Remaining', + remainingFraction: 0.8, + resetTime: '2026-09-20T12:00:00Z' + }, + { + bucketId: 'gemini-5h', + displayName: 'Five Hour Limit Remaining', + remainingFraction: 0.4, + resetTime: '2026-09-16T01:00:00Z' + } + ] + }, + { + displayName: 'Claude and GPT models', + buckets: [ + { + bucketId: 'claude-gpt-weekly', + displayName: 'Weekly Limit Remaining', + remainingFraction: 0.95, + resetTime: '2026-09-20T12:00:00Z' + }, + { + bucketId: 'claude-gpt-5h', + displayName: 'Five Hour Limit Remaining', + remainingFraction: 0.7, + resetTime: '2026-09-16T01:00:00Z' + } + ] + } + ] + } +} + +const LIVE_LOG = [ + 'Starting language server process with pid 4242', + 'listening on random port at 4312 for HTTP', + 'listening on random port at 4313 for HTTPS (gRPC)' +].join('\n') + +describe('parseAntigravityLanguageServerLog', () => { + it('reads pid and both loopback ports from an Agy startup banner', () => { + expect(parseAntigravityLanguageServerLog(LIVE_LOG)).toEqual({ + pid: 4242, + httpPort: 4312, + httpsPort: 4313 + }) + }) +}) + +describe('mapAntigravityQuotaSummary', () => { + it('maps native grouped remainingFraction buckets onto session and weekly', () => { + const limits = mapAntigravityQuotaSummary(GROUPED_SUMMARY) + expect(limits?.provider).toBe('antigravity') + expect(limits?.status).toBe('ok') + expect(limits?.session?.usedPercent).toBe(60) + expect(limits?.session?.windowMinutes).toBe(300) + expect(limits?.weekly?.usedPercent).toBe(20) + expect(limits?.weekly?.windowMinutes).toBe(10_080) + }) + + it('ignores disabled buckets', () => { + const limits = mapAntigravityQuotaSummary({ + response: { + groups: [ + { + displayName: 'Gemini Models', + buckets: [ + { + bucketId: 'gemini-5h', + displayName: 'Five Hour Limit Remaining', + remainingFraction: 0.1, + disabled: true + }, + { + bucketId: 'gemini-weekly', + displayName: 'Weekly Limit Remaining', + remainingFraction: 0.5 + } + ] + } + ] + } + }) + expect(limits?.session).toBeNull() + expect(limits?.weekly?.usedPercent).toBe(50) + }) +}) + +describe('fetchAntigravityRateLimits', () => { + const endpoint = { + pid: 4242, + httpPort: 4312, + httpsPort: 4313 + } + + it('POSTs the quota RPC to the current live loopback endpoint and maps the summary', async () => { + const posted: string[] = [] + const limits = await fetchAntigravityRateLimits({ + endpoint, + postQuota: async (url) => { + posted.push(url) + expect(url.startsWith('http://127.0.0.1:')).toBe(true) + return GROUPED_SUMMARY + } + }) + expect(posted).toEqual([ + 'http://127.0.0.1:4312/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary' + ]) + expect(limits.status).toBe('ok') + expect(limits.session?.usedPercent).toBe(60) + }) + + it('reports unavailable when no local LanguageServer is running', async () => { + const limits = await fetchAntigravityRateLimits({ + endpoint: null, + postQuota: async () => { + throw new Error('must not probe loopback when no LanguageServer is running') + } + }) + expect(limits.status).toBe('unavailable') + expect(limits.provider).toBe('antigravity') + expect(limits.error).toBe(ANTIGRAVITY_USAGE_UNAVAILABLE) + expect(limits.session).toBeNull() + expect(limits.weekly).toBeNull() + }) + + it('falls back to the HTTPS loopback port when HTTP does not answer', async () => { + const posted: string[] = [] + const limits = await fetchAntigravityRateLimits({ + endpoint, + postQuota: async (url) => { + posted.push(url) + if (url.startsWith('http://')) { + return null + } + return GROUPED_SUMMARY + } + }) + expect(posted).toEqual([ + 'http://127.0.0.1:4312/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary', + 'https://127.0.0.1:4313/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary' + ]) + expect(limits.status).toBe('ok') + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts new file mode 100644 index 000000000000..81bc0262622a --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -0,0 +1,299 @@ +import { closeSync, openSync, readSync, readdirSync } from 'node:fs' +import http from 'node:http' +import https from 'node:https' +import { homedir } from 'node:os' +import path from 'node:path' +import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' + +const QUOTA_PATH = '/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary' +const LOOPBACK_HOST = '127.0.0.1' +const REQUEST_TIMEOUT_MS = 2_500 +const SESSION_WINDOW_MINUTES = 300 +const WEEKLY_WINDOW_MINUTES = 10_080 + +export const ANTIGRAVITY_USAGE_UNAVAILABLE = + 'Antigravity usage is not available. Start agy so Orca can read its quota.' + +export type AntigravityLanguageServerEndpoint = { + pid: number + httpPort: number | null + httpsPort: number | null +} + +export type AntigravityUsageFetchDeps = { + endpoint?: AntigravityLanguageServerEndpoint | null + postQuota?: (url: string) => Promise + now?: () => number +} + +type Cadence = 'session' | 'weekly' +type CadenceWindow = RateLimitWindow & { cadence: Cadence } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function unavailable(now: number): ProviderRateLimits { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: now, + error: ANTIGRAVITY_USAGE_UNAVAILABLE, + status: 'unavailable', + usageMetadata: { source: 'cli', failureKind: 'cli-unavailable' } + } +} + +export function parseAntigravityLanguageServerLog( + logHead: string +): AntigravityLanguageServerEndpoint | null { + const pid = Number(/Starting language server process with pid (\d+)/.exec(logHead)?.[1]) + if (!Number.isInteger(pid) || pid <= 0) { + return null + } + let httpPort: number | null = null + let httpsPort: number | null = null + const portPattern = /listening on random port at (\d+) for (HTTPS|HTTP)\b/g + for (const match of logHead.matchAll(portPattern)) { + const port = Number(match[1]) + if (!Number.isInteger(port) || port <= 0 || port > 65_535) { + continue + } + if (match[2] === 'HTTP') { + httpPort ??= port + } else { + httpsPort ??= port + } + } + if (httpPort === null && httpsPort === null) { + return null + } + return { pid, httpPort, httpsPort } +} + +function windowFromBucket(raw: unknown): CadenceWindow | null { + if (!isRecord(raw) || raw.disabled === true) { + return null + } + const remaining = raw.remainingFraction + if (typeof remaining !== 'number' || !Number.isFinite(remaining)) { + return null + } + const id = typeof raw.bucketId === 'string' ? raw.bucketId : '' + const name = typeof raw.displayName === 'string' ? raw.displayName : id + const text = `${id} ${name}`.toLowerCase() + const cadence: Cadence | null = + text.includes('weekly') || text.includes('7d') || text.includes('7-day') + ? 'weekly' + : text.includes('5h') || text.includes('five hour') || text.includes('five-hour') + ? 'session' + : null + if (!cadence) { + return null + } + const resetTime = typeof raw.resetTime === 'string' ? Date.parse(raw.resetTime) : Number.NaN + return { + cadence, + usedPercent: Math.min(100, Math.max(0, Math.round((1 - remaining) * 100))), + windowMinutes: cadence === 'weekly' ? WEEKLY_WINDOW_MINUTES : SESSION_WINDOW_MINUTES, + resetsAt: Number.isNaN(resetTime) ? null : resetTime, + resetDescription: null + } +} + +function tightest(windows: CadenceWindow[], cadence: Cadence): RateLimitWindow | null { + const matches = windows.filter((window) => window.cadence === cadence) + if (matches.length === 0) { + return null + } + const chosen = matches.reduce((worst, window) => + window.usedPercent > worst.usedPercent ? window : worst + ) + return { + usedPercent: chosen.usedPercent, + windowMinutes: chosen.windowMinutes, + resetsAt: chosen.resetsAt, + resetDescription: chosen.resetDescription + } +} + +export function mapAntigravityQuotaSummary(data: unknown): ProviderRateLimits | null { + if (!isRecord(data)) { + return null + } + const response = isRecord(data.response) ? data.response : null + const groups = Array.isArray(response?.groups) ? response.groups : null + if (!groups) { + return null + } + const windows: CadenceWindow[] = [] + for (const group of groups) { + if (!isRecord(group) || !Array.isArray(group.buckets)) { + continue + } + for (const raw of group.buckets) { + const window = windowFromBucket(raw) + if (window) { + windows.push(window) + } + } + } + const session = tightest(windows, 'session') + const weekly = tightest(windows, 'weekly') + if (!session && !weekly) { + return null + } + return { + provider: 'antigravity', + session, + weekly, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { source: 'cli', lastSuccessfulSource: 'cli' } + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'EPERM') + } +} + +function discoverCurrentEndpoint(): AntigravityLanguageServerEndpoint | null { + const logDir = path.join(homedir(), '.gemini', 'antigravity-cli', 'log') + let names: string[] + try { + names = readdirSync(logDir) + } catch { + return null + } + names = names + .filter((name) => name.startsWith('cli-') && name.endsWith('.log')) + .sort() + .toReversed() + .slice(0, 12) + for (const name of names) { + try { + const fd = openSync(path.join(logDir, name), 'r') + try { + const buffer = Buffer.alloc(8_192) + const bytesRead = readSync(fd, buffer, 0, 8_192, 0) + const endpoint = parseAntigravityLanguageServerLog( + buffer.subarray(0, bytesRead).toString('utf8') + ) + if (endpoint && isProcessAlive(endpoint.pid)) { + return endpoint + } + } finally { + closeSync(fd) + } + } catch { + continue + } + } + return null +} + +function isLoopbackHttpUrl(raw: string): boolean { + try { + const url = new URL(raw) + return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname === LOOPBACK_HOST + } catch { + return false + } +} + +function postLoopbackQuota(url: string): Promise { + if (!isLoopbackHttpUrl(url)) { + return Promise.resolve(null) + } + const parsed = new URL(url) + const requestOptions: https.RequestOptions = { + protocol: parsed.protocol, + hostname: LOOPBACK_HOST, + port: parsed.port, + path: `${parsed.pathname}${parsed.search}`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Connect-Protocol-Version': '1' + }, + timeout: REQUEST_TIMEOUT_MS + } + if (parsed.protocol === 'https:') { + // Why: Agy presents a self-signed cert on 127.0.0.1 only. + requestOptions.rejectUnauthorized = false + } + const transport = parsed.protocol === 'https:' ? https : http + return new Promise((resolve) => { + const request = transport.request(requestOptions, (response) => { + const chunks: Buffer[] = [] + response.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + }) + response.on('end', () => { + const status = response.statusCode ?? 0 + if (status < 200 || status >= 300) { + resolve(null) + return + } + try { + const body: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')) + resolve(body) + } catch { + resolve(null) + } + }) + response.on('error', () => resolve(null)) + }) + request.on('timeout', () => { + request.destroy() + resolve(null) + }) + request.on('error', () => resolve(null)) + request.end('{"forceRefresh":true}') + }) +} + +async function probeQuota( + endpoint: AntigravityLanguageServerEndpoint, + postQuota: (url: string) => Promise, + now: () => number +): Promise { + const urls: string[] = [] + if (endpoint.httpPort !== null) { + urls.push(`http://${LOOPBACK_HOST}:${endpoint.httpPort}${QUOTA_PATH}`) + } + if (endpoint.httpsPort !== null) { + urls.push(`https://${LOOPBACK_HOST}:${endpoint.httpsPort}${QUOTA_PATH}`) + } + for (const url of urls) { + let json: unknown + try { + json = await postQuota(url) + } catch { + json = null + } + const mapped = json === null ? null : mapAntigravityQuotaSummary(json) + if (mapped) { + return mapped + } + } + return unavailable(now()) +} + +export function fetchAntigravityRateLimits( + deps: AntigravityUsageFetchDeps = {} +): Promise { + const now = deps.now ?? Date.now + const endpoint = deps.endpoint !== undefined ? deps.endpoint : discoverCurrentEndpoint() + if (!endpoint) { + return Promise.resolve(unavailable(now())) + } + return probeQuota(endpoint, deps.postQuota ?? postLoopbackQuota, now) +} diff --git a/src/main/rate-limits/antigravity-usage-mirror.test.ts b/src/main/rate-limits/antigravity-usage-mirror.test.ts deleted file mode 100644 index 84c92e1ad22f..000000000000 --- a/src/main/rate-limits/antigravity-usage-mirror.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { ProviderRateLimits, ProviderRateLimitStatus } from '../../shared/rate-limit-types' -import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' - -function geminiSnapshot( - status: ProviderRateLimitStatus, - error: string | null, - usedPercent: number | null = null -): ProviderRateLimits { - return { - provider: 'gemini', - session: - usedPercent === null - ? null - : { usedPercent, windowMinutes: 300, resetsAt: null, resetDescription: null }, - weekly: null, - updatedAt: 1_700_000_000_000, - error, - status - } -} - -describe('deriveAntigravityRateLimits', () => { - it('mirrors a successful Gemini read as shared Code Assist quota', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('ok', null, 42)) - - expect(antigravity.provider).toBe('antigravity') - expect(antigravity.status).toBe('ok') - expect(antigravity.session?.usedPercent).toBe(42) - expect(antigravity.error).toBeNull() - }) - - it('reports unavailable without quoting the Gemini failure', () => { - const antigravity = deriveAntigravityRateLimits( - geminiSnapshot('error', 'Gemini project ID not found') - ) - - expect(antigravity.provider).toBe('antigravity') - expect(antigravity.status).toBe('unavailable') - expect(antigravity.error).not.toContain('Gemini project ID not found') - expect(antigravity.error).toContain('Antigravity usage is not available') - expect(antigravity.session).toBeNull() - expect(antigravity.weekly).toBeNull() - }) - - it('does not blame a missing sign-in when the quota read itself failed', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) - - // Why: the reported symptom is a connected sign-in whose Code Assist read failed. - expect(antigravity.error).toContain('could not be read right now') - expect(antigravity.error).not.toContain('sign-in is connected') - }) - - it('keeps the Gemini timestamp so activation freshness checks are not forced to refetch', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) - - expect(antigravity.updatedAt).toBe(1_700_000_000_000) - }) - - it('points at the missing sign-in when the Gemini opt-in is off', () => { - const antigravity = deriveAntigravityRateLimits( - geminiSnapshot('unavailable', 'Gemini CLI OAuth is disabled in settings') - ) - - expect(antigravity.status).toBe('unavailable') - expect(antigravity.error).not.toContain('Gemini CLI OAuth is disabled in settings') - expect(antigravity.error).toContain('Antigravity usage is not available') - expect(antigravity.error).toContain('Gemini CLI sign-in is connected') - }) -}) diff --git a/src/main/rate-limits/antigravity-usage-mirror.ts b/src/main/rate-limits/antigravity-usage-mirror.ts deleted file mode 100644 index 3cea6decc111..000000000000 --- a/src/main/rate-limits/antigravity-usage-mirror.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ProviderRateLimits } from '../../shared/rate-limit-types' - -// Why: the Antigravity CLI keeps its token in the OS keyring, not in the files the Gemini -// fetcher reads, so Orca never actually queries Antigravity. Only a *successful* Gemini read -// describes shared Google Code Assist quota; republishing a Gemini failure under the -// Antigravity provider id surfaced "Refresh failed" for a request that was never attempted. -const ANTIGRAVITY_NO_SIGN_IN_REASON = - 'Antigravity usage is not available. Orca can only show shared Google Code Assist quota while a Gemini CLI sign-in is connected.' -// Why: a Gemini `error` means the sign-in exists and the quota read failed, so blaming a missing sign-in would misdirect the user. -const ANTIGRAVITY_QUOTA_UNREADABLE_REASON = - 'Antigravity usage is not available. Orca reads it from the shared Google Code Assist quota, which could not be read right now.' - -export function deriveAntigravityRateLimits(gemini: ProviderRateLimits): ProviderRateLimits { - if (gemini.status === 'ok') { - return { ...gemini, provider: 'antigravity' } - } - return { - provider: 'antigravity', - session: null, - weekly: null, - // Why: reuse the Gemini timestamp so activation freshness checks don't force a refetch every cycle. - updatedAt: gemini.updatedAt, - error: - gemini.status === 'unavailable' - ? ANTIGRAVITY_NO_SIGN_IN_REASON - : ANTIGRAVITY_QUOTA_UNREADABLE_REASON, - status: 'unavailable' - } -} diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index e0975af0db07..bd2aad054688 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -3,10 +3,12 @@ import { RateLimitService } from './service' import { fetchClaudeRateLimits } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { errorProvider, okProvider, - resetRateLimitProviderMocks + resetRateLimitProviderMocks, + unavailableProvider } from './rate-limit-service-test-harness' vi.mock('./claude-fetcher', () => ({ @@ -23,6 +25,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) @@ -54,47 +60,35 @@ describe('RateLimitService Antigravity usage', () => { vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) }) - it('does not republish a Gemini failure as an Antigravity refresh failure', async () => { + it('does not copy a Gemini failure onto Antigravity', async () => { vi.mocked(fetchGeminiRateLimits).mockResolvedValue( errorProvider('gemini', 'Gemini project ID not found') ) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(okProvider('antigravity', 18)) const service = new RateLimitService() await service.refresh() const state = service.getState() - expect(state.antigravity?.status).toBe('unavailable') - expect(state.antigravity?.error).not.toContain('Gemini project ID not found') - expect(state.antigravity?.session).toBeNull() - // Why: the real Gemini failure must still surface under its own provider. + expect(state.antigravity?.status).toBe('ok') + expect(state.antigravity?.session?.usedPercent).toBe(18) expect(state.gemini?.status).toBe('error') expect(state.gemini?.error).toBe('Gemini project ID not found') }) - it('keeps mirroring a successful Gemini read under the Antigravity provider', async () => { + it('keeps Antigravity unavailable when the local LanguageServer is not running', async () => { vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + unavailableProvider('antigravity', 'Antigravity usage is not available') + ) const service = new RateLimitService() await service.refresh() const state = service.getState() - expect(state.antigravity?.status).toBe('ok') - expect(state.antigravity?.provider).toBe('antigravity') - expect(state.antigravity?.session?.usedPercent).toBe(42) - }) - - it('never leaves a cached Antigravity snapshot in the error retry lane', async () => { - vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 42, Date.now())) - const service = new RateLimitService() - await service.refresh() - - vi.mocked(fetchGeminiRateLimits).mockResolvedValue( - errorProvider('gemini', 'Token refresh failed') - ) - await service.refresh() - - // Why: stale-retention would otherwise show Gemini numbers as "Refresh failed" Antigravity usage. - expect(service.getState().antigravity?.status).toBe('unavailable') - expect(service.getState().antigravity?.session).toBeNull() + expect(state.antigravity?.status).toBe('unavailable') + expect(state.antigravity?.session).toBeNull() + expect(state.gemini?.status).toBe('ok') + expect(state.gemini?.session?.usedPercent).toBe(42) }) }) diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 7104face38fa..dd5f1ebe9ec5 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,5 +1,4 @@ import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' -import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' import type { ProviderRateLimits } from './service-types' export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { @@ -32,7 +31,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ geminiResult, opencodeGoResult, kimiResult, - miniMaxResult + miniMaxResult, + antigravityResult ], grokResultPromise } = prepared @@ -79,8 +79,20 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ status: 'error' } satisfies ProviderRateLimits) - // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. - const antigravity = deriveAntigravityRateLimits(gemini) + const antigravity = + antigravityResult.status === 'fulfilled' + ? antigravityResult.value + : ({ + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + antigravityResult.reason instanceof Error + ? antigravityResult.reason.message + : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) const opencodeGo = opencodeGoResult.status === 'fulfilled' 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 7551652bca1d..62ffc5fa01fe 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -1,5 +1,6 @@ import { fetchClaudeRateLimits } from '../claude-fetcher' import { fetchCodexRateLimits } from '../codex-fetcher' +import { fetchAntigravityRateLimits } from '../antigravity-usage-fetcher' import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' import { fetchGrokRateLimits } from '../grok-fetcher' import { readGrokAuthSession } from '../grok-auth' @@ -36,6 +37,7 @@ export type FetchAllCyclePrepared = { PromiseSettledResult, PromiseSettledResult, PromiseSettledResult, + PromiseSettledResult, PromiseSettledResult ] grokResultPromise: Promise< @@ -138,42 +140,50 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ const claudeFetchGated = !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) - const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = - await Promise.allSettled([ - claudeFetchGated - ? Promise.resolve(previousState.claude as ProviderRateLimits) - : fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }), - codexFetchGated - ? Promise.resolve(previousState.codex as ProviderRateLimits) - : (missingWslCodexHome ?? - fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal - })), - fetchGeminiRateLimits(geminiCliOAuthEnabled), - fetchOpenCodeGoRateLimits( - cookie, - workspaceIdOverride || undefined, - this.networkProxySettingsResolver?.() - ), - this.fetchKimiWithResolvedHome(), - miniMaxConfigResult.error - ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) - : fetchMiniMaxRateLimits({ - cookie: miniMaxCookie, - groupId: miniMaxGroupId, - models: miniMaxModels, - endpointMode: miniMaxEndpoint, - apiKey: miniMaxApiKey - }) - ]) + const [ + claudeResult, + codexResult, + geminiResult, + opencodeGoResult, + kimiResult, + miniMaxResult, + antigravityResult + ] = await Promise.allSettled([ + claudeFetchGated + ? Promise.resolve(previousState.claude as ProviderRateLimits) + : fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }), + codexFetchGated + ? Promise.resolve(previousState.codex as ProviderRateLimits) + : (missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + })), + fetchGeminiRateLimits(geminiCliOAuthEnabled), + fetchOpenCodeGoRateLimits( + cookie, + workspaceIdOverride || undefined, + this.networkProxySettingsResolver?.() + ), + this.fetchKimiWithResolvedHome(), + miniMaxConfigResult.error + ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) + : fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels, + endpointMode: miniMaxEndpoint, + apiKey: miniMaxApiKey + }), + fetchAntigravityRateLimits() + ]) if (signal.aborted) { return null @@ -200,7 +210,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ geminiResult, opencodeGoResult, kimiResult, - miniMaxResult + miniMaxResult, + antigravityResult ], grokResultPromise } diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index ab2a0eca23f1..f186e37601b2 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -123,11 +123,7 @@ describe('hasUsageProviderSettings', () => { expect( hasUsageProviderSettings(usageSettings({ opencodeSessionCookie: ' session=abc ' })) ).toBe(true) - // Why: antigravity durability requires the Gemini OAuth opt-in; the - // checked item alone must not suppress the usage setup CTA. - expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe( - false - ) + expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ minimaxCookieConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ minimaxApiKeyConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ grokAuthConfigured: true }))).toBe(true) @@ -163,21 +159,13 @@ describe('hasUsageProviderSettingsForProvider', () => { expect(hasUsageProviderSettingsForProvider('grok', usageSettings())).toBe(false) }) - it('requires both a checked Antigravity item and Gemini OAuth as the durable Antigravity signal', () => { - expect( - hasUsageProviderSettingsForProvider( - 'antigravity', - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) - ) - ).toBe(true) - // Why: the snapshot mirrors the Gemini fetch — without the OAuth opt-in it - // is permanently unavailable, so the checked item alone is not durable. + it('treats a checked Antigravity item as the durable Antigravity signal', () => { expect( hasUsageProviderSettingsForProvider( 'antigravity', usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(false) + ).toBe(true) expect( hasUsageProviderSettingsForProvider( 'antigravity', @@ -345,11 +333,11 @@ describe('getVisibleUsageProvider', () => { ).toBe(null) }) - it('keeps Antigravity visible while the snapshot is pending when checked and Gemini OAuth is on', () => { + it('keeps Antigravity visible while the snapshot is pending when its status item is checked', () => { const visible = getVisibleUsageProvider( 'antigravity', null, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + usageSettings({ antigravityUsageConfigured: true }) ) expect(visible).toMatchObject({ provider: 'antigravity', @@ -359,24 +347,13 @@ describe('getVisibleUsageProvider', () => { }) }) - it('hides Antigravity while Gemini OAuth is off even when its status item is checked', () => { - // Why: without the OAuth opt-in the mirrored snapshot is permanently - // 'unavailable'; the default-on item must not pin a dead bar. + it('hides Antigravity when its status item is not checked', () => { + expect(getVisibleUsageProvider('antigravity', null, usageSettings())).toBe(null) expect( getVisibleUsageProvider( 'antigravity', - null, - usageSettings({ antigravityUsageConfigured: true }) - ) - ).toBe(null) - expect( - getVisibleUsageProvider( - 'antigravity', - provider('unavailable', { - provider: 'antigravity', - error: 'Gemini CLI OAuth is disabled in settings' - }), - usageSettings({ antigravityUsageConfigured: true }) + provider('unavailable', { provider: 'antigravity' }), + usageSettings() ) ).toBe(null) }) @@ -475,26 +452,6 @@ describe('isUsageEmptyState', () => { }) it('does not show the setup CTA while checked Antigravity usage is awaiting a snapshot', () => { - expect( - isUsageEmptyState( - { - claude: provider('unavailable', { provider: 'claude' }), - codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), - opencodeGo: provider('unavailable', { provider: 'opencode-go' }), - kimi: provider('unavailable', { provider: 'kimi' }), - antigravity: null, - grok: provider('unavailable', { provider: 'grok' }), - minimax: provider('unavailable', { provider: 'minimax' }) - }, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) - ) - ).toBe(false) - }) - - it('still shows the setup CTA when Antigravity is checked but Gemini OAuth is off', () => { - // Why: the default-on Antigravity item is not configured usage on its own; - // it must not hide the teaching CTA from users who set nothing up. expect( isUsageEmptyState( { @@ -509,6 +466,6 @@ describe('isUsageEmptyState', () => { }, usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(true) + ).toBe(false) }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index 19258592f1fd..51c95c37d914 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -10,9 +10,7 @@ export type UsageProviderSettings = Pick< > & { // Why: Antigravity has no separate persisted usage credential in Orca. The // checked status-bar item is the durable user signal; StatusBar only sets - // this after PATH detection says the agent is available. Durability further - // requires geminiCliOAuthEnabled — the snapshot mirrors the Gemini fetch, - // which never yields data while that opt-in is off. + // this after PATH detection says the agent is available. antigravityUsageConfigured: boolean // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean @@ -75,8 +73,7 @@ export function hasUsageProviderSettings( (settings?.claudeManagedAccounts?.length ?? 0) > 0 || settings?.geminiCliOAuthEnabled === true || Boolean(settings?.opencodeSessionCookie?.trim()) || - // Antigravity's durable signal requires geminiCliOAuthEnabled, so it is - // already covered by the gemini term above. + settings?.antigravityUsageConfigured === true || settings?.minimaxCookieConfigured === true || settings?.minimaxApiKeyConfigured === true || settings?.grokAuthConfigured === true @@ -103,10 +100,7 @@ export function hasUsageProviderSettingsForProvider( return Boolean(settings.opencodeSessionCookie?.trim()) } if (providerId === 'antigravity') { - // Why: the Antigravity snapshot mirrors the Gemini fetch, which stays - // 'unavailable' until the user opts into Gemini CLI OAuth. Without that - // gate the default-on checked item would pin a permanently dead bar. - return settings.antigravityUsageConfigured === true && settings.geminiCliOAuthEnabled === true + return settings.antigravityUsageConfigured === true } if (providerId === 'minimax') { return settings.minimaxCookieConfigured === true || settings.minimaxApiKeyConfigured === true diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts index cbcfcda04929..2f20c8a41f6b 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts @@ -6,7 +6,7 @@ describe('getUsageProviderAccountsSectionId', () => { expect(getUsageProviderAccountsSectionId('claude')).toBe('accounts-claude') expect(getUsageProviderAccountsSectionId('codex')).toBe('accounts-codex') expect(getUsageProviderAccountsSectionId('gemini')).toBe('accounts-gemini') - expect(getUsageProviderAccountsSectionId('antigravity')).toBe('accounts-gemini') + expect(getUsageProviderAccountsSectionId('antigravity')).toBeNull() expect(getUsageProviderAccountsSectionId('opencode-go')).toBe('accounts-opencode-go') expect(getUsageProviderAccountsSectionId('minimax')).toBe('accounts-minimax') expect(getUsageProviderAccountsSectionId('grok')).toBe('accounts-grok') diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts index 59ea71042490..48b47e060876 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -9,8 +9,6 @@ export function getUsageProviderAccountsSectionId( case 'codex': return 'accounts-codex' case 'gemini': - case 'antigravity': - // Why: Antigravity usage currently shares Gemini's OAuth configuration. return 'accounts-gemini' case 'opencode-go': return 'accounts-opencode-go' @@ -18,8 +16,9 @@ export function getUsageProviderAccountsSectionId( return 'accounts-minimax' case 'grok': return 'accounts-grok' + case 'antigravity': case 'kimi': - // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle. + // Why: Agy and Kimi own their CLI credential lifecycle outside Orca Accounts. return null } } From e31d6dc5010cb82498bb5ad40b72dbe3b5cd73f7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 12:40:53 +0000 Subject: [PATCH 2/3] fix(rate-limits): harden Antigravity language server probe --- .../rate-limits/antigravity-loopback-quota.ts | 79 ++++++++ .../antigravity-usage-fetcher.test.ts | 76 ++++++++ .../rate-limits/antigravity-usage-fetcher.ts | 176 +++++++----------- .../rate-limit-service-test-harness.ts | 5 + .../service-account-target-selection.test.ts | 4 + .../service-antigravity-usage.test.ts | 21 +++ .../service-inactive-account-previews.test.ts | 4 + .../service-live-claude-usage.test.ts | 4 + .../rate-limits/service-minimax-usage.test.ts | 4 + .../service-refresh-orchestration.test.ts | 4 + .../service-window-activation.test.ts | 4 + 11 files changed, 270 insertions(+), 111 deletions(-) create mode 100644 src/main/rate-limits/antigravity-loopback-quota.ts diff --git a/src/main/rate-limits/antigravity-loopback-quota.ts b/src/main/rate-limits/antigravity-loopback-quota.ts new file mode 100644 index 000000000000..3dfc9a40976a --- /dev/null +++ b/src/main/rate-limits/antigravity-loopback-quota.ts @@ -0,0 +1,79 @@ +import http from 'node:http' +import https from 'node:https' + +export const ANTIGRAVITY_LOOPBACK_HOST = '127.0.0.1' +const REQUEST_TIMEOUT_MS = 2_500 +const MAX_RESPONSE_BYTES = 1_048_576 + +export function postAntigravityLoopbackQuota( + url: string, + timeoutMs = REQUEST_TIMEOUT_MS, + maxBytes = MAX_RESPONSE_BYTES +): Promise { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return Promise.resolve(null) + } + if ( + (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || + parsed.hostname !== ANTIGRAVITY_LOOPBACK_HOST + ) { + return Promise.resolve(null) + } + const isHttps = parsed.protocol === 'https:' + const requestOptions: https.RequestOptions = { + protocol: parsed.protocol, + hostname: ANTIGRAVITY_LOOPBACK_HOST, + port: parsed.port, + path: `${parsed.pathname}${parsed.search}`, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1' } + } + // Why: Agy presents a self-signed cert on 127.0.0.1 only. + if (isHttps) { + requestOptions.rejectUnauthorized = false + } + return new Promise((resolve) => { + let settled = false + let request: http.ClientRequest | undefined + const chunks: Buffer[] = [] + let bytes = 0 + const finish = (value: unknown) => { + if (!settled) { + settled = true + clearTimeout(deadline) + request?.destroy() + resolve(value) + } + } + const deadline = setTimeout(() => finish(null), timeoutMs) + request = (isHttps ? https : http).request(requestOptions, (response) => { + response.on('data', (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + bytes += buffer.length + if (bytes > maxBytes) { + finish(null) + return + } + chunks.push(buffer) + }) + response.on('end', () => { + if ((response.statusCode ?? 0) < 200 || (response.statusCode ?? 0) >= 300) { + finish(null) + return + } + try { + finish(JSON.parse(Buffer.concat(chunks).toString('utf8'))) + } catch { + finish(null) + } + }) + response.on('error', () => finish(null)) + response.on('aborted', () => finish(null)) + }) + request.on('error', () => finish(null)) + request.end('{"forceRefresh":true}') + }) +} diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts index f58569e49d51..8414747c8c0b 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.test.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -1,5 +1,8 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { once } from 'node:events' import { describe, expect, it } from 'vitest' import { + ANTIGRAVITY_USAGE_PROBE_FAILED, ANTIGRAVITY_USAGE_UNAVAILABLE, fetchAntigravityRateLimits, mapAntigravityQuotaSummary, @@ -136,10 +139,22 @@ describe('fetchAntigravityRateLimits', () => { expect(limits.status).toBe('unavailable') expect(limits.provider).toBe('antigravity') expect(limits.error).toBe(ANTIGRAVITY_USAGE_UNAVAILABLE) + expect(limits.usageMetadata?.failureKind).toBe('cli-unavailable') expect(limits.session).toBeNull() expect(limits.weekly).toBeNull() }) + it('reports a probe error when a LanguageServer is found but quota is not returned', async () => { + const limits = await fetchAntigravityRateLimits({ + endpoint, + postQuota: async () => null + }) + expect(limits.status).toBe('error') + expect(limits.error).toBe(ANTIGRAVITY_USAGE_PROBE_FAILED) + expect(limits.usageMetadata?.failureKind).toBe('network') + expect(limits.session).toBeNull() + }) + it('falls back to the HTTPS loopback port when HTTP does not answer', async () => { const posted: string[] = [] const limits = await fetchAntigravityRateLimits({ @@ -159,3 +174,64 @@ describe('fetchAntigravityRateLimits', () => { expect(limits.status).toBe('ok') }) }) + +async function listen( + handler: (request: IncomingMessage, response: ServerResponse) => void +): Promise<{ port: number; close: () => Promise }> { + const server = createServer(handler) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('expected a TCP listen address') + } + return { + port: address.port, + close: async () => { + server.closeAllConnections() + server.close() + await once(server, 'close') + } + } +} + +describe('Antigravity loopback quota request limits', () => { + it('aborts an oversized quota response and reports a probe failure', async () => { + const { port, close } = await listen((_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end('{"response":{"groups":[]}}'.padEnd(64, '0')) + }) + try { + const limits = await fetchAntigravityRateLimits({ + endpoint: { pid: 4242, httpPort: port, httpsPort: null }, + maxResponseBytes: 16 + }) + expect(limits.status).toBe('error') + expect(limits.error).toBe(ANTIGRAVITY_USAGE_PROBE_FAILED) + } finally { + await close() + } + }) + + it('aborts a continuously streaming quota response on the wall-clock deadline', async () => { + const { port, close } = await listen((_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }) + const timer = setInterval(() => { + response.write(' ') + }, 10) + response.on('close', () => clearInterval(timer)) + }) + try { + const started = Date.now() + const limits = await fetchAntigravityRateLimits({ + endpoint: { pid: 4242, httpPort: port, httpsPort: null }, + requestTimeoutMs: 80 + }) + expect(Date.now() - started).toBeLessThan(1_000) + expect(limits.status).toBe('error') + expect(limits.error).toBe(ANTIGRAVITY_USAGE_PROBE_FAILED) + } finally { + await close() + } + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index 81bc0262622a..d27c688c541b 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -1,18 +1,18 @@ import { closeSync, openSync, readSync, readdirSync } from 'node:fs' -import http from 'node:http' -import https from 'node:https' import { homedir } from 'node:os' import path from 'node:path' import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import { + ANTIGRAVITY_LOOPBACK_HOST, + postAntigravityLoopbackQuota +} from './antigravity-loopback-quota' const QUOTA_PATH = '/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary' -const LOOPBACK_HOST = '127.0.0.1' -const REQUEST_TIMEOUT_MS = 2_500 -const SESSION_WINDOW_MINUTES = 300 -const WEEKLY_WINDOW_MINUTES = 10_080 export const ANTIGRAVITY_USAGE_UNAVAILABLE = 'Antigravity usage is not available. Start agy so Orca can read its quota.' +export const ANTIGRAVITY_USAGE_PROBE_FAILED = + 'Antigravity usage is not available. The local Agy LanguageServer did not return quota.' export type AntigravityLanguageServerEndpoint = { pid: number @@ -24,24 +24,30 @@ export type AntigravityUsageFetchDeps = { endpoint?: AntigravityLanguageServerEndpoint | null postQuota?: (url: string) => Promise now?: () => number + requestTimeoutMs?: number + maxResponseBytes?: number } -type Cadence = 'session' | 'weekly' -type CadenceWindow = RateLimitWindow & { cadence: Cadence } +type CadenceWindow = RateLimitWindow & { cadence: 'session' | 'weekly' } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function unavailable(now: number): ProviderRateLimits { +function failedLimits( + now: number, + error: string, + status: 'unavailable' | 'error', + failureKind: 'cli-unavailable' | 'network' +): ProviderRateLimits { return { provider: 'antigravity', session: null, weekly: null, updatedAt: now, - error: ANTIGRAVITY_USAGE_UNAVAILABLE, - status: 'unavailable', - usageMetadata: { source: 'cli', failureKind: 'cli-unavailable' } + error, + status, + usageMetadata: { source: 'cli', failureKind } } } @@ -83,12 +89,11 @@ function windowFromBucket(raw: unknown): CadenceWindow | null { const id = typeof raw.bucketId === 'string' ? raw.bucketId : '' const name = typeof raw.displayName === 'string' ? raw.displayName : id const text = `${id} ${name}`.toLowerCase() - const cadence: Cadence | null = - text.includes('weekly') || text.includes('7d') || text.includes('7-day') - ? 'weekly' - : text.includes('5h') || text.includes('five hour') || text.includes('five-hour') - ? 'session' - : null + const cadence: CadenceWindow['cadence'] | null = /weekly|7d|7-day/.test(text) + ? 'weekly' + : /5h|five[- ]hour/.test(text) + ? 'session' + : null if (!cadence) { return null } @@ -96,26 +101,30 @@ function windowFromBucket(raw: unknown): CadenceWindow | null { return { cadence, usedPercent: Math.min(100, Math.max(0, Math.round((1 - remaining) * 100))), - windowMinutes: cadence === 'weekly' ? WEEKLY_WINDOW_MINUTES : SESSION_WINDOW_MINUTES, + windowMinutes: cadence === 'weekly' ? 10_080 : 300, resetsAt: Number.isNaN(resetTime) ? null : resetTime, resetDescription: null } } -function tightest(windows: CadenceWindow[], cadence: Cadence): RateLimitWindow | null { - const matches = windows.filter((window) => window.cadence === cadence) - if (matches.length === 0) { - return null - } - const chosen = matches.reduce((worst, window) => - window.usedPercent > worst.usedPercent ? window : worst - ) - return { - usedPercent: chosen.usedPercent, - windowMinutes: chosen.windowMinutes, - resetsAt: chosen.resetsAt, - resetDescription: chosen.resetDescription +function tightest( + windows: CadenceWindow[], + cadence: CadenceWindow['cadence'] +): RateLimitWindow | null { + let chosen: CadenceWindow | null = null + for (const window of windows) { + if (window.cadence === cadence && (!chosen || window.usedPercent > chosen.usedPercent)) { + chosen = window + } } + return chosen + ? { + usedPercent: chosen.usedPercent, + windowMinutes: chosen.windowMinutes, + resetsAt: chosen.resetsAt, + resetDescription: chosen.resetDescription + } + : null } export function mapAntigravityQuotaSummary(data: unknown): ProviderRateLimits | null { @@ -199,92 +208,30 @@ function discoverCurrentEndpoint(): AntigravityLanguageServerEndpoint | null { return null } -function isLoopbackHttpUrl(raw: string): boolean { - try { - const url = new URL(raw) - return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname === LOOPBACK_HOST - } catch { - return false - } -} - -function postLoopbackQuota(url: string): Promise { - if (!isLoopbackHttpUrl(url)) { - return Promise.resolve(null) - } - const parsed = new URL(url) - const requestOptions: https.RequestOptions = { - protocol: parsed.protocol, - hostname: LOOPBACK_HOST, - port: parsed.port, - path: `${parsed.pathname}${parsed.search}`, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Connect-Protocol-Version': '1' - }, - timeout: REQUEST_TIMEOUT_MS - } - if (parsed.protocol === 'https:') { - // Why: Agy presents a self-signed cert on 127.0.0.1 only. - requestOptions.rejectUnauthorized = false - } - const transport = parsed.protocol === 'https:' ? https : http - return new Promise((resolve) => { - const request = transport.request(requestOptions, (response) => { - const chunks: Buffer[] = [] - response.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) - }) - response.on('end', () => { - const status = response.statusCode ?? 0 - if (status < 200 || status >= 300) { - resolve(null) - return - } - try { - const body: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')) - resolve(body) - } catch { - resolve(null) - } - }) - response.on('error', () => resolve(null)) - }) - request.on('timeout', () => { - request.destroy() - resolve(null) - }) - request.on('error', () => resolve(null)) - request.end('{"forceRefresh":true}') - }) -} - async function probeQuota( endpoint: AntigravityLanguageServerEndpoint, postQuota: (url: string) => Promise, now: () => number ): Promise { - const urls: string[] = [] - if (endpoint.httpPort !== null) { - urls.push(`http://${LOOPBACK_HOST}:${endpoint.httpPort}${QUOTA_PATH}`) - } - if (endpoint.httpsPort !== null) { - urls.push(`https://${LOOPBACK_HOST}:${endpoint.httpsPort}${QUOTA_PATH}`) - } + const urls = [ + ...(endpoint.httpPort === null + ? [] + : [`http://${ANTIGRAVITY_LOOPBACK_HOST}:${endpoint.httpPort}${QUOTA_PATH}`]), + ...(endpoint.httpsPort === null + ? [] + : [`https://${ANTIGRAVITY_LOOPBACK_HOST}:${endpoint.httpsPort}${QUOTA_PATH}`]) + ] for (const url of urls) { - let json: unknown try { - json = await postQuota(url) + const mapped = mapAntigravityQuotaSummary(await postQuota(url)) + if (mapped) { + return mapped + } } catch { - json = null - } - const mapped = json === null ? null : mapAntigravityQuotaSummary(json) - if (mapped) { - return mapped + continue } } - return unavailable(now()) + return failedLimits(now(), ANTIGRAVITY_USAGE_PROBE_FAILED, 'error', 'network') } export function fetchAntigravityRateLimits( @@ -293,7 +240,14 @@ export function fetchAntigravityRateLimits( const now = deps.now ?? Date.now const endpoint = deps.endpoint !== undefined ? deps.endpoint : discoverCurrentEndpoint() if (!endpoint) { - return Promise.resolve(unavailable(now())) - } - return probeQuota(endpoint, deps.postQuota ?? postLoopbackQuota, now) + return Promise.resolve( + failedLimits(now(), ANTIGRAVITY_USAGE_UNAVAILABLE, 'unavailable', 'cli-unavailable') + ) + } + return probeQuota( + endpoint, + deps.postQuota ?? + ((url) => postAntigravityLoopbackQuota(url, deps.requestTimeoutMs, deps.maxResponseBytes)), + now + ) } diff --git a/src/main/rate-limits/rate-limit-service-test-harness.ts b/src/main/rate-limits/rate-limit-service-test-harness.ts index 041ede99b628..bc0cf109f2ce 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events' import { vi, type Mock } from 'vitest' import type { ProviderRateLimits } from '../../shared/rate-limit-types' import type { RateLimitService } from './service' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' @@ -89,6 +90,9 @@ export function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0)) vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok')) + vi.mocked(fetchAntigravityRateLimits).mockImplementation(async () => + unavailableProvider('antigravity') + ) } /** Shared `beforeEach` body: healthy stubs for every provider the service polls. */ @@ -106,6 +110,7 @@ export function resetRateLimitProviderMocks(): void { error: null, status: 'unavailable' }) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(unavailableProvider('antigravity')) vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' }) } diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 9241831bdf6a..7e7a45e476fc 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -24,6 +24,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index bd2aad054688..e3a0a16687f2 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs' +import http from 'node:http' +import https from 'node:https' import { beforeEach, describe, expect, it, vi } from 'vitest' import { RateLimitService } from './service' import { fetchClaudeRateLimits } from './claude-fetcher' @@ -91,4 +94,22 @@ describe('RateLimitService Antigravity usage', () => { expect(state.gemini?.status).toBe('ok') expect(state.gemini?.session?.usedPercent).toBe(42) }) + + it('shared harness stub prevents LanguageServer discovery and loopback probes', async () => { + const readdir = vi.spyOn(fs, 'readdirSync') + const httpRequest = vi.spyOn(http, 'request') + const httpsRequest = vi.spyOn(https, 'request') + try { + const service = new RateLimitService() + await service.refresh() + expect(vi.mocked(fetchAntigravityRateLimits)).toHaveBeenCalled() + expect(readdir).not.toHaveBeenCalled() + expect(httpRequest).not.toHaveBeenCalled() + expect(httpsRequest).not.toHaveBeenCalled() + } finally { + readdir.mockRestore() + httpRequest.mockRestore() + httpsRequest.mockRestore() + } + }) }) diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 3c6293513826..517ece536dbe 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -31,6 +31,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index 668cfc113b9a..fdff98df95f1 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -28,6 +28,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts index 819b4c93f5d2..4ff0eace4c38 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -25,6 +25,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 44f4b05b3bac..4aa03e8b2f6e 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -32,6 +32,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index ec813a53d94a..8be43130df15 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -33,6 +33,10 @@ vi.mock('./gemini-usage-fetcher', () => ({ fetchGeminiRateLimits: vi.fn() })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./kimi-fetcher', () => ({ fetchKimiRateLimits: vi.fn() })) From 641a6b92a73f453259131fd3609a1cf8b17f05e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 13:47:03 +0000 Subject: [PATCH 3/3] fix(rate-limits): keep active Antigravity sessions discoverable Co-authored-by: Filipe Moreira --- .../antigravity-usage-fetcher.test.ts | 65 +++++++++++++++++++ .../rate-limits/antigravity-usage-fetcher.ts | 12 ++-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts index 8414747c8c0b..d7b2f0adf85c 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.test.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -1,5 +1,8 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { once } from 'node:events' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { ANTIGRAVITY_USAGE_PROBE_FAILED, @@ -56,6 +59,34 @@ const LIVE_LOG = [ 'listening on random port at 4313 for HTTPS (gRPC)' ].join('\n') +const QUOTA_25_PERCENT_USED = { + response: { + groups: [ + { + displayName: 'Gemini Models', + buckets: [ + { + bucketId: 'gemini-5h', + displayName: 'Five Hour Limit Remaining', + remainingFraction: 0.75, + resetTime: '2026-09-16T01:00:00Z' + } + ] + } + ] + } +} + +/** Above pid_max on every supported POSIX host, so kill(pid, 0) is a real ESRCH. */ +const EXITED_SESSION_PID = 2_147_483_647 + +function languageServerLog(pid: number, httpPort: number): string { + return [ + `Starting language server process with pid ${pid}`, + `listening on random port at ${httpPort} for HTTP` + ].join('\n') +} + describe('parseAntigravityLanguageServerLog', () => { it('reads pid and both loopback ports from an Agy startup banner', () => { expect(parseAntigravityLanguageServerLog(LIVE_LOG)).toEqual({ @@ -235,3 +266,37 @@ describe('Antigravity loopback quota request limits', () => { } }) }) + +describe('Antigravity LanguageServer log discovery', () => { + it('still finds an older live session after 12 newer exited session logs', async () => { + expect(() => process.kill(EXITED_SESSION_PID, 0)).toThrow() + const home = mkdtempSync(join(tmpdir(), 'orca-antigravity-usage-')) + const logDir = join(home, '.gemini', 'antigravity-cli', 'log') + mkdirSync(logDir, { recursive: true }) + let probeCount = 0 + const { port, close } = await listen((_request, response) => { + probeCount += 1 + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify(QUOTA_25_PERCENT_USED)) + }) + try { + writeFileSync( + join(logDir, 'cli-2026-01-01T00-00-00.log'), + languageServerLog(process.pid, port) + ) + for (let index = 0; index < 12; index += 1) { + writeFileSync( + join(logDir, `cli-2026-09-19T00-00-${String(index).padStart(2, '0')}.log`), + languageServerLog(EXITED_SESSION_PID, 65_000) + ) + } + const limits = await fetchAntigravityRateLimits({ homedir: () => home }) + expect(limits.status).toBe('ok') + expect(limits.session?.usedPercent).toBe(25) + expect(probeCount).toBe(1) + } finally { + await close() + rmSync(home, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index d27c688c541b..80e04968afce 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -22,6 +22,7 @@ export type AntigravityLanguageServerEndpoint = { export type AntigravityUsageFetchDeps = { endpoint?: AntigravityLanguageServerEndpoint | null + homedir?: () => string postQuota?: (url: string) => Promise now?: () => number requestTimeoutMs?: number @@ -173,19 +174,21 @@ function isProcessAlive(pid: number): boolean { } } -function discoverCurrentEndpoint(): AntigravityLanguageServerEndpoint | null { - const logDir = path.join(homedir(), '.gemini', 'antigravity-cli', 'log') +function discoverCurrentEndpoint( + resolveHome: () => string = homedir +): AntigravityLanguageServerEndpoint | null { + const logDir = path.join(resolveHome(), '.gemini', 'antigravity-cli', 'log') let names: string[] try { names = readdirSync(logDir) } catch { return null } + // Why: recency is only search order; a live LanguageServer can outlive newer exited logs. names = names .filter((name) => name.startsWith('cli-') && name.endsWith('.log')) .sort() .toReversed() - .slice(0, 12) for (const name of names) { try { const fd = openSync(path.join(logDir, name), 'r') @@ -238,7 +241,8 @@ export function fetchAntigravityRateLimits( deps: AntigravityUsageFetchDeps = {} ): Promise { const now = deps.now ?? Date.now - const endpoint = deps.endpoint !== undefined ? deps.endpoint : discoverCurrentEndpoint() + const endpoint = + deps.endpoint !== undefined ? deps.endpoint : discoverCurrentEndpoint(deps.homedir) if (!endpoint) { return Promise.resolve( failedLimits(now(), ANTIGRAVITY_USAGE_UNAVAILABLE, 'unavailable', 'cli-unavailable')