mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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:
1 parent
e93a4bdbec
commit
717960a5ce
6 files changed
+623
-5
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user