import { resolve } from "node:path"; import { errMessage } from "agent-relay-sdk"; import type { LandGate, LandGateRunResult } from "agent-relay-sdk"; import { DEFAULT_LAND_GATE_TIMEOUT_MS, loadRepoLandGates } from "agent-relay-sdk/land-gates"; // #902/#1199 — execute mandatory boundary gates and any repo-configured land gates // against the candidate merged tree BEFORE the ref advance in `mergeRebaseFf`. // A required gate failing blocks the land (ref NOT advanced); optional gates only warn. /** Per-gate default timeout when the gate didn't declare `timeoutMs`. Keep the * ceiling generous but bounded so a hung gate can't wedge the per-repo merge * lease forever. Full-suite boundary gates should declare their own timeout. */ const DEFAULT_GATE_TIMEOUT_MS = DEFAULT_LAND_GATE_TIMEOUT_MS; const TIMEOUT_KILL_GRACE_MS = 1_000; const OUTPUT_CANCEL_GRACE_MS = 50; /** Cap on the full output streamed to the relay artifact (the notification only ever * carries the tail). Errors usually surface at the END, so we keep the tail on overflow. */ const MAX_FULL_OUTPUT_BYTES = 256 * 1024; /** Bytes of the tail put in the notification body — enough to show the failure, small * enough never to flood relay. */ const TAIL_BYTES = 4000; function keepTail(text: string, maxBytes: number, label: string): string { if (text.length <= maxBytes) return text; const dropped = text.length - maxBytes; return `…(${dropped} earlier ${label} truncated)…\n${text.slice(text.length - maxBytes)}`; } function combineOutput(stdout: string, stderr: string): string { if (stdout && stderr) return `${stdout}\n${stderr}`; return stdout || stderr; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } interface OutputCapture { done: Promise; text(): string; cancel(): void; } function captureProcessOutput(pipe: unknown): OutputCapture { if (!(pipe instanceof ReadableStream)) { return { done: Promise.resolve(), text: () => "", cancel: () => {} }; } const reader = pipe.getReader(); const decoder = new TextDecoder(); let output = ""; let canceled = false; const done = (async () => { try { while (!canceled) { const chunk = await reader.read(); if (chunk.done) break; output += decoder.decode(chunk.value, { stream: true }); } output += decoder.decode(); } catch { // A timeout cancels the reader intentionally so pipe EOF cannot hold up the gate result. } finally { try { reader.releaseLock(); } catch {} } })(); return { done, text: () => output, cancel: () => { canceled = true; void reader.cancel().catch(() => {}); }, }; } function signalGateProcessGroup(proc: ReturnType, signal: NodeJS.Signals): void { if (typeof proc.pid === "number" && proc.pid > 0) { try { process.kill(-proc.pid, signal); return; } catch (err) { if ((err as { code?: string }).code !== "ESRCH") { try { proc.kill(signal); } catch {} } return; } } try { proc.kill(signal); } catch {} } /** Run a single gate and capture its outcome. Never throws — a spawn failure (e.g. * the command can't launch) is reported as a non-passing result so the caller can * decide block-vs-warn from the gate's `optional` flag. */ export async function runOneLandGate(worktreePath: string, gate: LandGate): Promise { const cwd = gate.cwd ? resolve(worktreePath, gate.cwd) : worktreePath; const timeoutMs = gate.timeoutMs ?? DEFAULT_GATE_TIMEOUT_MS; const started = Date.now(); const base = { name: gate.name, command: gate.command, optional: gate.optional === true } as const; let proc: ReturnType; let timedOut = false; let timeout: ReturnType | undefined; let killTimeout: ReturnType | undefined; try { // A login shell so PATH (bun, node, project bins) resolves like the worker's own // environment; `env: process.env` makes runtime env mutations visible to the child. proc = Bun.spawn(["bash", "-lc", gate.command], { cwd, env: process.env, stdin: "ignore", stdout: "pipe", stderr: "pipe", detached: true, }); } catch (err) { const durationMs = Date.now() - started; const output = `land gate "${gate.name}" could not be launched: ${errMessage(err)}`; return { ...base, passed: false, exitCode: null, timedOut: false, durationMs, outputTail: output, output }; } const stdoutCapture = captureProcessOutput(proc.stdout); const stderrCapture = captureProcessOutput(proc.stderr); let timeoutTriggeredResolve: () => void = () => {}; const timeoutTriggered = new Promise((resolve) => { timeoutTriggeredResolve = resolve; }); timeout = setTimeout(() => { timedOut = true; timeoutTriggeredResolve(); signalGateProcessGroup(proc, "SIGTERM"); stdoutCapture.cancel(); stderrCapture.cancel(); killTimeout = setTimeout(() => { signalGateProcessGroup(proc, "SIGKILL"); stdoutCapture.cancel(); stderrCapture.cancel(); }, TIMEOUT_KILL_GRACE_MS); killTimeout.unref?.(); }, timeoutMs); timeout.unref?.(); const exitCodeRaw = await Promise.race([ proc.exited, timeoutTriggered.then(async () => { await Promise.race([proc.exited.then(() => undefined), sleep(TIMEOUT_KILL_GRACE_MS + OUTPUT_CANCEL_GRACE_MS)]); return null; }), ]).finally(() => { if (timeout) clearTimeout(timeout); if (killTimeout) clearTimeout(killTimeout); }); if (timedOut) { stdoutCapture.cancel(); stderrCapture.cancel(); await Promise.race([Promise.allSettled([stdoutCapture.done, stderrCapture.done]), sleep(OUTPUT_CANCEL_GRACE_MS)]); } else { await Promise.all([stdoutCapture.done, stderrCapture.done]); } const durationMs = Date.now() - started; const stdout = stdoutCapture.text(); const stderr = stderrCapture.text(); let combined = combineOutput(stdout, stderr); const exitCode = timedOut ? null : typeof exitCodeRaw === "number" ? exitCodeRaw : null; const passed = exitCode === 0; if (timedOut) combined = `${combined}\n[land-gate] timed out after ${timeoutMs}ms`.trimStart(); if (!combined) combined = passed ? "(gate produced no output)" : "(gate produced no output)"; return { ...base, passed, exitCode, timedOut, durationMs, outputTail: keepTail(combined, TAIL_BYTES, "bytes"), output: keepTail(combined, MAX_FULL_OUTPUT_BYTES, "bytes"), }; } export interface LandGatesResult { /** Number of gates configured + run (0 ⇒ no config ⇒ caller's land path is unchanged). */ ran: number; /** First REQUIRED gate that failed — its presence means the land MUST be aborted * without advancing the ref. */ failure?: LandGateRunResult; /** Optional gates that failed (warn-only); the land still proceeds. */ warnings: LandGateRunResult[]; } /** * Run required boundary gates plus, when requested, repo-configured land gates against * `worktreePath`. Returns `ran: 0` with no failure/warnings only when neither set * declares gates. Stops at the first failing REQUIRED gate (it blocks the land); * optional failures accumulate as warnings and never block. * * A PRESENT-but-malformed `.agent-relay/land-gates.json` is itself a blocking failure * (surfaced as a synthetic required gate) rather than an unhandled throw that would * crash the merge command — the worker fixes the config and re-lands. */ export async function runLandGates(worktreePath: string, requiredGates: LandGate[] = [], options: { loadRepoGates?: boolean; repoGates?: LandGate[] } = {}): Promise { const warnings: LandGateRunResult[] = []; let gates: LandGate[]; try { const repoGates = options.repoGates ?? (options.loadRepoGates === false ? [] : loadRepoLandGates(worktreePath)); gates = [...requiredGates, ...repoGates]; } catch (err) { const output = `invalid .agent-relay/land-gates.json: ${errMessage(err)}`; return { ran: 1, failure: { name: "land-gates-config", command: "(config validation)", optional: false, passed: false, exitCode: null, timedOut: false, durationMs: 0, outputTail: output, output }, warnings, }; } if (gates.length === 0) return { ran: 0, warnings }; for (const gate of gates) { const result = await runOneLandGate(worktreePath, gate); if (result.passed) continue; if (result.optional) { warnings.push(result); continue; } // First required failure blocks the land — don't run the rest (the worker fixes // this gate and re-lands, which re-runs from the top). return { ran: gates.length, failure: result, warnings }; } return { ran: gates.length, warnings }; }