From e6161304969dc7c56f1a22e05ac1fdbdab30cb99 Mon Sep 17 00:00:00 2001 From: rifqiawl Date: Wed, 26 Aug 2026 19:21:41 +0700 Subject: [PATCH] fix(providers): poll onboardUser LRO before classifying BYOP (#11519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged via /merge-batch (lote 2026-08-26 batch 2, v3.8.51). Boarded no worktree combinado junto com outras ~20 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e 292 testes focados (unit) + 18 (vitest) passando. Obrigado pela contribuição. --- .../services/antigravityProjectBootstrap.ts | 126 ++++++++++++----- .../antigravity-onboard-lro-polling.test.ts | 132 ++++++++++++++++++ 2 files changed, 224 insertions(+), 34 deletions(-) create mode 100644 tests/unit/antigravity-onboard-lro-polling.test.ts diff --git a/open-sse/services/antigravityProjectBootstrap.ts b/open-sse/services/antigravityProjectBootstrap.ts index 95d7aaf570..1f548eb0f3 100644 --- a/open-sse/services/antigravityProjectBootstrap.ts +++ b/open-sse/services/antigravityProjectBootstrap.ts @@ -30,6 +30,16 @@ const BOOTSTRAP_TIMEOUT_MS = 8_000; const ONBOARD_TIMEOUT_MS = 15_000; const DEFAULT_TIER_ID = "legacy-tier"; +// onboardUser is a Long-Running Operation: Google frequently answers the +// first call with {"done": false} (no cloudaicompanionProject field yet) and +// expects the SAME request re-sent every couple of seconds until the +// operation settles with {"done": true, response: {...}}. Treating the +// first "done:false" response as "no project" (BYOP) misclassifies a normal +// in-progress onboarding as "bring your own project" and permanently caches +// that wrong verdict. Poll bounded, matching 9router's onboardUser(). +const ONBOARD_POLL_MAX_ATTEMPTS = 5; +const ONBOARD_POLL_INTERVAL_MS = 2_000; + /** Ordered list of loadCodeAssist endpoint URLs. */ export function getAntigravityLoadCodeAssistUrls(): string[] { return ANTIGRAVITY_BOOTSTRAP_BASE_URLS.map((base) => `${base}${LOAD_CODE_ASSIST_PATH}`); @@ -162,10 +172,31 @@ async function tryLoadCodeAssist( return { projectId: null, tierId: DEFAULT_TIER_ID }; } +/** + * Extract the project id from a settled ({done:true}) onboardUser response body. + * The documented LRO shape nests it under `response.cloudaicompanionProject` + * (matches 9router's onboardUser and Google's Operation envelope), but some + * observed responses put it at the top level — check both. + */ +function extractProjectIdFromOnboardResponse(data: Record | null): string | null { + const nested = (data?.response as Record | undefined)?.cloudaicompanionProject; + const project = nested ?? data?.cloudaicompanionProject; + if (typeof project === "string") { + const id = project.trim(); + return id || null; + } + if (project && typeof project === "object") { + const id = (project as Record).id; + if (typeof id === "string" && id.trim()) return id.trim(); + } + return null; +} + /** * Attempt onboardUser to create a Cloud Code project for the account. * Called when loadCodeAssist returns no project — the account has never - * been onboarded. Returns true if any endpoint reports success. + * been onboarded. Polls the same endpoint on {done:false} responses (an + * in-progress LRO) before concluding anything about the account. */ async function tryOnboardUser( accessToken: string, @@ -176,47 +207,74 @@ async function tryOnboardUser( ): Promise { const urls = getAntigravityOnboardUrls(); const headers = getAntigravityContentHeaders(clientProfile, accessToken); + const body = JSON.stringify({ + tier_id: tierId, + metadata: getAntigravityLoadCodeAssistMetadata(), + }); for (const url of urls) { - if (signal?.aborted) throw signal.reason; - try { - const timeoutSignal = AbortSignal.timeout(ONBOARD_TIMEOUT_MS); - const response = await fetchImpl(url, { - method: "POST", - headers, - body: JSON.stringify({ - tier_id: tierId, - metadata: getAntigravityLoadCodeAssistMetadata(), - }), - signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal, - }); + for (let attempt = 1; attempt <= ONBOARD_POLL_MAX_ATTEMPTS; attempt++) { + if (signal?.aborted) throw signal.reason; + try { + const timeoutSignal = AbortSignal.timeout(ONBOARD_TIMEOUT_MS); + const response = await fetchImpl(url, { + method: "POST", + headers, + body, + signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal, + }); - if (response.ok) { - // Accounts Google expects to Bring Their Own Project: onboardUser - // returns 200 without a `cloudaicompanionProject` in the body — no - // automatic project creation for standard-tier/personal accounts - // (tracked in #8491). Detect that so we can fail fast with a clear - // instruction instead of retrying forever or fabricating an id that - // Google later rejects with a delayed 429 RESOURCE_EXHAUSTED. - const body = await response.text().catch(() => ""); - if (body && !/cloudaicompanionProject/.test(body)) { + if (!response.ok) { console.warn( - `[models] antigravity onboardUser done but no project in response at ${url} — Google BYOP (user-defined GCP project) required` + `[models] antigravity onboardUser failed at ${url} (${response.status}) — trying next` ); - return "requires_manual_project"; + break; } - return "onboarded"; - } - console.warn( - `[models] antigravity onboardUser failed at ${url} (${response.status}) — trying next` - ); - } catch (error) { - if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) { - throw signal?.reason ?? error; + const data = (await response.json().catch(() => null)) as Record | null; + + // Only an EXPLICIT `done: false` means "in-progress LRO, poll again". + // A proper Google Operation always carries `done` when it is one; a + // response with `done` absent entirely (e.g. `{}`) is not an LRO in + // progress — it's Google's immediate, settled "no project" answer for + // BYOP accounts (#8491) and must fall through to that classification + // on the first attempt, same as before this polling was added. + if (data?.done === false) { + // In-progress LRO — Google hasn't decided (project created, or + // BYOP required) yet. Re-send the same request after a short wait. + if (attempt < ONBOARD_POLL_MAX_ATTEMPTS) { + console.warn( + `[models] antigravity onboardUser at ${url} not done yet (attempt ${attempt}/${ONBOARD_POLL_MAX_ATTEMPTS}) — waiting` + ); + await new Promise((resolve) => setTimeout(resolve, ONBOARD_POLL_INTERVAL_MS)); + continue; + } + console.warn( + `[models] antigravity onboardUser at ${url} still not done after ${ONBOARD_POLL_MAX_ATTEMPTS} attempts — treating as failed` + ); + break; + } + + // done:true — Google has settled the operation. Accounts Google + // expects to Bring Their Own Project answer with done:true and no + // cloudaicompanionProject — no automatic project creation for + // standard-tier/personal accounts (tracked in #8491). Only now is it + // safe to draw that conclusion. + if (extractProjectIdFromOnboardResponse(data)) { + return "onboarded"; + } + console.warn( + `[models] antigravity onboardUser done but no project in response at ${url} — Google BYOP (user-defined GCP project) required` + ); + return "requires_manual_project"; + } catch (error) { + if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) { + throw signal?.reason ?? error; + } + const msg = error instanceof Error ? error.message : String(error); + console.warn(`[models] antigravity onboardUser threw for ${url}: ${msg} — trying next`); + break; } - const msg = error instanceof Error ? error.message : String(error); - console.warn(`[models] antigravity onboardUser threw for ${url}: ${msg} — trying next`); } } return "failed"; diff --git a/tests/unit/antigravity-onboard-lro-polling.test.ts b/tests/unit/antigravity-onboard-lro-polling.test.ts new file mode 100644 index 0000000000..89bba52014 --- /dev/null +++ b/tests/unit/antigravity-onboard-lro-polling.test.ts @@ -0,0 +1,132 @@ +/** + * Tests: onboardUser Long-Running-Operation polling in + * ensureAntigravityProjectAssigned. + * + * onboardUser is a Google LRO: the first call frequently answers with + * {"done": false} (no cloudaicompanionProject field yet) and expects the + * SAME request re-sent every couple of seconds until the operation settles + * with {"done": true, response: {...}}. Treating that first "done:false" + * response as "no project" (BYOP) misclassifies a normal in-progress + * onboarding as "bring your own project" and permanently caches the wrong + * verdict for the process lifetime — every subsequent request for that + * account 422s with "Missing Google projectId" even though onboarding would + * have succeeded on the next poll. Reproduces the exact symptom reported in + * https://github.com/diegosouzapw/OmniRoute/issues/11379 (repeated + * "loadCodeAssist ... returned no project id" across reconnect/restart). + * + * These tests verify: + * 1. A {"done": false} response is polled (same endpoint, re-sent) rather + * than immediately classified as BYOP. + * 2. A subsequent {"done": true, response: {cloudaicompanionProject}} + * resolves the project id. + * 3. A genuinely empty {} response (no `done` field at all — not an LRO in + * progress, Google's immediate "no project" answer) is still classified + * as BYOP on the FIRST attempt, unchanged from before this fix — this is + * the case #8491 introduced, and must not regress. + * 4. Polling is bounded (does not loop forever on a stuck operation). + */ + +import { test, describe, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +import { + ensureAntigravityProjectAssigned, + clearAntigravityProjectCache, + ANTIGRAVITY_REQUIRES_MANUAL_PROJECT, +} from "../../open-sse/services/antigravityProjectBootstrap.ts"; + +beforeEach(() => { + clearAntigravityProjectCache(); +}); + +describe("ensureAntigravityProjectAssigned — onboardUser LRO polling", () => { + test("polls a {done:false} response instead of treating it as BYOP", async () => { + let onboardCalls = 0; + let onboarded = false; + + const mockFetch = async (url: string): Promise => { + if (url.includes("loadCodeAssist")) { + // Post-onboard retry (ensureAntigravityProjectAssigned re-calls + // loadCodeAssist once onboardUser reports success) now finds the + // freshly-created project. + return onboarded + ? new Response(JSON.stringify({ cloudaicompanionProject: "proj-after-poll" }), { + status: 200, + }) + : new Response(JSON.stringify({}), { status: 200 }); + } + if (url.includes("onboardUser")) { + onboardCalls++; + if (onboardCalls === 1) { + return new Response(JSON.stringify({ done: false }), { status: 200 }); + } + onboarded = true; + return new Response( + JSON.stringify({ done: true, response: { cloudaicompanionProject: "proj-after-poll" } }), + { status: 200 } + ); + } + return new Response("not found", { status: 404 }); + }; + + const projectId = await ensureAntigravityProjectAssigned( + "test-token", + mockFetch as typeof fetch, + "ide" + ); + + assert.equal(onboardCalls, 2, "should have polled onboardUser a second time"); + assert.equal(projectId, "proj-after-poll"); + }); + + test("still classifies a genuinely empty {} response as BYOP on the first attempt (#8491, no regression)", async () => { + let onboardCalls = 0; + + const mockFetch = async (url: string): Promise => { + if (url.includes("loadCodeAssist")) { + return new Response(JSON.stringify({}), { status: 200 }); + } + if (url.includes("onboardUser")) { + onboardCalls++; + return new Response(JSON.stringify({}), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }; + + const result = await ensureAntigravityProjectAssigned( + "test-token", + mockFetch as typeof fetch, + "ide" + ); + + assert.equal(onboardCalls, 1, "an absent `done` field must not trigger polling"); + assert.equal(result, ANTIGRAVITY_REQUIRES_MANUAL_PROJECT); + }); + + test("bounds polling instead of looping forever on a stuck operation", async () => { + let onboardCalls = 0; + + const mockFetch = async (url: string): Promise => { + if (url.includes("loadCodeAssist")) { + return new Response(JSON.stringify({}), { status: 200 }); + } + if (url.includes("onboardUser")) { + onboardCalls++; + return new Response(JSON.stringify({ done: false }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }; + + const result = await ensureAntigravityProjectAssigned( + "test-token", + mockFetch as typeof fetch, + "ide" + ); + + assert.ok( + onboardCalls > 1 && onboardCalls <= 5, + `expected bounded polling, got ${onboardCalls} calls` + ); + assert.equal(result, undefined); + }); +});