/** * 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; try { cache = JSON.parse(deps.readFile(cachePath)) as Record; } 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; 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; try { cache = JSON.parse(deps.readFile(cachePath)) as Record; } 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 `, 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 ` 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// 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 { 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 { 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 }; }