Setting the file. One moment.
Runner Scaffold · Claude API · anthropics/skills · Skills Docs
ContentsBack to the top of the page function mkdirNoFollow
— line 124
This file
Number 5.47
Position 47 of 94
Type JavaScript
Size 32 KB
Lines 602 shared/evals/report/ runner-scaffold.mjs
JavaScript · 602 lines · 32 KB
14
// - refuses to run when the harness (this file + _state.json.harness_paths) has
15 // changed since the sha a human last approved with --approve-harness, so a
16 // round that edits the runner cannot execute unreviewed under a standing
17 // session allowlist
18 // - pairwise graders judge against frozen baseline/ref/<id>.* on disk
19 // - writes rows as cases complete (crash-safe)
20 // - jittered exponential backoff on transient 429/overloaded/5xx errors
21 // - hard per-case wall-clock ceiling (--timeout-s; stream keepalives don't reset it)
22 // - served-model assertion (response model must match --model; documented alias->snapshot
23 // shapes tolerated: 'foo-latest'/'foo-0'/'foo' -> 'foo-20250101' / 'foo@20250101' / 'foo-2025-01-01')
24 // - failed attempts land in errors.jsonl with a failure class and, when the call
25 // completed, the billed model/usage (never in results.jsonl)
26 // - row ids, trace filenames, and frozen refs share one path-safe id
27 // (original id kept in meta.original_id when sanitization changed it)
28
29 import { createHash } from 'node:crypto' ;
30 import { closeSync, constants as FS, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, writeFileSync, writeSync } from 'node:fs' ;
31 import { dirname, isAbsolute, join, relative, resolve } from 'node:path' ;
32 import { fileURLToPath } from 'node:url' ;
33
34 // Every output write refuses symlinks: the flow dir is model-influenced, and a
35 // prompt-injected round can plant `results.jsonl -> ~/.bashrc` where the next
36 // unattended run would append. POSIX opens O_NOFOLLOW (a symlink fails with
37 // ELOOP); Windows - where Node leaves O_NOFOLLOW undefined and Bun defines a
38 // meaningless value - lstat-refuses first. Symlinked parent dirs are
39 // refused the same way. Same discipline as the report builders' reads.
40 const WIN = process.platform === 'win32' ;
41 const NOFOLLOW = WIN ? 0 : FS . O_NOFOLLOW ;
42 // A guard that cannot tell must refuse: only "no such entry" reads as absent;
43 // any other lstat failure (EACCES, ENAMETOOLONG, ...) is rethrown, never "no".
44 const lstatOrNull = p => { try { return lstatSync (p); } catch (e) { if (e?.code === 'ENOENT' ) return null ; throw e; } };
45 const isSymlink = p => lstatOrNull (p)?. isSymbolicLink () === true ;
46 // Stderr lines interpolate model-influenced bytes (case ids, error text that
47 // can echo model output, JSON.parse messages). Strip escape sequences and
48 // control characters, as build-report-lite.mjs's eprint does, so a planted
49 // OSC/CSI can't retitle the terminal or forge output lines.
50 const ESC_SEQ = / \x1b \[ [0-?] * [ - \/ ] * [@-~] |\x1b \] [ ^\x07\x1b ] * (?: \x07|\x1b \\ ) ?|\x1b [@-_] / g ;
51 const CONTROL = / [ \x00 - \x1f\x7f - \x9f ] / g ;
52 const termSafe = s => String (s). replace ( ESC_SEQ , '' ). replace ( CONTROL , '' );
53 const eprint = ( ... a ) => console. error ( ... a. map (termSafe));
54 // The leaf checks above can't see a symlink on an INTERMEDIATE component
55 // (lstat and open both resolve those silently), so every open is also bound
56 // to the flow root: main() captures realpathSync(flow) once, and any path
57 // whose resolved parent leaves it - e.g. `vdir` or the flow dir itself
58 // replaced by a directory symlink - is refused when the check sees it.
59 // Residual, all platforms: the check and the open are separate path lookups
60 // (Node's sync fs has no openat-style call), so a directory swapped for a
61 // symlink in between is still followed. This stops a planted link, not a
62 // writer racing the run.
63 let flowRealRoot = null ;
64 function assertInFlow ( dir , what ) {
65 if (flowRealRoot == null ) throw new Error ( `refusing to ${ what }: flow root not resolved yet` );
66 const dirReal = realpathSync (dir);
67 if (dirReal !== flowRealRoot && ! dirReal. startsWith (flowRealRoot + ( WIN ? ' \\ ' : '/' )))
68 throw new Error ( `refusing to ${ what }: ${ dir } resolves outside the flow directory` );
69 }
70 function openNoFollow ( p , flags ) {
71 if ( isSymlink ( dirname (p))) throw new Error ( `refusing to open through symlinked directory: ${ dirname ( p ) }` );
72 assertInFlow ( dirname (p), 'open' );
73 if ( WIN && isSymlink (p)) throw new Error ( `refusing to open through symlink: ${ p }` );
74 const fd = openSync (p, flags | NOFOLLOW , 0o644 );
75 try {
76 const st = fstatSync (fd);
77 if ( ! st. isFile ()) throw new Error ( `refusing to use non-regular file: ${ p }` );
78 // O_NOFOLLOW and lstat cannot see a hard link: a second name for a file
79 // outside the flow dir opens as an ordinary regular file. Nothing the
80 // runner creates has more than one link, so refuse any that does.
81 if (st.nlink > 1 ) throw new Error ( `refusing to use ${ p }: it has a second hard link (another name for the same file); replace it with a plain copy if it is yours` );
82 } catch (e) { closeSync (fd); throw e; }
83 return fd;
84 }
85 // writeFileSync on the fd loops until every byte lands (a bare writeSync is
86 // one write(2) that may return short on ENOSPC and silently truncate a
87 // results row or trace).
88 // Opened without O_TRUNC and truncated only after openNoFollow's checks, so a
89 // refused file keeps its bytes.
90 function writeFileNoFollow ( p , data ) {
91 const fd = openNoFollow (p, FS . O_WRONLY | FS . O_CREAT );
92 try { ftruncateSync (fd, 0 ); writeFileSync (fd, data); } finally { closeSync (fd); }
93 }
94 // POSIX appends atomically under O_APPEND with no position. On Windows, Bun
95 // writes an O_APPEND handle at offset 0 unless given a position, so there the
96 // write starts at the current size and re-issues any short write.
97 function appendFileNoFollow ( p , data ) {
98 const fd = openNoFollow (p, FS . O_WRONLY | FS . O_CREAT | FS . O_APPEND );
99 try {
100 if ( ! WIN ) { writeFileSync (fd, data); return ; }
101 const buf = Buffer. from (data);
102 const start = fstatSync (fd).size;
103 for ( let off = 0 ; off < buf. length ;) {
104 const n = writeSync (fd, buf, off, buf. length - off, start + off);
105 if (n <= 0 ) throw new Error ( `append to ${ p } made no progress` );
106 off += n;
107 }
108 } finally { closeSync (fd); }
109 }
110 // Reads of the frozen pairwise refs get the same discipline as writes (same
111 // open guard): the flow dir is model-influenced, so `baseline/ref/<id> ->
112 // ~/.ssh/id_rsa` planted after the startup preflight must not be read into
113 // the judge prompt. lexists probes with lstat so a planted symlink still
114 // counts as "present" at the freeze guard (never overwritten - or followed).
115 const lexists = p => lstatOrNull (p) != null ;
116 function readFileNoFollow ( p ) {
117 const fd = openNoFollow (p, FS . O_RDONLY );
118 try { return readFileSync (fd, 'utf8' ); } finally { closeSync (fd); }
119 }
120 // null when the file is absent; any other failure (a planted link included) throws.
121 function readIfPresent ( p ) {
122 try { return readFileNoFollow (p); } catch (e) { if (e?.code === 'ENOENT' ) return null ; throw e; }
123 }
124 function mkdirNoFollow ( dir ) {
125 if ( isSymlink (dir)) throw new Error ( `refusing to use symlinked directory: ${ dir }` );
126 mkdirSync (dir, { recursive: true });
127 // Check after creating: mkdirSync(recursive) follows symlinked ancestors,
128 // so a dir minted through one resolves outside the flow root and is refused
129 // here before any file lands in it.
130 assertInFlow (dir, 'create directory' );
131 }
132 // Frozen pairwise refs may carry an extension; reader and freeze-guard probe
133 // the same list so a suffixed ref never gets an extensionless shadow.
134 const REF_EXTS = [ '' , '.html' , '.txt' , '.json' ];
135
136 // --- fill these in ----------------------------------------------------------
137
138 /** Return the list of input cases. Each must have a stable `id`. */
139 async function loadCases () {
140 // e.g. return JSON.parse(readFileSync('eval/cases.json', 'utf8'));
141 throw new Error ( 'TODO: loadCases' );
142 }
143
144 /**
145 * Run the app on one input. Return everything the grader and the report need.
146 * `ctx.model` and `ctx.variant` are the CLI args; use them to pick the model
147 * and (for hillclimb) the harness/prompt under test.
148 */
149 async function runCase ( input , ctx ) {
150 // const res = await client.messages.create({ model: ctx.model, ... });
151 // return {
152 // output: res.content.at(-1).text, // or a file you wrote, an HTML page, ...
153 // transcript: [...], // Turn[] - see SCHEMA.md
154 // model: res.model, usage: res.usage, stop_reason: res.stop_reason,
155 // // optional, for model-graded cost: judge_model, judge_usage
156 // // optional, per-turn artifacts: attachments: [{kind, ref, alt}]
157 // };
158 throw new Error ( 'TODO: runCase' );
159 }
160
161 /**
162 * Grade one output. For pairwise, `ref` is the FROZEN baseline output read
163 * from disk (baseline/ref/<id>.*) - never a freshly co-generated one.
164 * Return { grade: {metric_id: number, ...}, explanation?: {metric_id: string},
165 * judge_model?, judge_usage? }.
166 * If the judge call succeeded but grading still fails (parse error, bad
167 * schema), attach judge_model/judge_usage to the thrown error - the errors
168 * sidecar reads them so billed judge spend on failed attempts stays counted.
169 */
170 async function gradeCase ( input , run , ref , ctx ) {
171 // return { grade: { pass: run.output.includes(input.expected) ? 1 : 0 } };
172 throw new Error ( 'TODO: gradeCase' );
173 }
174
175 /** Side-channel perf fields beyond the built-ins (latency_s etc.). */
176 function perfFrom ( run ) { return {}; }
177
178 // --- harness (you usually won't need to touch below this line) --------------
179
180 function parseArgs ( argv ) {
181 const a = { flow: '.claude/hillclimb/flow' , variant: 'baseline' ,
182 model: undefined , reps: 1 , concurrency: 4 , timeoutS: 1800 ,
183 approveHarness: false };
184 // A flag at the end of argv would otherwise consume undefined - which for
185 // --model equals the default and silently disables the served-model check.
186 const val = ( i ) => { if (argv[i] === undefined ) { eprint ( `missing value for ${ argv [ i - 1 ] }` ); usage (); process. exit ( 2 ); } return argv[i]; };
187 for ( let i = 0 ; i < argv. length ; i ++ ) {
188 const k = argv[i];
189 if (k === '--flow' ) a.flow = val ( ++ i);
190 else if (k === '--variant' ) a.variant = val ( ++ i);
191 else if (k === '--model' ) a.model = val ( ++ i);
192 else if (k === '--reps' ) a.reps = + val ( ++ i);
193 else if (k === '--concurrency' ) a.concurrency = + val ( ++ i);
194 else if (k === '--timeout-s' ) a.timeoutS = + val ( ++ i);
195 else if (k === '--approve-harness' ) a.approveHarness = true ;
196 else if (k === '-h' || k === '--help' ) { usage (); process. exit ( 0 ); }
197 else { eprint ( `unknown argument: ${ k }` ); usage (); process. exit ( 2 ); }
198 }
199 if ( ! / ^ (baseline | v [1-9]\d * ) $ / . test (a.variant)) {
200 // The report only reads directories named 'baseline' or 'v<N>' - any other
201 // name runs to completion but spends the pass into a directory the Summary,
202 // trajectory, and budget arithmetic never see.
203 eprint ( `--variant must be 'baseline' or 'v<N>', got '${ a . variant }'` );
204 usage (); process. exit ( 2 );
205 }
206 if ( ! Number. isFinite (a.timeoutS) || a.timeoutS < 0
207 || a.timeoutS * 1000 > 2147483647 // setTimeout clamps >2^31-1 ms to 1 ms - the ceiling would fire instantly
208 || ! Number. isInteger (a.reps) || a.reps < 1
209 || ! Number. isInteger (a.concurrency) || a.concurrency < 1 ) { usage (); process. exit ( 2 ); }
210 return a;
211 }
212 function usage () {
213 eprint ( 'usage: node run-eval.mjs --flow DIR --variant ID [--model ID] [--reps N] [--concurrency N] [--timeout-s N (0 = no ceiling)] [--approve-harness]' );
214 }
215
216 // Harness integrity gate. The hillclimb loop gets this runner command
217 // allowlisted for the session and then runs rounds unattended, while the
218 // per-round change (proposed by an analyzer fed untrusted transcripts) may
219 // legitimately edit harness code. Without this gate a round that rewrites the
220 // runner would execute attacker-chosen code on the next unattended run under
221 // the user's one-time approval. So: sha256 over this file, any lockfile
222 // beside it or in the directory the runner is invoked from (dependency drift
223 // is harness drift too; with neither, a note says so), plus
224 // every path in `_state.json.harness_paths` (relative to the directory the
225 // runner is invoked from, i.e. the repo root); compare to
226 // `_state.json.harness_sha`; refuse on absent/mismatch unless a human passes
227 // --approve-harness, which records the new sha. That write is the one
228 // sanctioned exception to "never write _state.json".
229 //
230 // Scope, stated plainly: this is a change detector, not a security boundary.
231 // The sha, the path list and this file all live where the loop agent can
232 // write, so an agent that is already prompt-injected can forge the record or
233 // pass --approve-harness itself. What it catches is the common case - a
234 // round's harness edit reaching an unattended run unreviewed - and what
235 // actually bounds an unattended run is the permission allowlist the user
236 // grants the runner command (scope it to this exact command line, not a
237 // prefix). Installed dependencies beyond the lockfile are outside the digest.
238 function checkHarness ( statePath , st , approve ) {
239 const self = fileURLToPath ( import . meta .url);
240 const listed = Array. isArray (st.harness_paths) ? st.harness_paths. map (String) : [];
241 const lockfiles = [ ...new Set ([ dirname (self), process. cwd ()]. flatMap ( d =>
242 [ 'package-lock.json' , 'bun.lock' , 'bun.lockb' , 'yarn.lock' , 'pnpm-lock.yaml' ]. map ( f => join (d, f))))]
243 . filter ( f => existsSync (f));
244 const paths = [ ...new Set ([self, ... lockfiles, ... listed. map ( p => resolve (p))])]. sort ();
245 const h = createHash ( 'sha256' );
246 const hashed = [];
247 for ( const p of paths) {
248 let buf;
249 try { buf = readFileSync (p); }
250 catch (e) {
251 if (p === self) throw e;
252 eprint ( `warning: harness path '${ relative ( process . cwd (), p ) }' not readable (${ e ?. code || 'error'}) - skipped` );
253 continue ;
254 }
255 h. update ( relative (process. cwd (), p)). update ( ' \0 ' ). update (buf). update ( ' \0 ' );
256 hashed. push ( relative (process. cwd (), p));
257 }
258 const sha = h. digest ( 'hex' );
259 if (st.harness_sha === sha) return ;
260 // Said only here, where a person is about to approve or is being refused.
261 if ( ! lockfiles. length ) eprint ( 'note: no lockfile beside the runner or in the current directory - dependency changes are outside the harness sha' );
262 if (approve) {
263 st.harness_sha = sha;
264 writeFileNoFollow (statePath, JSON . stringify (st, null , 2 ) + ' \n ' );
265 eprint ( `harness approved: sha256 ${ sha . slice ( 0 , 12 ) } over ${ hashed . length } file(s) recorded in ${ statePath }` );
266 return ;
267 }
268 if (st.harness_sha == null ) {
269 eprint ( `no approved harness sha in ${ statePath } (computed ${ sha . slice ( 0 , 12 ) } over: ${ hashed . join ( ', ' ) }).` );
270 eprint ( 'Review the harness, then run once with --approve-harness to record it.' );
271 } else {
272 eprint ( `harness changed since last approved run (files: ${ hashed . join ( ', ' ) }); `
273 + `approved ${ String ( st . harness_sha ). slice ( 0 , 12 ) }, now ${ sha . slice ( 0 , 12 ) }.` );
274 eprint ( 'Re-run with --approve-harness after reviewing the diff.' );
275 }
276 process. exit ( 2 );
277 }
278
279 // Transient provider errors (429 / overloaded / 5xx) retry with jittered
280 // exponential backoff - a zero-delay retry loop multiplies cost invisibly
281 // under rate limits and can turn one transient 429 into a torn-down batch.
282 // The attempt count lands in the row's meta (or the errors sidecar) so retry
283 // churn is visible in the data, not just the bill.
284 async function withBackoff ( fn , retry , deadline = Infinity , tries = 5 ) {
285 for ( let attempt = 0 ; ; attempt ++ ) {
286 // Checked before every attempt, not just before sleeps: once the case's
287 // ceiling has passed, an abandoned chain must not issue another call
288 // (e.g. a judge call after the app call consumed the whole ceiling).
289 if (Date. now () >= deadline) {
290 const e = new Error ( 'wall-clock ceiling exceeded before attempt' );
291 e.failure_class = 'timeout' ;
292 throw e;
293 }
294 try { return await fn (); } catch (e) {
295 const status = e?.status ?? e?.response?.status;
296 const transient = status === 429 || status === 529 || (status >= 500 && status < 600 )
297 || /overloaded | rate . ? limit/ i . test ( String (e?.message ?? '' ));
298 if ( ! transient || attempt >= tries - 1 ) throw e;
299 const delay = Math. min ( 60_000 , 1000 * 2 ** attempt) * ( 0.5 + Math. random ());
300 // Never start a retry that would outlive the case's wall-clock ceiling -
301 // otherwise an abandoned chain keeps issuing API calls after the case failed.
302 if (Date. now () + delay >= deadline) throw e;
303 retry.count ++ ;
304 await new Promise ( r => setTimeout (r, delay));
305 }
306 }
307 }
308
309 // Hard per-case wall-clock ceiling, independent of stream liveness - a hung
310 // SSE stream can emit keepalives forever, defeating inactivity-based timers.
311 // The underlying call may keep running; the case fails and the slot is freed.
312 function withTimeout ( promise , seconds , label ) {
313 if ( ! (seconds > 0 )) return promise;
314 let timer;
315 const ceiling = new Promise (( _ , reject ) => {
316 timer = setTimeout (() => {
317 const e = new Error ( `${ label }: exceeded ${ seconds }s wall-clock ceiling` );
318 e.failure_class = 'timeout' ;
319 reject (e);
320 }, seconds * 1000 );
321 });
322 return Promise . race ([promise, ceiling]). finally (() => clearTimeout (timer));
323 }
324
325 // Case ids appear in file paths AND as the row/file join key the report uses,
326 // so rows, trace filenames, and frozen refs all carry the same path-safe id.
327 // When sanitization changes the id, a short content hash keeps distinct ids
328 // distinct ('case/1' vs 'case_1'); the original rides in meta.original_id.
329 function pathSafeId ( id ) {
330 const raw = String (id);
331 const cleaned = raw. replace ( / [ ^ \w.-] / g , '_' );
332 // Idempotent by construction: anything already path-safe and within the
333 // length bound - including this function's own truncated+suffixed output -
334 // passes through unchanged. Long ids (URLs, prompt text as id) truncate to
335 // 120 chars plus an 8-hex hash of the full original, so they fail here, not
336 // at the trace write after the spend, and distinct ids stay distinct.
337 if (cleaned === raw && raw. length <= 129 ) return raw;
338 return `${ cleaned . slice ( 0 , 120 ) }-${ createHash ( 'sha256' ). update ( raw ). digest ( 'hex' ). slice ( 0 , 8 ) }` ;
339 }
340
341 async function main () {
342 const args = parseArgs (process.argv. slice ( 2 ));
343 // lstat("link/") follows the final symlink, so a trailing separator on
344 // --flow would blind every leaf isSymlink check below - strip it first.
345 // Only Windows treats `\` as a separator; on POSIX it is a filename byte, so
346 // splitting on it would walk prefixes that are not real path components.
347 args.flow = args.flow. replace ( WIN ? /( . ) [ \\ /] +$ / : /( . ) \/ +$ / , '$1' );
348 const flowSegments = args.flow. split ( WIN ? / [ \\ /] / : '/' );
349 // A `.`/`..` segment (e.g. a trailing `/.`) makes isSymlink(args.flow) below
350 // resolve a different final component than the named dir - following a
351 // planted link at the flow root - while join() collapses it and the absolute
352 // branch skips the ancestor walk. Refuse dot segments outright
353 // (absolute --flow stays supported).
354 if (flowSegments. some ( seg => seg === '.' || seg === '..' )) {
355 eprint ( `refusing to run: --flow must not contain '.' or '..' segments, got '${ args . flow }'` );
356 process. exit ( 2 );
357 }
358 const vdir = join (args.flow, args.variant);
359 // Preflight every output path before the first model call: a planted
360 // symlink would otherwise fail each case after its (billed) run.
361 for ( const p of [args.flow, join (args.flow, 'baseline' ), vdir, join (vdir, 'traces' ),
362 join (vdir, 'results.jsonl' ), join (vdir, 'errors.jsonl' ),
363 join (vdir, 'progress.txt' ), join (args.flow, 'baseline' , 'ref' ), join (args.flow, '_state.json' )])
364 if ( isSymlink (p)) { eprint ( `refusing to run: ${ p } is a symlink (the flow dir must hold regular files)` ); process. exit ( 2 ); }
365 // A relative --flow (the documented `.claude/hillclimb/<name>` layout) is
366 // also lstat-walked component by component from the cwd: a pre-planted
367 // link at an ancestor (`.claude/hillclimb -> elsewhere`) would otherwise
368 // relocate the root capture below - the containment anchor itself - to the
369 // attacker's target. An absolute --flow is the caller's own trust decision
370 // and is not walked (an absolute ancestor link can be legitimate: /tmp on
371 // macOS).
372 if ( ! isAbsolute (args.flow)) {
373 let walk = '' ;
374 for ( const part of flowSegments. filter (Boolean). slice ( 0 , - 1 )) {
375 walk = walk ? join (walk, part) : part;
376 if ( isSymlink (walk)) { eprint ( `refusing to run: ${ walk } is a symlink (ancestor of --flow)` ); process. exit ( 2 ); }
377 }
378 }
379 // Every later open/mkdir is bound to this resolved root (see assertInFlow):
380 // create the flow dir when fresh (the preflight above refused a link at it
381 // and, for a relative path, at every ancestor), then capture where it
382 // really resolves.
383 mkdirSync (args.flow, { recursive: true });
384 flowRealRoot = realpathSync (args.flow);
385 mkdirNoFollow ( join (vdir, 'traces' ));
386 // _state.json is READ-ONLY here. The orchestrator owns it. Absent is fine
387 // (a baseline-only run has no loop state yet), but present-and-unparsable
388 // must not let the id-space gate below pass vacuously over a corrupt file.
389 const statePath = join (args.flow, '_state.json' );
390 let st = {};
391 // Read through the no-follow opener like every other flow-dir file; the
392 // parse message is not echoed (it can quote the file's first bytes).
393 const stateText = readIfPresent (statePath);
394 if (stateText != null ) {
395 try { st = JSON . parse (stateText) || {}; }
396 catch { eprint ( `${ statePath } exists but is not valid JSON - fix it before spending a pass` ); process. exit ( 2 ); }
397 }
398 checkHarness (statePath, st, args.approveHarness);
399 const ctx = { ... args, state: st };
400
401 // Resume: which (id, rep) pairs already have a row?
402 const resultsPath = join (vdir, 'results.jsonl' );
403 const done = new Set ();
404 for ( const ln of ( readIfPresent (resultsPath) ?? '' ). split ( ' \n ' )) {
405 if ( ! ln. trim ()) continue ;
406 try { const r = JSON . parse (ln); done. add ( `${ r . prompt_id } \0 ${ r . rep }` ); } catch {}
407 }
408 // Rows key on the path-safe id (see pathSafeId), so resume must too.
409
410 const cases = await loadCases ();
411 // Validate the id space before spending anything: duplicate path-safe ids -
412 // including case-insensitive twins, which macOS/Windows filesystems collapse -
413 // would silently overwrite traces and frozen refs; and a _state.json split id
414 // that matches no case would silently shrink the scored denominator.
415 const seen = new Map ();
416 for ( const c of cases) {
417 const k = pathSafeId (c.id). toLowerCase ();
418 if (seen. has (k)) {
419 eprint ( `duplicate case id after sanitization: '${ c . id }' collides with '${ seen . get ( k ) }'` );
420 process. exit ( 2 );
421 }
422 seen. set (k, c.id);
423 }
424 const safeIds = new Set (cases. map ( c => pathSafeId (c.id)));
425 for ( const k of [ 'train_ids' , 'val_ids' , 'test_ids' ])
426 if (st[k] != null && ! Array. isArray (st[k])) { eprint ( `_state.json ${ k } must be a list of ids` ); process. exit ( 2 ); }
427 for ( const sid of [ ... (st.train_ids ?? []), ... (st.val_ids ?? []), ... (st.test_ids ?? [])]) {
428 const s = String (sid); // the adapter joins with String() on both sides - numeric ids are fine
429 if (safeIds. has (s)) continue ; // matches a loaded case - definitionally valid
430 if (s !== pathSafeId (s)) {
431 // Can never match a row: rows key on path-safe ids. This is the silent
432 // shrunken-denominator bug - fail before anything is spent.
433 eprint ( `_state.json split id '${ s }' is not a path-safe id - record split ids exactly as they appear in results.jsonl's prompt_id` );
434 process. exit ( 2 );
435 }
436 // Well-formed but absent is legitimate (a trimmed top-K subset run) - note it, don't fail.
437 eprint ( `note: split id '${ s }' matches no loaded case (expected for a trimmed subset run)` );
438 }
439 const refDir = join (args.flow, 'baseline' , 'ref' );
440 const tasks = [];
441 for ( const c of cases) for ( let rep = 0 ; rep < args.reps; rep ++ ) {
442 if (done. has ( `${ pathSafeId ( c . id ) } \0 ${ rep }` )) continue ;
443 tasks. push ({ c, rep });
444 }
445 eprint ( `[${ args . variant }] ${ tasks . length } of ${ cases . length * args . reps } (id,rep) to run` );
446
447 let i = 0 , ok = 0 , fail = 0 ;
448 const errorsPath = join (vdir, 'errors.jsonl' );
449 // A hard crash (power loss, ENOSPC) can leave a torn final line with no
450 // trailing newline; the next append would merge two rows into one permanently
451 // unparseable line. Isolate any fragment before appending anything.
452 for ( const p of [resultsPath, errorsPath]) {
453 const tail = readIfPresent (p);
454 if (tail && ! tail. endsWith ( ' \n ' )) appendFileNoFollow (p, ' \n ' );
455 }
456 async function worker () {
457 while (i < tasks. length ) {
458 const { c , rep } = tasks[i ++ ];
459 const safeId = pathSafeId (c.id);
460 const t0 = Date. now ();
461 let lastRun = null ; // survives into the catch - billed spend on a failed attempt
462 let rowWritten = false ; // set once the results row lands - the attempt is scored
463 const deadline = args.timeoutS > 0 ? t0 + args.timeoutS * 1000 : Infinity ;
464 const appRetry = { count: 0 }, judgeRetry = { count: 0 };
465 try {
466 // One ceiling over the whole case - app call, identity check, and grading -
467 // so a hung judge stream can't hold the slot either.
468 const { run , g , latency_s } = await withTimeout (( async () => {
469 let tAttempt = t0;
470 const run = await withBackoff (() => { tAttempt = Date. now (); return runCase (c, ctx); },
471 appRetry, deadline);
472 lastRun = run;
473 // latency_s = the final app attempt only; backoff sleeps, failed
474 // attempts, and judge time are excluded (retry counts are in meta).
475 const latency_s = (Date. now () - tAttempt) / 1000 ;
476 // Serving identity: fail loudly when the response was served by a model
477 // other than the one requested. Accept exact match or a documented
478 // alias->snapshot resolution - 'foo-latest'/'foo-0'/'foo' served as
479 // 'foo-20250101', 'foo@20250101', or 'foo-2025-01-01'. Anything else -
480 // another snapshot of the requested pin, a sibling model, or the bare
481 // base id ('foo-latest' served as 'foo', an unversioned echo that can
482 // hide snapshot drift across rounds) - fails the attempt. Non-Anthropic
483 // id schemes (e.g. Bedrock's 'anthropic.claude-...-v1:0') need their own
484 // rule here.
485 if (ctx.model && run.model && run.model !== ctx.model) {
486 const base = ctx.model. replace ( /-latest $| -0 $ / , '' );
487 const rest = String (run.model). startsWith (base)
488 ? String (run.model). slice (base. length ) : null ;
489 if ( ! (rest != null && / ^ [-@] ( \d {8}| \d {4} - \d {2} - \d {2} ) $ / . test (rest))) {
490 const e = new Error ( `served model ${ run . model } != requested ${ ctx . model }` );
491 e.failure_class = 'serving_substitution' ;
492 throw e;
493 }
494 }
495 // Frozen pairwise reference (never regenerated): baseline/ref/<id>.*
496 let ref = null ;
497 if (args.variant !== 'baseline' ) {
498 const p = join (refDir, safeId);
499 // A planted symlink throws (ELOOP) rather than feeding the judge
500 // its target; the case then fails loudly instead of leaking.
501 for ( const ext of REF_EXTS ) {
502 try { ref = readFileNoFollow (p + ext); break ; }
503 catch (e) { if (e?.code !== 'ENOENT' ) throw e; }
504 }
505 }
506 const g = await withBackoff (() => gradeCase (c, run, ref, ctx), judgeRetry, deadline);
507 return { run, g, latency_s };
508 })(), args.timeoutS, `${ c . id } rep${ rep }` );
509 const row = {
510 prompt_id: safeId, rep, prompt: c.prompt ?? c.input ?? c.id,
511 tags: c.tags, attachments: c.attachments,
512 meta: safeId !== String (c.id) || appRetry.count || judgeRetry.count
513 ? { ... (c.meta ?? {}),
514 ... (safeId !== String (c.id) ? { original_id: String (c.id) } : {}),
515 ... (appRetry.count ? { retries: appRetry.count } : {}),
516 ... (judgeRetry.count ? { judge_retries: judgeRetry.count } : {}) }
517 : c.meta,
518 model: run.model, usage: run.usage, stop_reason: run.stop_reason,
519 // The report keys on `status`, not stop_reason: a clipped answer is
520 // counted and shown but kept out of the means. runCase may set
521 // run.status to override the max_tokens rule.
522 status: run.status ?? (run.stop_reason === 'max_tokens' ? 'truncated' : 'ok' ),
523 judge_model: g.judge_model ?? run.judge_model,
524 judge_usage: g.judge_usage ?? run.judge_usage,
525 latency_s, ... perfFrom (run),
526 grade: g.grade, explanation: g.explanation,
527 };
528 appendFileNoFollow (resultsPath, JSON . stringify (row) + ' \n ' );
529 rowWritten = true ; // past this point the attempt is scored - a later throw (trace write, ref freeze) must not also append an error row
530 if (run.transcript)
531 writeFileNoFollow ( join (vdir, 'traces' , `${ safeId }_rep${ rep }.json` ),
532 JSON . stringify (run.transcript, null , 2 ));
533 // For pairwise: on the baseline run, freeze the reference output once.
534 if (args.variant === 'baseline' && run.output != null
535 && ! REF_EXTS . some ( ext => lexists ( join (refDir, safeId) + ext))) {
536 mkdirNoFollow (refDir);
537 writeFileNoFollow ( join (refDir, safeId),
538 typeof run.output === 'string' ? run.output : JSON . stringify (run.output));
539 }
540 ok ++ ;
541 } catch (e) {
542 fail ++ ;
543 if (rowWritten) {
544 // The attempt scored; only a post-row write (trace, ref) failed. An error
545 // row here would double-count the billed usage under the budget rule.
546 eprint ( ` [${ args . variant }] ${ c . id } rep${ rep } scored, but a post-row write failed: ${ e ?. message || e }` );
547 continue ;
548 }
549 // Failed attempts are data too - but they must not occupy the (case, rep)
550 // slot in results.jsonl, or resume would never re-run them.
551 appendFileNoFollow (errorsPath, JSON . stringify ({
552 prompt_id: safeId, rep,
553 ... (safeId !== String (c.id) ? { original_id: String (c.id) } : {}),
554 failure_class: e?.failure_class ?? 'error' ,
555 error: String (e?.message || e),
556 retries: appRetry.count, judge_retries: judgeRetry.count,
557 // Billed-but-failed spend stays countable: when the app call completed
558 // before the failure (e.g. a served-model mismatch, a judge-stage
559 // ceiling), carry its identity and usage on the error row.
560 model: lastRun?.model, usage: lastRun?.usage,
561 judge_model: e?.judge_model ?? lastRun?.judge_model,
562 judge_usage: e?.judge_usage ?? lastRun?.judge_usage,
563 latency_s: (Date. now () - t0) / 1000 ,
564 }) + ' \n ' );
565 eprint ( ` [${ args . variant }] ${ c . id } rep${ rep } FAILED: ${ e ?. message || e }` );
566 }
567 }
568 }
569 // One progress line every 30s (and to <vdir>/progress.txt) so "how far along
570 // is it?" is answerable from the background shell's output or one file read,
571 // without the orchestrator parsing results.jsonl mid-write. ETA is a plain
572 // rate extrapolation from this pass.
573 const t0 = Date. now ();
574 const progress = () => {
575 const done = ok + fail, total = tasks. length ;
576 const el = (Date. now () - t0) / 1000 ;
577 const eta = done ? Math. round ((el / done) * (total - done)) : null ;
578 const line = `[${ args . variant }] ${ done }/${ total } done (${ ok } ok, ${ fail } failed), `
579 + `${ Math . round ( el ) }s elapsed` + (eta != null ? `, ~${ eta }s left` : '' );
580 eprint (line);
581 try { writeFileNoFollow ( join (vdir, 'progress.txt' ), line + ' \n ' ); } catch {}
582 };
583 const tick = setInterval (progress, 30_000 );
584 workersStarted = true ;
585 await Promise . all (Array. from ({ length: Math. max ( 1 , args.concurrency) }, worker));
586 clearInterval (tick); progress ();
587 eprint ( `[${ args . variant }] done - ${ ok } ok, ${ fail } failed -> ${ resultsPath }` );
588 process. exit (fail ? 1 : 0 );
589 }
590
591 // Anything main() throws prints as one sanitized line, not a raw stack. Before
592 // the workers start it is a refusal (a planted link at _state.json or
593 // results.jsonl, an lstat that fails, an error from loadCases) and exits 2 like
594 // the preflight refusals. After they start, only a failed errors.jsonl append
595 // gets here; rows may already be on disk, so say that and exit 1.
596 let workersStarted = false ;
597 main (). catch ( e => {
598 const m = String (e?.message || e);
599 if (workersStarted) { eprint ( 'stopped mid-run (rows already written are kept; re-run to resume): ' + m); process. exit ( 1 ); }
600 eprint (m. startsWith ( 'refusing to ' ) ? m : 'refusing to run: ' + m);
601 process. exit ( 2 );
602 });