* fix(#3086): apply #2667 .cmd-shim gate to deps.spawn + surface errorCode in review lanes deps.spawn used shell:false with a bare binary name — on Windows, npm-installed CLIs (gemini, codex, etc.) are .cmd shims that CreateProcess cannot start, producing ENOENT + empty stderr. The review path then wrote an empty err file and emitted a generic 'failed or returned empty output' stub. Two fixes: 1. deps.spawn: detect .cmd/.bat on win32 and mediate through cmd.exe /d /s /c (same gate as runWithTimeout #2667, same explicit argv array). 2. runSpawnLane: surface errorCode (ENOENT, ETIMEDOUT) in the err file so the stub explains WHY the lane produced nothing. * chore(#3086): backfill changeset PR number 3142 --------- Co-authored-by: sim <sim@local>
720 lines
32 KiB
TypeScript
720 lines
32 KiB
TypeScript
/**
|
||
* Reviewer Lane Runner (ADR-2782 Phase 5b, #2799).
|
||
*
|
||
* Executes an invocation plan from `review-lane-invocation.cts`: probes the lane, spawns the binary
|
||
* or calls the HTTP endpoint, applies the empty-output policy, and dispatches the three first-party
|
||
* handlers D6 names. This is the module that replaces ~640 lines of hand-authored per-CLI bash.
|
||
*
|
||
* THREE RUNTIME DEPENDENCIES DISAPPEAR HERE, and that is a correctness win rather than a tidy-up:
|
||
* - `jq` — five legs needed it on PATH; it is absent on stock Windows/Git-Bash (#2589). Parsing is
|
||
* `JSON.parse` now.
|
||
* - `curl` — the three OpenAI-compatible legs shelled out to it. Node's `fetch` replaces it, and
|
||
* the raw response body (where an OpenAI-compatible server puts its error JSON on a 4xx/5xx
|
||
* while curl still exits 0) is no longer discarded by a pipe.
|
||
* - external `timeout` / `gtimeout` — the Antigravity leg probed for one because
|
||
* `--print-timeout` cannot fire before a session exists, and STOCK MACOS SHIPS NEITHER, so on a
|
||
* stock Mac that leg ran unbounded. `spawnSync`'s native `timeout` is always available, so D7's
|
||
* "skip the probe where no bounding mechanism exists" carve-out is no longer needed.
|
||
*
|
||
* EVERY subprocess call in this file passes `timeout` + `killSignal` + `maxBuffer`. This repo has a
|
||
* named `DEFECT.UNBOUNDED-SUBPROCESS` class (`CONTEXT.md:772`) and an unbounded sync spawn is not a
|
||
* slow test, it is a hung one: `--test-force-exit` cannot interrupt a synchronous call, so a frozen
|
||
* spawn hangs the whole CI chunk to its 10-minute kill with `# fail 0` and no `not ok` (#2099).
|
||
*
|
||
* TOTAL. No function here throws for a lane-level failure; every one returns a typed outcome. A
|
||
* reviewer lane that cannot run must produce a diagnosable artifact, because the ambiguity between
|
||
* "failed" and "ran cleanly with nothing to report" IS the defect this epic closes (#2494/#2605).
|
||
*/
|
||
|
||
import type { LanePlan, SpawnPlan, HttpPlan, LaneUnavailableReason } from './review-lane-invocation.cjs';
|
||
import {
|
||
LANE_UNAVAILABLE,
|
||
isEmptyReview,
|
||
normalizeHost,
|
||
fileRefPrompt as fileRefPromptText,
|
||
} from './review-lane-invocation.cjs';
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Injected seams
|
||
* ------------------------------------------------------------------ */
|
||
|
||
export interface SpawnOutcome {
|
||
status: number | null;
|
||
stdout: string;
|
||
stderr: string;
|
||
/** Node sets this to ETIMEDOUT when `timeout` fired. */
|
||
errorCode?: string;
|
||
}
|
||
|
||
export interface RunnerDeps {
|
||
/** Bounded synchronous spawn. Production wires `child_process.spawnSync`. */
|
||
spawn: (binary: string, argv: string[], opts: { input?: string; timeoutMs: number }) => SpawnOutcome;
|
||
/** Bounded HTTP POST/GET returning the RAW body — never pre-parsed, so errors stay diagnosable. */
|
||
httpJson: (url: string, opts: { method: 'GET' | 'POST'; body?: string; timeoutMs: number }) =>
|
||
Promise<{ ok: boolean; status: number; body: string; error?: string }>;
|
||
readFile: (p: string) => string;
|
||
writeFile: (p: string, content: string) => void;
|
||
exists: (p: string) => boolean;
|
||
/** `command -v` equivalent. */
|
||
hasBinary: (name: string) => boolean;
|
||
/** Resolved config lookup, for the egress-host re-verification. */
|
||
configGet: (key: string) => unknown;
|
||
/** Home dir, for the Antigravity transcript paths. */
|
||
homeDir: string;
|
||
warn: (msg: string) => void;
|
||
}
|
||
|
||
export interface LaneRunResult {
|
||
slug: string;
|
||
ok: boolean;
|
||
/** Present when `ok` is false. */
|
||
reason?: LaneUnavailableReason;
|
||
detail?: string;
|
||
/** True when a diagnostic stub was written instead of a real review. */
|
||
stubbed: boolean;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* D5 rules 2–4 — the egress destination is re-verified at INVOCATION
|
||
* ------------------------------------------------------------------ */
|
||
|
||
export interface EgressCheck {
|
||
allowed: boolean;
|
||
consentedHost?: string;
|
||
currentHost?: string;
|
||
}
|
||
|
||
/**
|
||
* Compare a lane's re-resolved egress destination against the one the user consented to.
|
||
*
|
||
* ADR-2782 D5 puts this on the invocation path deliberately. `hostConfigKey` names a key in
|
||
* `.planning/config.json`, which is the ONE consent-bound value living outside the SHA-pinned
|
||
* bundle: user- and CI-editable at any time, with no re-install and no integrity check. Without
|
||
* this check a lane consented against `http://localhost:8080` could be silently redirected to a
|
||
* remote host by an ordinary pull request touching a JSON file, and every subsequent review would
|
||
* egress plans, requirements, research and decisions to the new destination with no re-prompt.
|
||
*
|
||
* ABSENCE IS NOT A MISMATCH, and this is the part that must not be "hardened" later by someone who
|
||
* reads only the rule name:
|
||
* - No consent record at all ⇒ ALLOW. First-party lanes (`ollama`, `lm_studio`, `llama_cpp`) ship
|
||
* inside the SHA-pinned distribution and are never consent-gated, so blocking on a missing
|
||
* record would break every existing local-model user on upgrade.
|
||
* - A consent record predating this feature ⇒ ALLOW. Those records were written before a host was
|
||
* recorded; treating the absence as a change would force spurious re-consent across every
|
||
* installed capability, which ADR-2782 D4 rule 5 explicitly forbids.
|
||
*
|
||
* Hosts are compared NORMALIZED, so a trailing slash or an explicit `:80` is not a "destination
|
||
* change". A warning that fires on cosmetic edits is a warning users learn to dismiss.
|
||
*/
|
||
export function checkEgressHost(consentedHost: unknown, currentHost: string): EgressCheck {
|
||
const consented = typeof consentedHost === 'string' ? normalizeHost(consentedHost) : '';
|
||
if (!consented) return { allowed: true };
|
||
const current = normalizeHost(currentHost);
|
||
if (consented === current) return { allowed: true, consentedHost: consented, currentHost: current };
|
||
return { allowed: false, consentedHost: consented, currentHost: current };
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Probe (D7) — every probe bounded, and only for a SELECTED lane
|
||
* ------------------------------------------------------------------ */
|
||
|
||
export async function probeLane(
|
||
plan: LanePlan,
|
||
deps: RunnerDeps,
|
||
): Promise<{ available: true } | { available: false; reason: LaneUnavailableReason; detail: string }> {
|
||
// Prerequisites first: a missing `jq`-style helper is a clearer failure than whatever the tool
|
||
// does without it, and reporting it costs nothing.
|
||
for (const bin of plan.requiresBinaries) {
|
||
if (!deps.hasBinary(bin)) {
|
||
return {
|
||
available: false,
|
||
reason: LANE_UNAVAILABLE.MISSING_REQUIRED_BINARY,
|
||
detail: `lane '${plan.slug}' requires '${bin}' on PATH`,
|
||
};
|
||
}
|
||
}
|
||
|
||
const probe = plan.probe;
|
||
if (!probe || typeof probe !== 'object') {
|
||
return { available: false, reason: LANE_UNAVAILABLE.PROBE_FAILED, detail: 'lane declares no probe' };
|
||
}
|
||
|
||
switch (probe.kind) {
|
||
case 'command-exists':
|
||
return deps.hasBinary(probe.binary)
|
||
? { available: true }
|
||
: {
|
||
available: false,
|
||
reason: LANE_UNAVAILABLE.MISSING_BINARY,
|
||
detail: `'${probe.binary}' not found on PATH`,
|
||
};
|
||
|
||
case 'command-capability': {
|
||
// Existence alone is structurally insufficient here: `kimi` is claimed by BOTH the Kimi Code
|
||
// CLI and the legacy Python kimi-cli, and an existence-only probe registers the wrong tool.
|
||
if (!deps.hasBinary(probe.binary)) {
|
||
return {
|
||
available: false,
|
||
reason: LANE_UNAVAILABLE.MISSING_BINARY,
|
||
detail: `'${probe.binary}' not found on PATH`,
|
||
};
|
||
}
|
||
const out = deps.spawn(probe.binary, ['--help'], { timeoutMs: probe.timeoutMs });
|
||
if (out.errorCode === 'ETIMEDOUT') {
|
||
return {
|
||
available: false,
|
||
reason: LANE_UNAVAILABLE.PROBE_TIMEOUT,
|
||
detail: `'${probe.binary} --help' exceeded ${probe.timeoutMs}ms`,
|
||
};
|
||
}
|
||
const help = `${out.stdout}\n${out.stderr}`;
|
||
return help.includes(probe.needle)
|
||
? { available: true }
|
||
: {
|
||
available: false,
|
||
reason: LANE_UNAVAILABLE.PROBE_FAILED,
|
||
detail: `'${probe.binary}' is on PATH but its --help lacks '${probe.needle}' — this is a different tool sharing the name`,
|
||
};
|
||
}
|
||
|
||
case 'http-reachable': {
|
||
// Only a STRING config value names a host. `String(unknown)` would turn an object into the
|
||
// literal '[object Object]' and probe that as a URL — a nonsense destination reported as
|
||
// "unreachable" rather than as the misconfiguration it is.
|
||
const configured = deps.configGet(probe.hostConfigKey);
|
||
const base = normalizeHost(
|
||
(typeof configured === 'string' ? configured : '') ||
|
||
(plan.transport === 'openai-http' ? plan.host : ''),
|
||
);
|
||
if (!base) {
|
||
return { available: false, reason: LANE_UNAVAILABLE.HOST_UNREACHABLE, detail: 'no host resolved' };
|
||
}
|
||
const r = await deps.httpJson(`${base}${probe.path}`, { method: 'GET', timeoutMs: probe.timeoutMs });
|
||
return r.ok
|
||
? { available: true }
|
||
: {
|
||
available: false,
|
||
reason: LANE_UNAVAILABLE.HOST_UNREACHABLE,
|
||
detail: `${base}${probe.path} unreachable: ${r.error ?? `HTTP ${r.status}`}`,
|
||
};
|
||
}
|
||
|
||
default:
|
||
return {
|
||
available: false,
|
||
reason: LANE_UNAVAILABLE.PROBE_FAILED,
|
||
detail: `unknown probe kind '${String((probe as { kind?: unknown }).kind)}'`,
|
||
};
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Empty-output policy (#2494 / #2605 / #2794)
|
||
* ------------------------------------------------------------------ */
|
||
|
||
/**
|
||
* Write the review, or a diagnostic stub when the lane produced nothing usable.
|
||
*
|
||
* The stub is not cosmetic. Before #2494/#2605 a failed lane left a zero-byte file, `write_reviews`
|
||
* rendered it as "a reviewer that ran cleanly with nothing to report", and the lane vanished from
|
||
* the cross-AI consensus while `present_results` reported success — a review blind in one eye. The
|
||
* stub keeps its "failed or returned empty output" header precisely so it can never be mistaken for
|
||
* a real review.
|
||
*
|
||
* `extraDiagnostics` carries the raw HTTP response body for the OpenAI-compatible lanes: an error
|
||
* from such a server arrives with HTTP 4xx/5xx and the JSON in the BODY, so stderr alone is empty
|
||
* and the body is the only evidence.
|
||
*/
|
||
export function writeReviewOrStub(
|
||
plan: LanePlan,
|
||
content: string,
|
||
deps: RunnerDeps,
|
||
extraDiagnostics?: string,
|
||
): { stubbed: boolean } {
|
||
if (!isEmptyReview(content)) {
|
||
deps.writeFile(plan.reviewPath, content.endsWith('\n') ? content : `${content}\n`);
|
||
return { stubbed: false };
|
||
}
|
||
const stderr = deps.exists(plan.errPath) ? deps.readFile(plan.errPath) : '';
|
||
const parts = [`${plan.slug} review failed or returned empty output. stderr:`, stderr];
|
||
if (extraDiagnostics) parts.push('Raw response body:', extraDiagnostics);
|
||
deps.writeFile(plan.reviewPath, `${parts.join('\n')}\n`);
|
||
return { stubbed: true };
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Handlers (D6) — named first-party code, never conditionals in data
|
||
* ------------------------------------------------------------------ */
|
||
|
||
/**
|
||
* `opencode` — reconstruct the review from the assistant `text` parts of a `--format json` stream.
|
||
*
|
||
* `--format json` is the PRIMARY invocation, not a fallback: OpenCode's default `build` agent is an
|
||
* agentic coder, and on a large review prompt it may run a few `read` tool calls then end its turn
|
||
* with ZERO output tokens, so `--format default` yields empty stdout and the reviewer is silently
|
||
* lost (#1936). When no assistant text was emitted, surface the stop reason and output-token count
|
||
* so the failure is diagnosable rather than a generic empty stub.
|
||
*
|
||
* The stream is JSONL-ish: one JSON value per line. A line that does not parse is SKIPPED rather
|
||
* than failing the lane — a partial stream still carries usable review text, and losing the whole
|
||
* review to one malformed line would be strictly worse than the bug this handler exists to fix.
|
||
*/
|
||
export function handleOpencodeOutput(rawStdout: string): { review: string; diagnostic: string } {
|
||
const texts: string[] = [];
|
||
let stopReason = '?';
|
||
let outputTokens = '?';
|
||
for (const line of String(rawStdout ?? '').split(/\r?\n/)) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) continue;
|
||
let evt: unknown;
|
||
try {
|
||
evt = JSON.parse(trimmed);
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (evt === null || typeof evt !== 'object') continue;
|
||
const e = evt as { type?: unknown; part?: unknown };
|
||
const part = (e.part ?? {}) as { text?: unknown; reason?: unknown; tokens?: unknown };
|
||
if (e.type === 'text' && typeof part.text === 'string') {
|
||
// An EMPTY string is kept, not skipped. The shipped jq was `.part.text // empty`, and `//`
|
||
// only substitutes for `false`/`null` — an empty string is truthy in jq, so it survived and
|
||
// contributed a blank line. Dropping it here would quietly close up blank lines between
|
||
// parts, which is a real (if cosmetic) divergence from the behaviour being ported.
|
||
texts.push(part.text);
|
||
} else if (e.type === 'step_finish') {
|
||
if (typeof part.reason === 'string') stopReason = part.reason;
|
||
const tokens = (part.tokens ?? {}) as { output?: unknown };
|
||
if (typeof tokens.output === 'number') outputTokens = String(tokens.output);
|
||
}
|
||
}
|
||
return {
|
||
review: texts.join('\n'),
|
||
diagnostic: `stop reason=${stopReason}, output tokens=${outputTokens}`,
|
||
};
|
||
}
|
||
|
||
/** One `PLANNER_RESPONSE` line of an Antigravity transcript. */
|
||
interface TranscriptEntry {
|
||
source?: unknown;
|
||
status?: unknown;
|
||
type?: unknown;
|
||
content?: unknown;
|
||
}
|
||
|
||
/**
|
||
* `antigravity` — three layers, a two-level timeout, and a stale-response watermark.
|
||
*
|
||
* Layer 1 is stdout, which works on macOS/Linux/WSL. On native Windows `agy -p` silently produces
|
||
* no stdout despite the API call succeeding (an upstream `text_drip.go` non-TTY flush bug), so
|
||
* layer 2 reads the transcript `agy` always persists to disk. Layer 3 is a diagnostic stub.
|
||
*
|
||
* THE WATERMARK IS THE SUBTLE PART. Without it, layer 2 reads the last `PLANNER_RESPONSE` in the
|
||
* transcript regardless of when it was written — including one from a PREVIOUS invocation in the
|
||
* same workspace, silently presenting a stale review as this run's. So the transcript line count is
|
||
* snapshotted BEFORE the spawn and only lines appended after it are considered. If the conversation
|
||
* id changed, `agy` started a fresh session and every line is new (skip 0).
|
||
*
|
||
* `takeWatermark` must therefore be called before `runAntigravity`; the plan's outer timeout is the
|
||
* external wall-clock cap that `--print-timeout` cannot provide (it cannot fire before a session
|
||
* exists — a process can stall pre-session and outlive its own native timeout, #2073 mode 3).
|
||
*
|
||
* KNOWN LIMIT — the watermark is sequential, not concurrent. `last_conversations.json` is keyed by
|
||
* WORKSPACE, so two `/gsd:review` runs against the same repo at the same time resolve the same
|
||
* conversation id and share one transcript. The fallback then takes "the latest DONE
|
||
* PLANNER_RESPONSE after the watermark" with nothing to tell the two runs apart, so one run could
|
||
* read the other's response. This cannot be closed here: `agy` exposes no per-invocation id to
|
||
* filter on, and the transcript carries none. It is stated rather than silently tolerated because
|
||
* the guarantee this function advertises ("never stale") holds only for sequential use, and a
|
||
* future reader deserves to know which half is actually guaranteed.
|
||
*/
|
||
export function antigravityWatermark(
|
||
workspace: string,
|
||
deps: RunnerDeps,
|
||
): { convId: string; lines: number; unreadable?: boolean } {
|
||
const cachePath = `${deps.homeDir}/.gemini/antigravity-cli/cache/last_conversations.json`;
|
||
if (!deps.exists(cachePath)) return { convId: '', lines: 0 };
|
||
let cache: Record<string, unknown>;
|
||
try {
|
||
cache = JSON.parse(deps.readFile(cachePath)) as Record<string, unknown>;
|
||
} catch {
|
||
return { convId: '', lines: 0 };
|
||
}
|
||
const convId = resolveConvId(cache, workspace);
|
||
if (!convId) return { convId: '', lines: 0 };
|
||
const tx = transcriptPath(deps.homeDir, convId);
|
||
if (!deps.exists(tx)) return { convId, lines: 0 };
|
||
try {
|
||
return { convId, lines: deps.readFile(tx).split(/\r?\n/).filter((l) => l.trim()).length };
|
||
} catch {
|
||
// #3118: this conv-id pre-dates this run, so its transcript exists but this run cannot verify
|
||
// its line count. Reporting `lines: 0` would assert a fact we could not check — flag it instead
|
||
// so the fallback can decline rather than silently skip zero and replay a stale response.
|
||
return { convId, lines: 0, unreadable: true };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Workspace lookup is case-insensitive — the leg's jq did `ascii_downcase` on both sides.
|
||
*
|
||
* #3118: a successful `JSON.parse` does not by itself make the payload a usable object —
|
||
* `JSON.parse('null')` succeeds and returns `null`, so a truncated/zeroed cache file slips past
|
||
* the callers' parse-only try/catch. `typeof null === 'object'`, so the guard below must exclude
|
||
* `null` explicitly. Arrays are excluded too (not a workspace map), which also falls out of the
|
||
* `Object.entries`/`hasOwnProperty` lookups below returning nothing for array input.
|
||
*/
|
||
function resolveConvId(cache: unknown, workspace: string): string {
|
||
if (cache === null || typeof cache !== 'object') return '';
|
||
const record = cache as Record<string, unknown>;
|
||
if (Object.prototype.hasOwnProperty.call(record, workspace)) {
|
||
const direct = record[workspace];
|
||
if (typeof direct === 'string' && direct) return direct;
|
||
}
|
||
const target = workspace.toLowerCase();
|
||
for (const [k, v] of Object.entries(record)) {
|
||
if (k.toLowerCase() === target && typeof v === 'string' && v) return v;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function transcriptPath(homeDir: string, convId: string): string {
|
||
return `${homeDir}/.gemini/antigravity-cli/brain/${convId}/.system_generated/logs/transcript.jsonl`;
|
||
}
|
||
|
||
/**
|
||
* Layer 2: the newest `PLANNER_RESPONSE` written AFTER the watermark, or `''`.
|
||
*
|
||
* Returning `''` rather than the newest entry overall is the whole point — an empty result lets
|
||
* layer 3 fire with an honest diagnostic, where a stale one would be indistinguishable from a
|
||
* successful review.
|
||
*/
|
||
export function antigravityTranscriptFallback(
|
||
workspace: string,
|
||
mark: { convId: string; lines: number; unreadable?: boolean },
|
||
deps: RunnerDeps,
|
||
): string {
|
||
const cachePath = `${deps.homeDir}/.gemini/antigravity-cli/cache/last_conversations.json`;
|
||
if (!deps.exists(cachePath)) return '';
|
||
let cache: Record<string, unknown>;
|
||
try {
|
||
cache = JSON.parse(deps.readFile(cachePath)) as Record<string, unknown>;
|
||
} catch {
|
||
return '';
|
||
}
|
||
const convId = resolveConvId(cache, workspace);
|
||
if (!convId) return '';
|
||
// #3118: the watermark could not read this conversation's transcript, so there is no trustworthy
|
||
// skip for it. Declining is the fail-closed answer; skipping 0 would replay a prior run's review.
|
||
if (mark.unreadable === true && convId === mark.convId) return '';
|
||
const tx = transcriptPath(deps.homeDir, convId);
|
||
if (!deps.exists(tx)) return '';
|
||
let lines: string[];
|
||
try {
|
||
lines = deps.readFile(tx).split(/\r?\n/).filter((l) => l.trim());
|
||
} catch {
|
||
return '';
|
||
}
|
||
// Same conv-id ⇒ only lines beyond the watermark are this run's. Different id ⇒ fresh session,
|
||
// so every line is new.
|
||
const skip = convId === mark.convId ? mark.lines : 0;
|
||
let latest = '';
|
||
for (const line of lines.slice(skip)) {
|
||
let entry: TranscriptEntry;
|
||
try {
|
||
entry = JSON.parse(line) as TranscriptEntry;
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (
|
||
entry.source === 'MODEL' &&
|
||
entry.status === 'DONE' &&
|
||
entry.type === 'PLANNER_RESPONSE' &&
|
||
typeof entry.content === 'string'
|
||
) {
|
||
latest = entry.content;
|
||
}
|
||
}
|
||
return latest;
|
||
}
|
||
|
||
/**
|
||
* Antigravity's prompt variant (#2176).
|
||
*
|
||
* Differs from the standard `argv-file-ref` text by one clause, and that clause is load-bearing:
|
||
* it REQUIRES the reviewer to self-report when it cannot read the repo. Without it a blind review
|
||
* is indistinguishable from a grounded one — the reviewer happily reviews the plan text in
|
||
* isolation and its verdict is counted at full weight in the consensus. `stampBlindReview` reads
|
||
* this self-report back out.
|
||
*/
|
||
export function antigravityPrompt(promptPath: string, repoRoot: string): string {
|
||
return (
|
||
`Read the file at ${promptPath} in full and carry out the review request it contains. ` +
|
||
`The repository under review is at ${repoRoot} — resolve every relative file path in the ` +
|
||
`review request against that absolute root and verify claims against those files. ` +
|
||
`If you cannot read files under ${repoRoot}, begin your output with the exact line ` +
|
||
`REVIEWED-WITHOUT-REPO-ACCESS before the review. Output only the resulting markdown review. ` +
|
||
`Do not edit any files.`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Antigravity's argv adjustments — the two things data cannot express for this lane.
|
||
*
|
||
* 1. `--add-dir <repo>`, CAPABILITY-PROBED. Without it agy's permission context never receives the
|
||
* cwd repo: the agent anchors on its own `~/.gemini/antigravity-cli/scratch` dir and reviews the
|
||
* plan text in isolation, which is exactly what the Review Instructions forbid (#2176). It is
|
||
* probed rather than assumed because older agy builds reject the unknown flag outright, and a
|
||
* lane that fails to start is worse than one that runs on the prompt anchor alone.
|
||
* 2. The self-report prompt variant above, swapped in for the standard file-ref text.
|
||
*
|
||
* Both are argv shape, so they belong here rather than in the descriptor: expressing "add this flag
|
||
* only if the binary's --help mentions it" as data would need a conditional, which is precisely
|
||
* what the named-handler seam exists to absorb (ADR-2782 D6).
|
||
*/
|
||
export function antigravityArgv(
|
||
argv: readonly string[],
|
||
promptPath: string,
|
||
repoRoot: string,
|
||
deps: RunnerDeps,
|
||
): string[] {
|
||
const standard = fileRefPromptText(promptPath, repoRoot);
|
||
const out = argv.map((a) => (a === standard ? antigravityPrompt(promptPath, repoRoot) : a));
|
||
|
||
let supportsAddDir = false;
|
||
try {
|
||
const help = deps.spawn('agy', ['--help'], { timeoutMs: 5_000 });
|
||
supportsAddDir = `${help.stdout}\n${help.stderr}`.includes('--add-dir');
|
||
} catch {
|
||
supportsAddDir = false;
|
||
}
|
||
if (!supportsAddDir) return out;
|
||
|
||
// Insert before the trailing `-p <prompt>` pair so the prompt stays last, as the leg had it.
|
||
const pIdx = out.lastIndexOf('-p');
|
||
if (pIdx === -1) return [...out, '--add-dir', repoRoot];
|
||
return [...out.slice(0, pIdx), '--add-dir', repoRoot, ...out.slice(pIdx)];
|
||
}
|
||
|
||
/**
|
||
* Layer 3's diagnostic: what `agy` itself logged, when both stdout and the transcript were empty.
|
||
*
|
||
* #2073 mode 2 is invisible without this. A pinned model that 404s server-side exits **0** with
|
||
* empty stdout AND an empty transcript — every signal the other two layers read says "clean run,
|
||
* nothing to report". The only evidence is in `agy`'s own log, so a bare generic stub would leave
|
||
* the user with a silently missing reviewer and nothing to diagnose.
|
||
*
|
||
* Mode 3 (a pre-session stall, which `--print-timeout` cannot bound because it cannot fire before a
|
||
* session exists) leaves no log line at all, so its tell is stated rather than searched for.
|
||
*/
|
||
export function antigravityDiagnostic(deps: RunnerDeps): string {
|
||
const lines = [
|
||
'Antigravity review failed or returned empty output.',
|
||
];
|
||
const logPath = `${deps.homeDir}/.gemini/antigravity-cli/cli.log`;
|
||
if (deps.exists(logPath)) {
|
||
try {
|
||
const hits = deps
|
||
.readFile(logPath)
|
||
.split(/\r?\n/)
|
||
.filter((l) => /agent executor error|NOT_FOUND|Publisher model/i.test(l))
|
||
.slice(-3);
|
||
if (hits.length) {
|
||
lines.push(
|
||
"agy log hint (pinned model may be unavailable — run 'agy models' and set review.models.agy):",
|
||
...hits,
|
||
);
|
||
}
|
||
} catch {
|
||
/* an unreadable log is not worth failing the lane over */
|
||
}
|
||
}
|
||
lines.push(
|
||
'If no agy run started, that is the pre-session-stall case: check whether a new ' +
|
||
'~/.gemini/antigravity-cli/brain/<conv-id>/ dir appeared within ~30s of launch.',
|
||
);
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/**
|
||
* Stamp a machine-readable marker when the reviewer plainly ran without repo access (#2176).
|
||
*
|
||
* BOTH tells are ANCHORED, and that anchoring is load-bearing: a grounded review that merely QUOTES
|
||
* `REVIEWED-WITHOUT-REPO-ACCESS` — reviewing this very file, say — must never be mis-stamped. So
|
||
* the self-report is only honoured in the first five lines, and the scratch-dir tell only in a
|
||
* workspace-DECLARATION phrasing.
|
||
*/
|
||
export function stampBlindReview(review: string): string {
|
||
if (isEmptyReview(review)) return review;
|
||
const head = review.split(/\r?\n/).slice(0, 5).join('\n');
|
||
const selfReported = head.includes('REVIEWED-WITHOUT-REPO-ACCESS');
|
||
const scratchTell = /(workspace|working) (directory|dir)[\s\S]{0,40}antigravity-cli\/scratch/i.test(review);
|
||
if (!selfReported && !scratchTell) return review;
|
||
return (
|
||
'> [reviewed-without-repo-access] This reviewer ran without visibility into the repo under ' +
|
||
'review — down-weight its verdict in the Consensus Summary.\n\n' +
|
||
review
|
||
);
|
||
}
|
||
|
||
/**
|
||
* `openai-compatible` — model discovery, the chat-completions round trip, and the served-model
|
||
* mismatch warning.
|
||
*
|
||
* The raw body is returned alongside the content because an OpenAI-compatible server reports errors
|
||
* with an HTTP 4xx/5xx and the JSON in the BODY. The bash piped the response straight into `jq`,
|
||
* which discarded exactly that evidence; the stub appends it now.
|
||
*/
|
||
export async function runOpenAiCompatible(
|
||
plan: HttpPlan,
|
||
promptText: string,
|
||
deps: RunnerDeps,
|
||
): Promise<{ review: string; rawBody: string }> {
|
||
let model = plan.model;
|
||
if (!model && plan.modelsUrl) {
|
||
const listed = await deps.httpJson(plan.modelsUrl, { method: 'GET', timeoutMs: 2_000 });
|
||
if (listed.ok) {
|
||
try {
|
||
const parsed = JSON.parse(listed.body) as { data?: Array<{ id?: unknown }> };
|
||
const first = Array.isArray(parsed.data) ? parsed.data[0] : undefined;
|
||
if (first && typeof first.id === 'string' && first.id) model = first.id;
|
||
} catch {
|
||
/* fall through to the declared fallback */
|
||
}
|
||
}
|
||
}
|
||
if (!model) model = plan.fallbackModel;
|
||
|
||
const body = JSON.stringify({ model, messages: [{ role: 'user', content: promptText }] });
|
||
const res = await deps.httpJson(plan.url, { method: 'POST', body, timeoutMs: plan.timeoutMs });
|
||
if (!res.ok && !res.body) {
|
||
return { review: '', rawBody: res.error ?? `HTTP ${res.status}` };
|
||
}
|
||
let review = '';
|
||
try {
|
||
const parsed = JSON.parse(res.body) as {
|
||
model?: unknown;
|
||
choices?: Array<{ message?: { content?: unknown } }>;
|
||
};
|
||
// A server that quietly serves a different model than requested produces a review the user
|
||
// will attribute to the wrong system. Warn rather than fail — the review is still real.
|
||
if (typeof parsed.model === 'string' && parsed.model && parsed.model !== model) {
|
||
deps.warn(
|
||
`${plan.slug} served model '${parsed.model}' but '${model}' was requested. ` +
|
||
`Review may be from a different model.`,
|
||
);
|
||
}
|
||
const content = parsed.choices?.[0]?.message?.content;
|
||
if (typeof content === 'string') review = content;
|
||
} catch {
|
||
/* leave review empty — the raw body carries the diagnosis */
|
||
}
|
||
return { review, rawBody: res.body };
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Orchestration
|
||
* ------------------------------------------------------------------ */
|
||
|
||
/**
|
||
* Run one resolved lane end to end.
|
||
*
|
||
* Order is deliberate: egress re-verification happens BEFORE the probe and before any spawn, so a
|
||
* lane whose destination changed never receives the plan text even once.
|
||
*/
|
||
export async function runLane(
|
||
plan: LanePlan,
|
||
deps: RunnerDeps,
|
||
opts: { consentedHost?: unknown; explicitlyRequested?: boolean; repoRoot: string },
|
||
): Promise<LaneRunResult> {
|
||
const base = { slug: plan.slug, stubbed: false };
|
||
|
||
if (plan.transport === 'openai-http') {
|
||
const egress = checkEgressHost(opts.consentedHost, plan.host);
|
||
if (!egress.allowed) {
|
||
const detail =
|
||
`lane '${plan.slug}' was consented to send plans to ${egress.consentedHost} but ` +
|
||
`${plan.hostConfigKey} now resolves to ${egress.currentHost}. Re-consent to allow the new ` +
|
||
`destination — this lane will not run against a host you did not approve.`;
|
||
deps.warn(detail);
|
||
return { ...base, ok: false, reason: LANE_UNAVAILABLE.EGRESS_HOST_CHANGED, detail };
|
||
}
|
||
}
|
||
|
||
const probed = await probeLane(plan, deps);
|
||
if (!probed.available) {
|
||
// D4's carve-out: not finding a lane nobody asked for is normal; failing to run a lane somebody
|
||
// asked for is an ERROR. Both are visible; only the second is fatal to the run.
|
||
if (opts.explicitlyRequested) deps.warn(`explicitly requested reviewer '${plan.slug}': ${probed.detail}`);
|
||
return { ...base, ok: false, reason: probed.reason, detail: probed.detail };
|
||
}
|
||
|
||
return plan.transport === 'spawn'
|
||
? runSpawnLane(plan, deps, opts.repoRoot)
|
||
: runHttpLane(plan, deps);
|
||
}
|
||
|
||
function runSpawnLane(plan: SpawnPlan, deps: RunnerDeps, repoRoot: string): LaneRunResult {
|
||
const input = plan.stdin && deps.exists(plan.stdin) ? deps.readFile(plan.stdin) : undefined;
|
||
|
||
const mark =
|
||
plan.handler === 'antigravity' ? antigravityWatermark(repoRoot, deps) : { convId: '', lines: 0 };
|
||
|
||
const argv =
|
||
plan.handler === 'antigravity'
|
||
? antigravityArgv(plan.argv, plan.promptPath, repoRoot, deps)
|
||
: plan.argv;
|
||
|
||
const out = deps.spawn(plan.binary, argv, { input, timeoutMs: plan.timeoutMs });
|
||
// #3086: surface spawn errors (ENOENT, ETIMEDOUT, etc.) that would otherwise
|
||
// be silently dropped — the review path read only stdout/stderr and treated
|
||
// an empty-stderr spawn failure as "the model had nothing to say".
|
||
const errContent = out.errorCode
|
||
? `${out.stderr ?? ''}\n[spawn error: ${out.errorCode}]\n`
|
||
: (out.stderr ?? '');
|
||
deps.writeFile(plan.errPath, errContent);
|
||
|
||
// `file-arg` lanes write the review themselves and their stdout is deliberately discarded (#1698).
|
||
let review =
|
||
plan.outputTarget.kind === 'file'
|
||
? deps.exists(plan.outputTarget.path)
|
||
? deps.readFile(plan.outputTarget.path)
|
||
: ''
|
||
: (out.stdout ?? '');
|
||
|
||
let extra: string | undefined;
|
||
|
||
if (plan.handler === 'opencode') {
|
||
const rebuilt = handleOpencodeOutput(review);
|
||
if (!isEmptyReview(rebuilt.review)) {
|
||
review = rebuilt.review;
|
||
} else {
|
||
review = '';
|
||
extra = `OpenCode review returned no assistant text (#1936: agent ended its turn with no final message).\nDiagnostic: ${rebuilt.diagnostic}`;
|
||
}
|
||
}
|
||
|
||
if (plan.handler === 'antigravity') {
|
||
// A non-zero exit (timeout kill, crash) discards partial output so the transcript fallback and
|
||
// the diagnostic stub can take over.
|
||
if (out.status !== 0) review = '';
|
||
if (isEmptyReview(review)) review = antigravityTranscriptFallback(repoRoot, mark, deps);
|
||
review = stampBlindReview(review);
|
||
// Layer 3. `emptyOutput: 'handler-owned'` means the generic stub does not fire for this lane,
|
||
// so if nothing is written here the lane goes out empty — the #2073 failure itself.
|
||
if (isEmptyReview(review)) {
|
||
deps.writeFile(plan.reviewPath, `${antigravityDiagnostic(deps)}\n`);
|
||
return { slug: plan.slug, ok: true, stubbed: true };
|
||
}
|
||
}
|
||
|
||
const { stubbed } = writeReviewOrStub(plan, review, deps, extra);
|
||
return { slug: plan.slug, ok: true, stubbed };
|
||
}
|
||
|
||
async function runHttpLane(plan: HttpPlan, deps: RunnerDeps): Promise<LaneRunResult> {
|
||
const promptText = deps.exists(plan.promptPath) ? deps.readFile(plan.promptPath) : '';
|
||
const { review, rawBody } = await runOpenAiCompatible(plan, promptText, deps);
|
||
deps.writeFile(plan.errPath, '');
|
||
const { stubbed } = writeReviewOrStub(plan, review, deps, rawBody);
|
||
return { slug: plan.slug, ok: true, stubbed };
|
||
}
|