feat(s05): wire run-evals --provider rig to a live persistent seat (Test-A preflight blocker 3, row testa-provider)

The frozen door's live-harness gate required the documented `--provider rig`
entry to drive one real persistent seat end to end without a Test-A-private
source edit. New eval-rig-session drives ONE seat via supported rig CLI
surfaces only (up/whoami/send/capture/down): --seat attaches (never torn
down), --seat-spec spawns a scratch rig (torn down once). captureSince anchors
'since the prompt' across pane wrapping/truncation/scroll-off and waits for
pane stability; a stuck seat times out as ERROR. The provider fails the whole
RUN fast on a spawn failure (measured: per-case re-spawn churned six leaked
rigs) and dispose failures never discard the recorded grades. run-evals wires
the session behind the existing seam and teaches a loud refusal when neither
--seat nor --seat-spec is given. [NOT guard-accepted]
This commit is contained in:
v-openrig-build authored and mvschwarz committed 2026-08-25 18:42:22 -07:00
1 parent e93a4bdbec
commit 717960a5ce
6 files changed
+623 -5

No files matched your search

+36 -4
View File
@@ -13,8 +13,12 @@
* --provider fake (default): a deterministic provider whose transcripts come from --transcripts
* (a JSON map of prompt -> transcript); absent prompts ERROR (never a silent green). This is the
* CI-runnable path.
* --provider rig: the live-seat provider (the non-author proof-contract door) — see
* eval-rig-provider.ts; it throws until the non-author wires seat spawn + capture.
* --provider rig: the LIVE-seat provider (the proof-contract door). Requires ONE of:
* --seat <session> attach to an existing live seat (never torn down), or
* --seat-spec <rig.yaml> `rig up` a scratch rig and drive its single seat
* (torn down via `rig down` at the end).
* One persistent seat/generation serves every case; capture waits for pane
* stability; the leading input echo is excluded from grading by the provider.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
@@ -63,14 +67,42 @@ if (unresolved.length > 0) {
let provider;
if (providerName === "rig") {
provider = new RigSeatProvider({ productionPackage: PRODUCTION_PACKAGE });
const seat = opt("--seat", null);
const spec = opt("--seat-spec", null);
if ((seat === null) === (spec === null)) {
console.error(
"[REFUSED] --provider rig drives ONE persistent real seat and needs exactly one of:\n" +
" --seat <session> attach to an existing live seat (never torn down)\n" +
" --seat-spec <rig.yaml> spawn a scratch rig via `rig up` and drive its seat",
);
process.exit(2);
}
const { createRigCliSession } = await import("../test/helpers/eval-rig-session.ts");
provider = new RigSeatProvider({
productionPackage: PRODUCTION_PACKAGE,
session: createRigCliSession(seat !== null ? { seat } : { spec }),
});
} else {
const tPath = opt("--transcripts", null);
const transcripts = tPath ? JSON.parse(readFileSync(tPath, "utf-8")) : {};
provider = new FakeProvider(transcripts);
}
const summary = await runEvals(cases, provider);
let summary;
try {
summary = await runEvals(cases, provider);
} finally {
// Retire the persistent seat exactly once (idempotent; no-op for fake/attach).
// A teardown failure must NOT destroy the run's results — the grades are the
// product; a leftover rig is a named warning for manual `rig down`.
if (typeof provider.dispose === "function") {
try {
await provider.dispose();
} catch (e) {
console.error(`[WARN] seat retirement failed — tear the scratch rig down manually with 'rig down': ${e.message}`);
}
}
}
const recorded = {
provider: provider.name,
total: summary.total,
@@ -69,4 +69,13 @@ describe("RigSeatProvider — session-persistent mode (Test-A)", () => {
const provider = new RigSeatProvider({ productionPackage: "/packs" });
await expect(provider.run("anything")).rejects.toThrow(/not yet driven|provider fake/i);
});
it("SPAWN FAIL-FAST: a failed spawn poisons the run — later cases error WITHOUT re-spawning (the six-leaked-rigs class)", async () => {
const spawn = vi.fn(async () => { throw new Error("rig up failed: port conflict"); });
const provider = new RigSeatProvider({ productionPackage: "/packs", session: { spawn } });
await expect(provider.run("case one")).rejects.toThrow(/port conflict/);
await expect(provider.run("case two")).rejects.toThrow(/already failed/);
await expect(provider.run("case three")).rejects.toThrow(/already failed/);
expect(spawn).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,226 @@
// Test-A preflight blocker 3 (row testa-provider) — pins for the CLI-backed
// RigSeatSession behind `run-evals.mjs --provider rig`. The exec boundary is
// injected, so these pin the MECHANICS (spawn/attach identity, capture-since
// anchoring across pane wrapping and truncation, stability polling, timeout,
// retire-only-when-spawned) without a daemon; the live leg is the frozen
// command run by the non-author.
import { describe, it, expect, vi } from "vitest";
import { createRigCliSession, sliceAfterPrompt, type RigExec } from "./helpers/eval-rig-session.js";
const WHOAMI = JSON.stringify({ session: "ops-eval@evalrig", occupantGeneration: "gen-1234", nodeId: "01NODE" });
const UP = JSON.stringify({ status: "completed", rigId: "01RIG", attachCommand: "tmux attach -t ops-eval@evalrig" });
function scriptedExec(script: (args: string[]) => string | undefined): { exec: RigExec; calls: string[][] } {
const calls: string[][] = [];
const exec: RigExec = async (args) => {
calls.push(args);
const out = script(args);
if (out === undefined) throw new Error(`unscripted exec: ${args.join(" ")}`);
return out;
};
return { exec, calls };
}
describe("sliceAfterPrompt — the since-the-prompt anchor", () => {
it("wrapped echo: the prompt broken across pane lines still anchors, output follows", () => {
const prompt = "What can I do here in this world?";
const capture = "old scroll\n> What can I do\nhere in this world?\nI will check rig context profile now.\n";
const since = sliceAfterPrompt(capture, prompt, "old scroll\n");
expect(since).toContain("I will check rig context profile now.");
expect(since).not.toContain("old scroll");
});
it("truncated echo: a TUI-elided prompt anchors on the prompt head", () => {
const prompt = "What can I do here in this world? Please be thorough about it.";
const capture = "> What can I do here in th…\nrunning rig context get now\n";
expect(sliceAfterPrompt(capture, prompt, "")).toBe("running rig context get now\n");
});
it("REPEATED prompt (r2 QA finding 1): the SAME prompt from an earlier case does NOT leak its response into the current one", () => {
// The real corpus repeats prompts across loading.yaml + selection.yaml. The
// earlier case's response contained `rig context get`; the current case did
// NOT pull context. Anchoring on the current SEND boundary must return only
// the current response.
const prompt = "the box rebooted and everything's gone — bring the whole fleet back";
const earlier = `> ${prompt}\nI ran rig context get skills/core/rig-lifecycle first.\n`;
const preSend = earlier; // the pane BEFORE the current send holds the earlier case
const rawCapture = earlier + `> ${prompt}\nI'll just restart it directly, no context needed.\n`;
const since = sliceAfterPrompt(rawCapture, prompt, preSend);
expect(since).toContain("no context needed");
expect(since).not.toContain("rig context get");
});
it("REDRAWN footer (r2 QA finding, redraw residual): a repeated input/status footer at the bottom is NOT the boundary; the response is retained", () => {
// Interactive TUIs REDRAW their footer rather than preserving it as history.
// The pre-send footer reappears at the bottom of the post-send capture; a
// lastIndexOf(footer) boundary would skip past NEW RESPONSE. LCS-diff keeps it.
const prompt = "What can I do here?";
const footer = "────────\n> \n esc to interrupt · gpt-5.6\n";
const preSend = `prior scrollback line\n${footer}`;
const rawCapture = `prior scrollback line\n> ${prompt}\nNEW RESPONSE: rig context get skills/x\n${footer}`;
const since = sliceAfterPrompt(rawCapture, prompt, preSend);
expect(since).toContain("NEW RESPONSE: rig context get skills/x");
expect(since).not.toBe("");
});
it("BOUNDED-SCROLL identity (r2 QA finding): an OLD identical command scrolled out, the current turn re-emits it — the MARKER keeps it, LCS-alone would delete it", () => {
const prompt = "the box rebooted — bring the fleet back";
const marker = "eval-sync-NONCE1";
// pre-send still holds the earlier identical command; post-send has scrolled
// it off the top and shows only the current turn (after the marker echo).
const preSend = "hist\nrig context get skills/core/rig-lifecycle\n> \n";
const rawCapture = `${marker}\n> ${prompt}\nrig context get skills/core/rig-lifecycle\n> \n`;
// WITHOUT the marker, LCS matches the new command to pre's old one -> "".
expect(sliceAfterPrompt(rawCapture, prompt, preSend)).not.toContain("rig context get");
// WITH the marker, the current command is retained.
const since = sliceAfterPrompt(rawCapture, prompt, preSend, marker);
expect(since).toContain("rig context get skills/core/rig-lifecycle");
});
it("marker scrolled out too (turn > window): falls back to the LCS diff, never returns pre-send history", () => {
const prompt = "what can I do";
const preSend = "old history line\n";
const rawCapture = "old history line\n> what can I do\nfresh response here\n"; // no marker visible
const since = sliceAfterPrompt(rawCapture, prompt, preSend, "eval-sync-GONE");
expect(since).toContain("fresh response here");
expect(since).not.toContain("old history line");
});
it("scrolled-off echo: falls back to the pre-send snapshot tail", () => {
const pre = "line a\nline b\nline c\n";
const capture = "line b\nline c\nfresh output only\n";
const since = sliceAfterPrompt(capture, "a prompt that is entirely gone", pre);
expect(since).toContain("fresh output only");
expect(since).not.toContain("line b");
});
it("a LATER genuine quotation stays IN the transcript — the anchor is the FIRST echo occurrence", () => {
const prompt = "pull the lifecycle entry";
const capture = '> pull the lifecycle entry\nthinking...\nyou asked "pull the lifecycle entry" so I ran it\n';
expect(sliceAfterPrompt(capture, prompt, "")).toBe('thinking...\nyou asked "pull the lifecycle entry" so I ran it\n');
});
});
describe("createRigCliSession — spawn/attach, polling, retirement", () => {
it("SPAWN: rig up -> adopt the attach session, generation from whoami, retire tears the spawned rig down ONCE", async () => {
const { exec, calls } = scriptedExec((args) => {
if (args[0] === "up") return UP;
if (args[0] === "whoami") return WHOAMI;
if (args[0] === "down") return "torn down";
return undefined;
});
const session = await createRigCliSession({ spec: "/tmp/eval-rig.yaml", exec }).spawn();
expect(session.generation).toBe("gen-1234");
await session.retire();
// teardown targets the rigId from `rig up` (survives even an unparseable attach line)
expect(calls.filter((c) => c[0] === "down")).toEqual([["down", "01RIG"]]);
});
it("ATTACH: a named seat is verified via whoami and NEVER torn down on retire", async () => {
const { exec, calls } = scriptedExec((args) => {
if (args[0] === "whoami") return WHOAMI;
return undefined;
});
const session = await createRigCliSession({ seat: "ops-eval@evalrig", exec }).spawn();
expect(session.generation).toBe("gen-1234");
await session.retire();
expect(calls.some((c) => c[0] === "down")).toBe(false);
});
it("CLEANUP on empty identity (r2 QA finding 2): whoami succeeds but yields no generation -> the spawned rig is torn DOWN", async () => {
const { exec, calls } = scriptedExec((args) => {
if (args[0] === "up") return JSON.stringify({ status: "completed", rigId: "01RIGX", attachCommand: "tmux attach -t p-s@evalx" });
if (args[0] === "whoami") return JSON.stringify({ resolvedBy: "session", identity: {}, peers: [] }); // no generation
if (args[0] === "down") return "torn down";
return undefined;
});
await expect(createRigCliSession({ spec: "/tmp/r.yaml", exec }).spawn()).rejects.toThrow(/stable seat generation/);
expect(calls.filter((c) => c[0] === "down")).toEqual([["down", "01RIGX"]]);
});
it("CLEANUP on unparseable attach (r2 QA finding 2): rig up completed but no attach session -> the rig (by rigId) is torn DOWN", async () => {
const { exec, calls } = scriptedExec((args) => {
if (args[0] === "up") return JSON.stringify({ status: "completed", rigId: "01RIGY", attachCommand: "" }); // no -t session
if (args[0] === "down") return "torn down";
return undefined;
});
await expect(createRigCliSession({ spec: "/tmp/r.yaml", exec }).spawn()).rejects.toThrow(/did not launch a seat/);
expect(calls.filter((c) => c[0] === "down")).toEqual([["down", "01RIGY"]]);
});
it("exactly one of seat/spec is required, loud", () => {
expect(() => createRigCliSession({})).toThrow(/exactly ONE/);
expect(() => createRigCliSession({ seat: "a", spec: "b" })).toThrow(/exactly ONE/);
});
it("send snapshots the pane BEFORE typing; captureSince waits for stability then slices after the echo", async () => {
let pane = "idle prompt box\n";
const { exec } = scriptedExec((args) => {
if (args[0] === "whoami") return WHOAMI;
if (args[0] === "send") {
pane = "idle prompt box\n> the natural prompt\n";
return "sent";
}
if (args[0] === "capture") {
const out = pane;
// simulate the seat producing output across polls, then going stable
if (pane.includes("> the natural prompt") && !pane.includes("DONE")) pane = pane + "working...\nDONE rig context get skills/x\n";
return out;
}
return undefined;
});
const session = await createRigCliSession({ seat: "ops-eval@evalrig", exec, pollMs: 1, stablePolls: 2, sleep: async () => {}, nonce: () => "N1" }).spawn();
await session.sendPrompt("the natural prompt");
const since = await session.captureSince("the natural prompt");
expect(since).toContain("DONE rig context get skills/x");
expect(since).not.toContain("idle prompt box");
expect(since).not.toContain("eval-sync-N1"); // the marker is excluded from the graded transcript
});
it("a seat that never goes stable times out as an ERROR, never a silent partial", async () => {
let n = 0;
const { exec } = scriptedExec((args) => {
if (args[0] === "whoami") return WHOAMI;
if (args[0] === "send") return "sent";
if (args[0] === "capture") return `always changing ${n++}\n`;
return undefined;
});
const session = await createRigCliSession({ seat: "s@r", exec, pollMs: 1, timeoutMs: 30, sleep: async () => {} }).spawn();
await session.sendPrompt("p");
await expect(session.captureSince("p")).rejects.toThrow(/did not go stable/);
});
it("TRANSIENT capture failures are tolerated — a lag-slow daemon's 5s timeout retries, not errors the case", async () => {
let pane = "idle\n";
let captureCall = 0;
const { exec } = scriptedExec((args) => {
if (args[0] === "whoami") return WHOAMI;
if (args[0] === "send") { pane = "idle\n> the prompt\n"; return "sent"; }
if (args[0] === "capture") {
captureCall++;
// fail a couple of polls transiently (daemon lag), then succeed + go stable
if (captureCall === 3 || captureCall === 4) throw new Error("Daemon did not respond in time");
if (pane.includes("> the prompt") && !pane.includes("DONE")) pane += "DONE rig context get skills/x\n";
return pane;
}
return undefined;
});
const session = await createRigCliSession({ seat: "s@r", exec, pollMs: 1, stablePolls: 2, sleep: async () => {} }).spawn();
await session.sendPrompt("the prompt");
const since = await session.captureSince("the prompt");
expect(since).toContain("DONE rig context get skills/x");
});
it("SUSTAINED capture failure IS a dead daemon — surfaces loud after the consecutive bound", async () => {
const { exec } = scriptedExec((args) => {
if (args[0] === "whoami") return WHOAMI;
if (args[0] === "send") return "sent";
if (args[0] === "capture") throw new Error("Daemon did not respond in time");
return undefined;
});
const session = await createRigCliSession({ seat: "s@r", exec, pollMs: 1, timeoutMs: 60_000, sleep: async () => {} }).spawn();
await session.sendPrompt("p");
await expect(session.captureSince("p")).rejects.toThrow(/consecutive capture failures|unresponsive/);
});
});
@@ -54,6 +54,7 @@ export class RigSeatProvider implements EvalProvider {
readonly name = "rig-seat";
private session: RigSeatSession | null = null;
private disposed = false;
private spawnError: string | null = null;
constructor(private readonly opts: RigSeatProviderOptions) {}
@@ -70,8 +71,18 @@ export class RigSeatProvider implements EvalProvider {
if (this.disposed) {
throw new Error("RigSeatProvider session is retired/disposed — a new provider (and seat) is required for further runs.");
}
if (this.spawnError !== null) {
// A failed spawn poisons the RUN: re-spawning per case would churn one
// scratch rig per remaining case (measured live: six leaked rigs).
throw new Error(`RigSeatProvider: seat spawn already failed for this run — ${this.spawnError}`);
}
if (!this.session) {
this.session = await this.opts.session.spawn();
try {
this.session = await this.opts.session.spawn();
} catch (err) {
this.spawnError = err instanceof Error ? err.message : String(err);
throw err;
}
}
const started = Date.now();
await this.session.sendPrompt(prompt);
@@ -0,0 +1,324 @@
/**
* Test-A preflight blocker 3 (row testa-provider) — the LIVE RigSeatSession
* wiring behind `run-evals.mjs --provider rig`. Drives ONE persistent real seat
* through the supported rig CLI surfaces only:
*
* spawn — attach to a named seat (--seat) or `rig up` a scratch spec
* (--seat-spec) and adopt its single launched seat; the seat's
* GENERATION comes from `rig whoami --session <seat> --json`.
* send — `rig send <seat> <prompt>` (argv array, never a shell string).
* capture — poll `rig capture <seat> --lines N` until the pane is STABLE
* (unchanged across consecutive polls), then return everything
* AFTER the prompt's echo. Panes wrap and TUIs truncate the echo,
* so the anchor search is whitespace-NORMALIZED with an index map
* back to the raw capture; a truncated echo falls back to the
* prompt head; a scrolled-off echo falls back to the pre-send
* snapshot diff. The leading-echo strip itself stays the
* PROVIDER's contract (eval-rig-provider.ts) — this module only
* bounds "since the prompt".
* retire — `rig down <rig>` exactly when THIS session spawned the rig;
* attaching never destroys someone else's seat.
*
* Every rig invocation goes through an injectable exec so the mechanics are
* unit-pinned without a daemon; the live entry injects nothing and gets the
* real CLI.
*/
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import type { RigSeatSession } from "./eval-rig-provider.js";
export type RigExec = (args: string[]) => Promise<string>;
export interface RigCliSessionOptions {
/** Attach to an existing session (mutually exclusive with spec). */
seat?: string;
/** `rig up` this spec and adopt its single launched seat. */
spec?: string;
rigBin?: string;
exec?: RigExec;
/** Poll interval while waiting for the pane to go stable. */
pollMs?: number;
/** Consecutive identical captures that count as "the seat finished". */
stablePolls?: number;
/** Hard per-prompt ceiling; expiring is an ERROR, never a silent partial. */
timeoutMs?: number;
captureLines?: number;
sleep?: (ms: number) => Promise<void>;
/** Per-send unique boundary marker generator (default crypto.randomUUID). */
nonce?: () => string;
}
function defaultExec(rigBin: string): RigExec {
return (args: string[]) =>
new Promise((resolvePromise, reject) => {
execFile(rigBin, args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
if (err) reject(new Error(`${rigBin} ${args[0]} failed: ${err.message}${stderr ? ` — ${stderr.slice(0, 400)}` : ""}`));
else resolvePromise(stdout);
});
});
}
/** Whitespace-normalized haystack with a map from normalized index -> raw index. */
function normalized(raw: string): { text: string; map: number[] } {
const chars: string[] = [];
const map: number[] = [];
for (let i = 0; i < raw.length; i++) {
const c = raw[i]!;
if (/\s/.test(c)) continue;
chars.push(c);
map.push(i);
}
return { text: chars.join(""), map };
}
/** The lines of `post` that are NOT part of its longest common subsequence with
* `pre` — i.e. what the terminal produced SINCE the pre-send snapshot. Line-LCS
* is the right boundary because it is robust to all three real terminal shapes:
* APPEND (new lines at the end), SCROLL (shared lines shift up, top falls off),
* and REDRAW (the input/status footer reappears at the bottom — it is common to
* both snapshots, so LCS matches it and it is NOT counted as new). A footer or
* prompt that merely repeats can never become the boundary. */
function newLinesSince(pre: string, post: string): string[] {
const a = pre.split("\n");
const b = post.split("\n");
const n = a.length;
const m = b.length;
// LCS length DP.
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
}
}
// Backtrack: mark which `b` (post) lines are matched into the LCS.
const matched = new Array<boolean>(m).fill(false);
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
matched[j] = true;
i++;
j++;
} else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
i++;
} else {
j++;
}
}
const out: string[] = [];
for (let k = 0; k < m; k++) if (!matched[k]) out.push(b[k]!);
return out;
}
/** Raw-capture slice of the seat's output SINCE the current prompt was sent.
*
* BOUNDARY, in priority order:
* 1. MARKER (review50-r2 QA finding, bounded-scroll identity): a unique nonce
* is emitted into the pane immediately before the prompt. Content-identity
* alone cannot tell an OLD identical command line (that has since scrolled
* out of the pre-send window) from the NEW one the current response emits —
* LCS matches them and deletes the current command. The per-send nonce
* cannot collide with history, so the region after its LAST occurrence is
* unambiguously the current turn regardless of scroll.
* 2. LCS-diff against the pre-send snapshot — the fallback when the marker
* itself scrolled out (a turn larger than the capture window); robust to
* append/scroll/redraw for non-identical content.
* Within the chosen region the prompt echo (wrapped or TUI-truncated) is
* skipped so grading starts at the response; a later genuine quotation is
* kept (it is in the region, after the echo). */
export function sliceAfterPrompt(rawCapture: string, prompt: string, preSendCapture: string, marker?: string): string {
let region: string;
if (marker && marker.length > 0) {
const mAt = rawCapture.lastIndexOf(marker);
if (mAt >= 0) {
region = rawCapture.slice(mAt + marker.length);
} else {
// marker scrolled out (turn > window): fall back to the LCS diff.
region = preSendCapture.length > 0 ? newLinesSince(preSendCapture, rawCapture).join("\n") : rawCapture;
}
} else {
region = preSendCapture.length > 0 ? newLinesSince(preSendCapture, rawCapture).join("\n") : rawCapture;
}
const hay = normalized(region);
const fullNeedle = normalized(prompt).text;
for (const needle of [fullNeedle, fullNeedle.slice(0, 16)]) {
if (needle.length === 0) continue;
const at = hay.text.indexOf(needle);
if (at >= 0) {
const endNorm = at + needle.length - 1;
let rawEnd = hay.map[endNorm]! + 1;
// On a PARTIAL (truncated-echo) match, skip the rest of the echo line.
if (needle !== fullNeedle) {
const nl = region.indexOf("\n", rawEnd);
rawEnd = nl >= 0 ? nl + 1 : region.length;
}
return region.slice(rawEnd).replace(/^\n/, "");
}
}
// Echo fully truncated/scrolled: the region IS the post-send output.
return region.replace(/^\n/, "");
}
interface UpNodeDetail {
stages?: Array<{ detail?: { nodes?: Array<{ logicalId?: string; status?: string }> } }>;
attachCommand?: string;
rigId?: string;
status?: string;
}
export function createRigCliSession(options: RigCliSessionOptions): { spawn: () => Promise<RigSeatSession> } {
const {
seat,
spec,
rigBin = "rig",
exec = defaultExec(rigBin),
pollMs = 5_000,
stablePolls = 2,
timeoutMs = 300_000,
captureLines = 2_000,
sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)),
nonce = () => randomUUID(),
} = options;
if ((seat === undefined) === (spec === undefined)) {
throw new Error("createRigCliSession: exactly ONE of seat (attach) or spec (spawn) is required");
}
return {
async spawn(): Promise<RigSeatSession> {
let sessionName: string;
let spawnedRig: string | undefined;
// The live daemon can be briefly unresponsive under load (its CLI probe
// gives up at 5s and tries to spawn a SECOND daemon, which port-conflicts)
// — retry with backoff instead of churning rigs.
const withRetry = async (fn: () => Promise<string>, attempts = 6, backoffMs = 10_000): Promise<string> => {
let lastErr: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
lastErr = err;
if (i < attempts - 1) await sleep(backoffMs);
}
}
throw lastErr;
};
// Once `rig up` returns, it may have CREATED a rig — record ownership
// IMMEDIATELY (by rigId, which survives even if the attach line can't be
// parsed) so EVERY later failure path can tear it down (review50-r2 QA
// finding 2). Attach mode records nothing and tears nothing down.
// If ANY post-up step fails, retire the rig we created.
const cleanupOnFailure = async (err: unknown): Promise<never> => {
if (spawnedRig !== undefined) {
await withRetry(() => exec(["down", spawnedRig!])).catch(() => {});
}
throw err;
};
if (spec !== undefined) {
const up = JSON.parse(await withRetry(() => exec(["up", spec, "--json"]))) as UpNodeDetail;
spawnedRig = up.rigId ?? undefined; // own it before any validation can throw
const attach = up.attachCommand ?? "";
const m = attach.match(/-t\s+(\S+)/);
if (up.status !== "completed" || !m) {
await cleanupOnFailure(new Error(`rig up did not launch a seat (status=${up.status ?? "?"}, attach=${JSON.stringify(attach)})`));
}
sessionName = m![1]!;
spawnedRig = spawnedRig ?? sessionName.split("@").pop();
} else {
sessionName = seat!;
}
// ALL identity validation is inside cleanupOnFailure's reach: a successful
// whoami with an empty/malformed identity must still retire a spawned rig.
let generation: string | undefined;
try {
const who = JSON.parse(
await withRetry(() => exec(["whoami", "--session", sessionName, "--json"])),
) as Record<string, unknown>;
// The live shape nests the identity: { resolvedBy, identity: { nodeId, ... }, ... }.
const identity = (who["identity"] ?? who) as Record<string, unknown>;
generation =
(identity["occupantGeneration"] as string | undefined) ??
(identity["occupant_generation"] as string | undefined) ??
(identity["generation"] as string | undefined) ??
(identity["nodeId"] as string | undefined) ??
(identity["node_id"] as string | undefined);
if (typeof generation !== "string" || generation.length === 0) {
throw new Error(`cannot derive a stable seat generation from rig whoami for '${sessionName}' — got identity keys ${Object.keys(identity).join(",")}`);
}
} catch (err) {
await cleanupOnFailure(err);
}
const capture = () => exec(["capture", sessionName, "--lines", String(captureLines)]);
// A single `capture` can time out at the CLI's 5s bound when the daemon is
// briefly lag-slow (measured live). A transient poll failure is NOT a dead
// seat — return null so the loop waits and retries; the overall deadline
// still bounds a genuinely stuck seat. Consecutive failures past a bound
// ARE a dead daemon and surface loud.
const maxConsecutiveCaptureFailures = 8;
const tolerantCapture = async (failures: { n: number }): Promise<string | null> => {
try {
const out = await capture();
failures.n = 0;
return out;
} catch (err) {
failures.n += 1;
if (failures.n >= maxConsecutiveCaptureFailures) {
throw new Error(`captureSince: ${failures.n} consecutive capture failures for '${sessionName}' — daemon unresponsive: ${(err as Error).message}`);
}
return null;
}
};
let preSendCapture = "";
let currentMarker = "";
return {
generation,
async sendPrompt(prompt: string): Promise<void> {
// The pre-send snapshot is a best-effort fallback anchor (used only if
// the marker scrolls off); a failed snapshot must NOT abort the case.
preSendCapture = await withRetry(() => capture()).catch(() => "");
// Emit a UNIQUE per-send marker BEFORE the prompt so the current-send
// boundary has scroll-proof identity (a repeated/scrolled command line
// cannot be mistaken for it). The marker echoes into the pane; it sits
// BEFORE the prompt echo, so the prompt-echo skip excludes it from the
// graded transcript. The SEND is the load-bearing action and IS retried.
currentMarker = `eval-sync-${nonce()}`;
await withRetry(() => exec(["send", sessionName, currentMarker]));
await withRetry(() => exec(["send", sessionName, prompt]));
},
async captureSince(prompt: string): Promise<string> {
const deadline = Date.now() + timeoutMs;
let last = "";
let stable = 0;
const failures = { n: 0 };
// First wait for ANY change from the pre-send pane (the seat consumed
// the prompt), then for stability (the seat stopped producing).
for (;;) {
if (Date.now() > deadline) {
throw new Error(`captureSince: seat '${sessionName}' did not go stable within ${timeoutMs}ms`);
}
await sleep(pollMs);
const now = await tolerantCapture(failures);
if (now === null) continue;
if (now === last && now !== preSendCapture) {
stable += 1;
if (stable >= stablePolls) return sliceAfterPrompt(now, prompt, preSendCapture, currentMarker);
} else {
stable = 0;
}
last = now;
}
},
async retire(): Promise<void> {
if (spawnedRig !== undefined) {
await withRetry(() => exec(["down", spawnedRig!]));
}
},
};
},
};
}
@@ -0,0 +1,16 @@
# Example scratch rig for run-evals --provider rig --seat-spec (Test-A blocker 3).
# A one-seat claude-code rig; the eval spawns it, drives the cases, and tears it
# down. NOTE (drive-side): on the shared daemon this seat sees the live rig graph
# and behaves as a full agent — verified-blank Test-A needs it ISOLATED.
version: "0.2"
name: eval-scratch
pods:
- id: p
label: P
members:
- id: s
agent_ref: "local:agents/blank"
profile: default
runtime: claude-code
cwd: "."
permission_policy: builtin:yolo