fix(providers): poll onboardUser LRO before classifying BYOP (#11519)

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.
This commit is contained in:
rifqiawl
2026-08-26 19:21:41 +07:00
committed by GitHub
parent 7a10d3856a
commit e616130496
2 changed files with 224 additions and 34 deletions

View File

@@ -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<string, unknown> | null): string | null {
const nested = (data?.response as Record<string, unknown> | 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<string, unknown>).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<AntigravityOnboardStatus> {
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<string, unknown> | 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";

View File

@@ -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<Response> => {
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<Response> => {
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<Response> => {
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);
});
});