lastlight-evals 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dashboard/dist/assets/index-B4TAaIUR.css +1 -0
  2. package/dashboard/dist/assets/index-LFdFc6Z9.js +109 -0
  3. package/dashboard/dist/index.html +2 -2
  4. package/datasets/aacr-bench/README.md +146 -0
  5. package/datasets/pr-review/README.md +41 -0
  6. package/datasets/pr-review/anchors.json +3709 -0
  7. package/dist/add-case.js.map +1 -1
  8. package/dist/arm.js +14 -5
  9. package/dist/arm.js.map +1 -1
  10. package/dist/bootstrap.js +14 -0
  11. package/dist/bootstrap.js.map +1 -1
  12. package/dist/clean.js.map +1 -1
  13. package/dist/config.js +59 -20
  14. package/dist/config.js.map +1 -1
  15. package/dist/fake-github.js +72 -0
  16. package/dist/fake-github.js.map +1 -1
  17. package/dist/grade.js +308 -64
  18. package/dist/grade.js.map +1 -1
  19. package/dist/init.js.map +1 -1
  20. package/dist/judge.js +19 -0
  21. package/dist/judge.js.map +1 -1
  22. package/dist/mechanism.test.js +303 -13
  23. package/dist/mechanism.test.js.map +1 -1
  24. package/dist/metrics.js +88 -9
  25. package/dist/metrics.js.map +1 -1
  26. package/dist/paths.js +135 -1
  27. package/dist/paths.js.map +1 -1
  28. package/dist/phase-models.js +56 -0
  29. package/dist/phase-models.js.map +1 -0
  30. package/dist/pool.js +32 -0
  31. package/dist/pool.js.map +1 -0
  32. package/dist/pool.test.js +180 -0
  33. package/dist/pool.test.js.map +1 -0
  34. package/dist/pr-context.js +77 -6
  35. package/dist/pr-context.js.map +1 -1
  36. package/dist/repeats.test.js +317 -0
  37. package/dist/repeats.test.js.map +1 -0
  38. package/dist/report.js +99 -5
  39. package/dist/report.js.map +1 -1
  40. package/dist/review-metrics.js +571 -0
  41. package/dist/review-metrics.js.map +1 -0
  42. package/dist/review-metrics.test.js +634 -0
  43. package/dist/review-metrics.test.js.map +1 -0
  44. package/dist/review-pipeline-stats.js +455 -0
  45. package/dist/review-pipeline-stats.js.map +1 -0
  46. package/dist/review-pipeline-stats.test.js +413 -0
  47. package/dist/review-pipeline-stats.test.js.map +1 -0
  48. package/dist/run-instance.js +176 -45
  49. package/dist/run-instance.js.map +1 -1
  50. package/dist/run.js +637 -122
  51. package/dist/run.js.map +1 -1
  52. package/dist/schema.js +22 -1
  53. package/dist/schema.js.map +1 -1
  54. package/package.json +9 -8
  55. package/dashboard/dist/assets/index-B1jNgf2P.js +0 -313
  56. package/dashboard/dist/assets/index-Sic0jI7y.css +0 -1
package/dist/run.js CHANGED
@@ -26,10 +26,13 @@ import chalk from "chalk";
26
26
  import { loadDotEnv, hasProviderKey, evalModels, compareModels, modelLabels, resolveModel, setModelsPath } from "./env.js";
27
27
  import { runInstance, applyEvalEnv, slug } from "./run-instance.js";
28
28
  import { modelsArm, configArm, releaseOverlayGuard } from "./arm.js";
29
+ import { mapPool } from "./pool.js";
29
30
  import { summarize, writeArtifacts, writeScorecard, aggregateTrials, loadMartianSidecar, computeMartianRanking, } from "./report.js";
30
- import { bootstrapAssets } from "./bootstrap.js";
31
+ import { bootstrapAssets, describeCore } from "./bootstrap.js";
31
32
  import { discoverTiers, loadInstances, workflowFor } from "./discovery.js";
32
- import { builtinDatasetsRoot, tierResultsDir, makeRunId, gitShortSha, resultsRoot, dashboardDistRoot } from "./paths.js";
33
+ import { builtinDatasetsRoot, tierResultsDir, makeRunId, assignRunIds, gitShortSha, resultsRoot, dashboardDistRoot, packageRoot, harnessVersion, resolveFactsBin, factsToolchainStamp, } from "./paths.js";
34
+ import { defaultJudgeModel } from "./judge.js";
35
+ import { defaultBeta } from "./grade.js";
33
36
  import { startServer } from "./serve.js";
34
37
  import { runInit } from "./init.js";
35
38
  import { runAddCase } from "./add-case.js";
@@ -186,7 +189,7 @@ function strFlagAll(name) {
186
189
  return out;
187
190
  }
188
191
  /** CLI flags that take a following value (so it isn't read as a tier name). */
189
- const VALUE_FLAGS = new Set(["--runs", "--model", "--models", "--mode", "--overlay", "--datasets", "--models-file", "--instance", "--limit", "--f-beta", "--sandbox"]);
192
+ const VALUE_FLAGS = new Set(["--runs", "--repeats", "--model", "--models", "--mode", "--overlay", "--datasets", "--models-file", "--instance", "--limit", "--f-beta", "--sandbox", "--concurrency", "--repeat-concurrency"]);
190
193
  async function runEval() {
191
194
  loadDotEnv();
192
195
  p.intro(chalk.bold(`Last Light ${chalk.yellow("·")} eval`));
@@ -254,6 +257,13 @@ async function runEval() {
254
257
  if (overlayDir && overlayDir === autoOverlay)
255
258
  p.log.info(`overlay → ${chalk.cyan("./instance")} ${chalk.dim("(auto-detected)")}`);
256
259
  const { builtInRoot } = bootstrapAssets({ overlayDir });
260
+ // Say WHICH core is about to run, at the top, every time. A published core and
261
+ // a working tree can report the same version, so an arm measuring unreleased
262
+ // engine work would otherwise look identical to one that never loaded it —
263
+ // and every gate in the deterministic-pr-levers plan is a delta against a
264
+ // stored baseline, where that reads as a real negative result.
265
+ const core = describeCore(builtInRoot);
266
+ p.log.info(`core → ${chalk.cyan(core.version)} ${core.published ? chalk.yellow("(published package)") : chalk.green("(working tree)")} ${chalk.dim(core.root)}`);
257
267
  // A user/overlay can ship its own model registry too: explicit --models-file
258
268
  // wins, else an overlay's `evals/models.json` if present, else the built-in.
259
269
  const overlayModels = overlayDir ? join(overlayDir, "evals", "models.json") : undefined;
@@ -276,7 +286,25 @@ async function runEval() {
276
286
  p.outro(chalk.red("aborted"));
277
287
  return 1;
278
288
  }
279
- const noOpen = process.argv.includes("--no-open") || !!process.env.CI;
289
+ // `--repeats N` — run the WHOLE arm N times, as N sibling runs, so a result can
290
+ // be reported as a band rather than a point. Three identical runs of one arm
291
+ // measured micro-recall 0.320 / 0.080 / 0.200 (union 0.440, intersection 0.040
292
+ // — 1 of 25 gold findings found by all three), and `diff-runs` returned KEEP on
293
+ // one and REVERT on the other two FROM ONE CONFIGURATION. A single arm is not
294
+ // a measurement of that arm.
295
+ //
296
+ // Emphatically NOT `--runs N`, which repeats each CASE and folds the trials
297
+ // into one worst-case result with mean metrics (`aggregateTrials`): that
298
+ // destroys the per-trial hit vectors, so it can never produce a band. The two
299
+ // compose (`--repeats 3 --runs 2` = 3 bands of 2-trial cases), they do not
300
+ // substitute.
301
+ const repeats = intFlag("repeats", 1);
302
+ // A repeat loop must never leave a dashboard server behind: `run` holds one open
303
+ // FOREVER at the end (see the server-hold block), and on 2026-08-23 seven leaked
304
+ // servers had accumulated overnight from ordinary single runs. N of them would be
305
+ // worse, and the band is read afterwards anyway — so `--repeats` implies
306
+ // `--no-open` and prints the standalone `serve` command once instead.
307
+ const noOpen = process.argv.includes("--no-open") || !!process.env.CI || repeats > 1;
280
308
  const runs = intFlag("runs", 1);
281
309
  // Positional tier names — skip flags AND the values that follow value-flags.
282
310
  const argv = process.argv.slice(2);
@@ -367,7 +395,11 @@ async function runEval() {
367
395
  : compare
368
396
  ? compareModels().map((m) => ({ id: m.id, family: m.envKey ?? m.provider ?? "default" }))
369
397
  : evalModels().map((id) => ({ id, family: "default" }));
370
- arms = entries.map((e) => modelsArm(e.id, e.family));
398
+ // The overlay goes in even though the model is forced: it also carries the
399
+ // arm's `review:` policy (`review.analysis.enabled` — the review evidence
400
+ // pipeline switch), which is a deployment fact a `--model` flag says nothing
401
+ // about. Same overlay `bootstrapAssets` already wired above.
402
+ arms = entries.map((e) => modelsArm(e.id, e.family, overlayDir));
371
403
  }
372
404
  if (!arms.length) {
373
405
  p.log.error("No comparison models available — set provider keys (OPENAI_API_KEY / ANTHROPIC_API_KEY /\n" +
@@ -410,6 +442,17 @@ async function runEval() {
410
442
  // run-instance's injection step. ON by default; `--no-inject-context` (or
411
443
  // `EVAL_INJECT_CONTEXT=0`) forces a clean control run for an A/B.
412
444
  const injectContext = !process.argv.includes("--no-inject-context") && process.env.EVAL_INJECT_CONTEXT !== "0";
445
+ // Keep each trial's workspace instead of deleting it (`runInstance`'s
446
+ // `finally`). The evidence pipeline's deliverable is files —
447
+ // `.lastlight/pr-review/{facts.json,obligations/,hypotheses/,probes/}` — and
448
+ // without this flag the only way to read one was to catch a live run
449
+ // mid-flight, which is not an instrument. OFF by default: a kept workspace is
450
+ // a full checkout, plus `node_modules` once `prepare` installs.
451
+ //
452
+ // `--repeats` forces it on: when the repeats disagree, the question is always
453
+ // *which* evidence each one produced, and a previous repeat was already lost
454
+ // for want of the flag. A band you cannot open is a band you cannot explain.
455
+ const keepWorkspace = process.argv.includes("--keep-workspace") || repeats > 1;
413
456
  // Execution sandbox backend (or EVAL_SANDBOX). Default `none` (in-process, no
414
457
  // QEMU dependency — the fast/CI path). `gondolin` isolates the agent's tools
415
458
  // in a QEMU micro-VM so it can't read host gold data, while keeping the fake
@@ -434,6 +477,68 @@ async function runEval() {
434
477
  return 1;
435
478
  }
436
479
  }
480
+ // `--concurrency N`: how many CASES of ONE arm run at once. Default 1, so an
481
+ // invocation without the flag takes the exact serial path it always did.
482
+ //
483
+ // Within-arm serialism was never a correctness constraint — it is the
484
+ // rate-limit choice recorded above `byFamily` below. Everything that makes
485
+ // in-process concurrency safe is already true per case: a fresh `mkdtemp`
486
+ // stateDir, its own fake-GitHub port, a threaded per-run `cwd` with no
487
+ // `process.chdir`, and the eval env hoisted once for the whole batch. What is
488
+ // NOT safe is two ARMS at once — see the overlay note where the pool is driven.
489
+ const concurrencyRaw = strFlag("concurrency");
490
+ let concurrency = concurrencyRaw !== undefined ? Number(concurrencyRaw) : 1;
491
+ if (!Number.isFinite(concurrency) || !Number.isInteger(concurrency) || concurrency < 1) {
492
+ p.log.error(`--concurrency must be a positive integer, got "${concurrencyRaw}".`);
493
+ return 1;
494
+ }
495
+ // gondolin boots a QEMU micro-VM per case. Clamp rather than error: the flag is
496
+ // about throughput and the right answer on this backend is "one", not "refuse".
497
+ if (sandbox === "gondolin" && concurrency > 1) {
498
+ p.log.warn(`--concurrency ${concurrency} ignored: --sandbox gondolin runs a QEMU micro-VM per case. Using 1.`);
499
+ concurrency = 1;
500
+ }
501
+ // `--repeat-concurrency N`: how many of the `--repeats` REPEATS run at once.
502
+ // Default 1 = today's strictly-sequential repeat loop, untouched. With N>1 the
503
+ // repeats of the arm overlap — safe because every repeat runs the SAME arm(s)
504
+ // over ONE overlay, so the process-global asset-root guard (ADR 0001) is taken
505
+ // once for the whole batch, exactly as `--concurrency` holds it per arm.
506
+ //
507
+ // What overlap costs is the LATENCY instrument: concurrent repeats contend for
508
+ // CPU/network, so per-phase `durationMs`/`agentMs` on such runs are
509
+ // contaminated. That trade is accepted and made legible — each repeat's
510
+ // `meta.repeat.concurrency` records it so latency reads can be discounted.
511
+ // Verdicts, cost, and recall/precision are unaffected.
512
+ //
513
+ // Composes with `--concurrency` (case-level): total in-flight cases =
514
+ // repeatConcurrency × concurrency. No cap — size the product against the
515
+ // provider's rate limit.
516
+ const repeatConcurrencyRaw = strFlag("repeat-concurrency");
517
+ let repeatConcurrency = repeatConcurrencyRaw !== undefined ? Number(repeatConcurrencyRaw) : 1;
518
+ if (!Number.isFinite(repeatConcurrency) || !Number.isInteger(repeatConcurrency) || repeatConcurrency < 1) {
519
+ p.log.error(`--repeat-concurrency must be a positive integer, got "${repeatConcurrencyRaw}".`);
520
+ return 1;
521
+ }
522
+ // Same clamp-not-refuse policy as --concurrency: one QEMU micro-VM at a time.
523
+ if (sandbox === "gondolin" && repeatConcurrency > 1) {
524
+ p.log.warn(`--repeat-concurrency ${repeatConcurrency} ignored: --sandbox gondolin runs a QEMU micro-VM per case. Using 1.`);
525
+ repeatConcurrency = 1;
526
+ }
527
+ // A multi-overlay `config` run switches the process-global asset root between
528
+ // arms, so its repeats cannot overlap: two repeats in different arms would be
529
+ // two overlays at once (the guard would throw — ADR 0001). One overlay per
530
+ // batch is the invariant the concurrent branch is built on.
531
+ if (runType === "config" && arms.length > 1 && repeatConcurrency > 1) {
532
+ p.log.warn(`--repeat-concurrency ${repeatConcurrency} ignored: a multi-overlay config run switches the ` +
533
+ `process-global asset root between arms (ADR 0001), so repeats must stay sequential. Using 1.`);
534
+ repeatConcurrency = 1;
535
+ }
536
+ if (repeats === 1 && repeatConcurrency > 1) {
537
+ p.log.warn(`--repeat-concurrency ${repeatConcurrency} has nothing to overlap without --repeats N>1 — ignored.`);
538
+ repeatConcurrency = 1;
539
+ }
540
+ /** True when the repeat loop actually overlaps (the concurrent branch below). */
541
+ const repeatsConcurrent = repeats > 1 && repeatConcurrency > 1;
437
542
  // Instances per tier, resolved ONCE — the case set is identical across arms
438
543
  // (arms vary only the model selection / assets, never the cases).
439
544
  const tierInstances = new Map();
@@ -582,8 +687,33 @@ async function runEval() {
582
687
  const tierKeyFor = (tier) => `${tier}${runType === "config" ? "-config" : compare ? "-compare" : ""}`;
583
688
  // One shared runId, checked free in the first tier's dir (collisions in the
584
689
  // same second across runs are what the suffix guards — rare, one dir suffices).
585
- const runId = makeRunId(new Date(), gitSha, tierResultsDir(tierKeyFor(tiers[0])));
586
- const resultsDirFor = (tier) => join(tierResultsDir(tierKeyFor(tier)), runId);
690
+ //
691
+ // `let`, not `const`, and reassigned once per `--repeats` iteration. Every
692
+ // closure below that names a run directory — `resultsDirFor`, `baseMetaFor`,
693
+ // the session paths built from them — reads `runId` at CALL time, so a single
694
+ // reassignment repoints all of them with no code movement. Repeats are SIBLING
695
+ // dirs (`<tierKey>/<runId>/`, a fresh id each time): `indexTier` and `clean`
696
+ // both walk exactly two levels, so a nested `<runId>/rep-2/` would be invisible
697
+ // to the dashboard AND uncleanable. `makeRunId`'s existing `-2`/`-3` suffix
698
+ // already handles two repeats finishing inside the same second.
699
+ //
700
+ // CONCURRENT repeats (`--repeat-concurrency N>1`) can't assign lazily — N
701
+ // launches inside one second would all resolve the same id, because the ids
702
+ // exist nowhere on disk yet for `makeRunId` to dedupe against. So the whole
703
+ // band's ids are PRE-ASSIGNED here (`assignRunIds` bumps the second per id,
704
+ // keeping every id `<timestamp>-<sha>`-shaped with a batch-unique stamp) and
705
+ // each repeat's state carries its own. The sequential path keeps lazy
706
+ // assignment, one fresh id per repeat, exactly as before.
707
+ const preassignedRunIds = repeatsConcurrent
708
+ ? assignRunIds(repeats, new Date(), gitSha, tierResultsDir(tierKeyFor(tiers[0])))
709
+ : undefined;
710
+ let runId = preassignedRunIds?.[0] ?? makeRunId(new Date(), gitSha, tierResultsDir(tierKeyFor(tiers[0])));
711
+ /** The band's id = the FIRST repeat's runId (see `RepeatRef`) — pre-assigned
712
+ * before any repeat launches, so the group label never depends on run order. */
713
+ const repeatGroup = runId;
714
+ /** 1-based position in the band; bumped by the (sequential) repeat loop. */
715
+ let repeatIndex = 1;
716
+ const resultsDirFor = (tier, rid = runId) => join(tierResultsDir(tierKeyFor(tier)), rid);
587
717
  // Each case's session logs live under `sessions/<id>__<model>/trial-<N>/`
588
718
  // (relative to the tier run dir) — the model is in the name since several
589
719
  // models share a run dir; per-trial keeps every `--runs N` trial. `full.jsonl`
@@ -595,14 +725,77 @@ async function runEval() {
595
725
  // identity, labels, and live state straight off disk). `live`/`progress`/
596
726
  // `pending`/`generatedAt` are layered on per write; `tiers` is the single tier.
597
727
  const armLabels = arms.map((a) => labels[a.label] ?? a.label);
598
- const baseMetaFor = (tier) => ({
599
- runId,
728
+ // The invocation, recorded beside the numbers it produced (see `RunProvenance`).
729
+ // Resolved ONCE — none of it varies per repeat, and the facts-toolchain probe
730
+ // spawns a process. `judgeModel`/`factsBin` are what this run WOULD use, which
731
+ // is the point: an arm reporting `coverage: "none"` is explained by a null
732
+ // `factsBin`, and a scorecard whose judge silently changed between two runs is
733
+ // otherwise indistinguishable from one where the models changed.
734
+ const factsBin = resolveFactsBin();
735
+ // A pr-review tier with no facts binary runs a DIFFERENT pipeline than the
736
+ // one the scorecard claims: the survey seed, the adjudicate conservation gate
737
+ // and the reconcile floor all shell out to `lastlight-facts` and exit 127
738
+ // without it — measured 2026-08-25, every adjudication ran to max_iterations
739
+ // and shipped unrepaired findings while the run reported all phases green.
740
+ // Warn loudly rather than refuse: an analysis-DISABLED pr-review arm (the
741
+ // shipped baseline) never touches the binary and is fine without it.
742
+ if (!factsBin && tiers.some((t) => tierKeyFor(t).includes("pr-review"))) {
743
+ p.log.warn("lastlight-facts is UNRESOLVABLE (factsBin: null). Any analysis-enabled pr-review arm will run with " +
744
+ "its conservation gate and reconcile floor dead (exit 127) — adjudication loops to max_iterations and " +
745
+ "findings ship unrepaired. Set LASTLIGHT_FACTS_BIN or build packages/code-facts before trusting this run.");
746
+ }
747
+ let judgeModel;
748
+ try {
749
+ judgeModel = defaultJudgeModel();
750
+ }
751
+ catch {
752
+ judgeModel = undefined; // no provider key resolves one; already fatal above for models
753
+ }
754
+ const provenance = {
755
+ overlay: overlayDir,
756
+ overlays: overlays.length ? overlays : undefined,
757
+ datasets: userDatasetsDir,
758
+ sandbox,
759
+ fBeta: fBeta ?? defaultBeta(),
760
+ judgeWithDiff: judge.withDiff,
761
+ injectContext,
762
+ keepWorkspace,
763
+ instances: instanceFilter.length ? instanceFilter : undefined,
764
+ limit,
765
+ repeats: repeats > 1 ? repeats : undefined,
766
+ judgeModel,
767
+ factsBin,
768
+ toolchain: factsToolchainStamp(factsBin),
769
+ harness: { version: harnessVersion(), root: packageRoot() },
770
+ argv: process.argv.slice(2),
771
+ };
772
+ // `rid`/`idx` default to the sequential loop's shared `runId`/`repeatIndex`;
773
+ // a CONCURRENT repeat passes its own (two repeats in flight cannot share one).
774
+ const baseMetaFor = (tier, rid = runId, idx = repeatIndex) => ({
775
+ // Flat on `meta`, exactly like `gitSha`/`concurrency`/`core`.
776
+ ...provenance,
777
+ runId: rid,
600
778
  runType,
601
779
  tiers: [tier],
602
780
  models: armLabels,
603
781
  runs,
782
+ concurrency,
783
+ // A band of one is not a band — an ordinary run's meta stays exactly as it
784
+ // was, and `undefined` reads as "not repeated" rather than "repeated once".
785
+ // `repeat.concurrency` is stamped only when the repeats actually overlapped
786
+ // (`--repeat-concurrency N>1`) — the flag that contaminates per-phase
787
+ // latency, recorded so `durationMs`/`agentMs` reads can be discounted.
788
+ repeat: repeats > 1
789
+ ? {
790
+ group: repeatGroup,
791
+ index: idx,
792
+ of: repeats,
793
+ ...(repeatsConcurrent ? { concurrency: repeatConcurrency } : {}),
794
+ }
795
+ : undefined,
604
796
  gitSha,
605
797
  labels,
798
+ core: describeCore(builtInRoot),
606
799
  });
607
800
  // pr-review: load each tier's Martian leaderboard sidecar ONCE. Lets every
608
801
  // scorecard carry "where would we rank" over the PRs it covered (recomputed per
@@ -631,22 +824,32 @@ async function runEval() {
631
824
  `${chalk.bold("tiers")} ${tiers.join(", ")}\n` +
632
825
  `${chalk.bold("cases")} ${work.length}${runs > 1
633
826
  ? chalk.dim(` × ${runs} trials = ${work.length * runs} runs · worst-case verdict, mean cost`)
827
+ : ""}${repeats > 1
828
+ ? `\n${chalk.bold("repeats")} ${repeats}${chalk.dim(` × the whole arm, ${repeatsConcurrent
829
+ ? `up to ${repeatConcurrency} at once (per-phase latency contends — stamped meta.repeat.concurrency)`
830
+ : "sequentially"} · ${repeats} sibling runs, read as a band (implies --keep-workspace, --no-open)`)}`
634
831
  : ""}`, "plan");
635
832
  // `total` counts individual trials so live progress advances per model call.
636
833
  const total = work.length * runs;
637
- // Seed an empty live scorecard per tier so the dashboard has something to poll,
638
- // then start the server and open the SPA deep-linked at this run. The server is
639
- // skipped entirely when not opening (CI / --no-open) — we only write JSON.
640
- for (const tier of tiers) {
641
- writeScorecard(resultsDirFor(tier), withMeta(summarize([]), {
642
- ...baseMetaFor(tier),
643
- generatedAt: new Date().toISOString(),
644
- live: true,
645
- pid: process.pid,
646
- heartbeat: new Date().toISOString(),
647
- progress: `0/${total}`,
648
- }));
649
- }
834
+ // Seed an empty live scorecard per tier so the dashboard has something to poll.
835
+ // Called once per repeat, AFTER `runId` has been repointed, so each sibling run
836
+ // dir exists from the moment its repeat starts rather than only when it ends.
837
+ const seedScorecards = (rid = runId, idx = repeatIndex) => {
838
+ for (const tier of tiers) {
839
+ writeScorecard(resultsDirFor(tier, rid), withMeta(summarize([]), {
840
+ ...baseMetaFor(tier, rid, idx),
841
+ generatedAt: new Date().toISOString(),
842
+ live: true,
843
+ pid: process.pid,
844
+ heartbeat: new Date().toISOString(),
845
+ progress: `0/${total}`,
846
+ }));
847
+ }
848
+ };
849
+ // Repeat 1's seed lands BEFORE the server starts (the SPA must have something
850
+ // to poll the instant it opens deep-linked at this run) and before the possibly
851
+ // slow prefetch.
852
+ seedScorecards();
650
853
  let server;
651
854
  if (!noOpen) {
652
855
  try {
@@ -669,23 +872,38 @@ async function runEval() {
669
872
  p.outro(chalk.red("aborted"));
670
873
  return 1;
671
874
  }
875
+ // The per-repeat accumulators. Declared ONCE, outside the repeat loop, because
876
+ // `refresh`/`runItem` close over them — the loop RESETS them in place rather
877
+ // than rebinding, so every closure keeps pointing at the live objects.
672
878
  const all = [];
673
879
  let harnessErrors = 0;
674
880
  let completed = 0;
881
+ /** Harness errors across ALL repeats — what the exit code is read from. A band
882
+ * whose first repeat blew up and whose last was clean is not a clean run. */
883
+ let totalHarnessErrors = 0;
884
+ /** Trials + cases summed over every repeat, for the closing line. */
885
+ let totalCompleted = 0;
886
+ let totalCases = 0;
675
887
  // Track in-flight cases so the live report can show running / queued rows.
676
888
  const caseKey = (tier, model, id) => `${tier}|${model}|${id}`;
677
889
  const running = new Set();
678
890
  // Current trial number per running case, so the live "follow" link points at
679
891
  // the right `trial-<N>/full.jsonl` (matters only for `--runs N>1`).
680
892
  const trialOf = new Map();
893
+ /** The concurrent repeats currently in flight — what the heartbeat re-stamps. */
894
+ const activeStates = new Set();
681
895
  // writeScorecard/summarize/all.push run synchronously to completion inside one
682
896
  // event-loop turn, so even with concurrent families they never interleave; the
683
897
  // temp-file+rename keeps a polling dashboard from reading a half-written file.
684
- const refresh = () => {
685
- const done = new Set(all.map((r) => caseKey(r.tier ?? "", r.model, r.instance_id)));
898
+ const refresh = (st) => {
899
+ // A concurrent repeat's own accumulators, else the sequential shared ones.
900
+ const results = st?.all ?? all;
901
+ const inFlight = st?.running ?? running;
902
+ const trialNo = st?.trialOf ?? trialOf;
903
+ const done = new Set(results.map((r) => caseKey(r.tier ?? "", r.model, r.instance_id)));
686
904
  const now = new Date().toISOString();
687
905
  for (const tier of tiers) {
688
- const tierResults = all.filter((r) => (r.tier ?? "") === tier);
906
+ const tierResults = results.filter((r) => (r.tier ?? "") === tier);
689
907
  const pending = work
690
908
  .filter((w) => w.tierName === tier)
691
909
  .map((w) => ({ w, k: caseKey(w.tierName, w.arm.label, w.inst.instance_id) }))
@@ -694,18 +912,18 @@ async function runEval() {
694
912
  tier: w.tierName,
695
913
  model: w.arm.label,
696
914
  instance_id: w.inst.instance_id,
697
- status: running.has(k) ? "running" : "pending",
915
+ status: inFlight.has(k) ? "running" : "pending",
698
916
  // Only a running case has a (live-updating) transcript to follow —
699
917
  // point at the current trial's consolidated `full.jsonl`.
700
- sessionLog: running.has(k)
701
- ? `${trialRelFor(w.inst.instance_id, w.arm.label, trialOf.get(k) ?? 1)}/full.jsonl`
918
+ sessionLog: inFlight.has(k)
919
+ ? `${trialRelFor(w.inst.instance_id, w.arm.label, trialNo.get(k) ?? 1)}/full.jsonl`
702
920
  : undefined,
703
921
  }));
704
922
  // Per-tier progress (cases), not the global trial count — each tier's
705
923
  // scorecard stands alone, so "0/5" across both tiers was misleading.
706
924
  const tierCases = work.filter((w) => w.tierName === tier).length;
707
- writeScorecard(resultsDirFor(tier), withMeta(summarize(tierResults), {
708
- ...baseMetaFor(tier),
925
+ writeScorecard(resultsDirFor(tier, st?.runId), withMeta(summarize(tierResults), {
926
+ ...baseMetaFor(tier, st?.runId, st?.index),
709
927
  generatedAt: now,
710
928
  live: true,
711
929
  // Liveness signals: a `live` run whose heartbeat goes stale (writer
@@ -719,13 +937,23 @@ async function runEval() {
719
937
  }));
720
938
  }
721
939
  };
940
+ // NOT reset between repeats: its only consumer is the end-of-run summary, and
941
+ // the whole reason `--repeats` forces `--keep-workspace` is that you need to
942
+ // open EVERY repeat's evidence when they disagree. A per-repeat reset would
943
+ // print only the last one's paths and silently orphan the rest.
944
+ const keptWorkspaces = [];
722
945
  // Run one case `runs` times and fold the trials into a single result
723
- // (worst-case verdict, mean metrics). `onTrial` ticks per model call.
724
- const runItem = async (w, onTrial) => {
946
+ // (worst-case verdict, mean metrics). `onTrial` ticks per model call. `st` is
947
+ // a concurrent repeat's own state (`--repeat-concurrency N>1`); absent, the
948
+ // sequential shared accumulators are used, unchanged.
949
+ const runItem = async (w, onTrial, st) => {
725
950
  const k = caseKey(w.tierName, w.arm.label, w.inst.instance_id);
726
951
  const trials = [];
952
+ // Collected per TRIAL, not off the aggregate: `aggregateTrials` carries
953
+ // trial 1's fields through, so with `--runs 3` the other two workspaces
954
+ // would still be on disk and named nowhere.
727
955
  for (let t = 1; t <= runs; t++) {
728
- trialOf.set(k, t); // so the live "follow" link targets this trial
956
+ (st?.trialOf ?? trialOf).set(k, t); // so the live "follow" link targets this trial
729
957
  const trialRel = trialRelFor(w.inst.instance_id, w.arm.label, t);
730
958
  const r = await runInstance(w.inst, {
731
959
  // The arm carries all model selection (forced model / merged config) +
@@ -740,15 +968,21 @@ async function runEval() {
740
968
  defaultWorkflow: w.defaultWorkflow,
741
969
  manageEnv: false,
742
970
  // Per-trial dir: `full.jsonl` (consolidated, live) + `NN-<phase>.jsonl`.
743
- sessionTrialDir: join(resultsDirFor(w.tierName), trialRel),
971
+ sessionTrialDir: join(resultsDirFor(w.tierName, st?.runId), trialRel),
744
972
  sessionTrialRel: trialRel,
745
973
  trial: t,
746
974
  judge,
747
975
  sandbox,
976
+ keepWorkspace,
748
977
  });
749
978
  r.tier = w.tierName;
979
+ if (r.workspaceDir)
980
+ keptWorkspaces.push({ id: w.inst.instance_id, trial: t, repeat: st?.index ?? repeatIndex, path: r.workspaceDir });
750
981
  trials.push(r);
751
- completed++;
982
+ if (st)
983
+ st.completed++;
984
+ else
985
+ completed++;
752
986
  onTrial();
753
987
  }
754
988
  const agg = aggregateTrials(trials);
@@ -763,112 +997,359 @@ async function runEval() {
763
997
  // Heartbeat: re-stamp the live scorecard every 20s so a long-running single
764
998
  // phase keeps its `heartbeat` fresh; if the process dies the stamp goes stale
765
999
  // and the index reclassifies the run as interrupted. `unref` so the timer
766
- // never keeps the process alive on its own.
767
- const heartbeat = setInterval(() => refresh(), 20_000);
1000
+ // never keeps the process alive on its own. With concurrent repeats each
1001
+ // in-flight repeat's OWN scorecard is re-stamped (never the shared one — its
1002
+ // accumulators are unused there, and a shared refresh would overwrite repeat
1003
+ // 1's card with an empty live one).
1004
+ const heartbeat = setInterval(() => {
1005
+ if (repeatsConcurrent)
1006
+ for (const st of activeStates)
1007
+ refresh(st);
1008
+ else
1009
+ refresh();
1010
+ }, 20_000);
768
1011
  heartbeat.unref?.();
1012
+ /** Every repeat's per-tier artifact dirs, for the closing summary. */
1013
+ const repeatDirs = [];
769
1014
  try {
770
- if (parallel) {
771
- // Per-family progress for the aggregate spinner line.
772
- const fam = new Map();
773
- for (const [f, items] of byFamily)
774
- fam.set(f, { done: 0, total: items.length * runs });
775
- const status = () => {
776
- const segs = [...fam].map(([f, c]) => {
777
- const done = c.done === c.total ? chalk.green(`${c.done}/${c.total}`) : `${c.done}/${c.total}`;
778
- return `${familyLabel(f)} ${done}`;
779
- });
780
- return `${chalk.dim(`${completed}/${total}`)} ${segs.join(chalk.dim(" · "))}`;
781
- };
1015
+ if (repeatsConcurrent) {
1016
+ // ── `--repeat-concurrency N>1`: up to N whole-arm repeats in flight ──
1017
+ //
1018
+ // Everything the repeats share is BATCH-scoped, the same shape as the
1019
+ // case-concurrent branch below, widened to span the repeats:
1020
+ //
1021
+ // - ONE overlay for the whole batch. Every repeat runs the same arm
1022
+ // list, so the process-global asset root (ADR 0001) is activated once
1023
+ // here and released once in the finally — the guard window covers the
1024
+ // entire batch. (Multi-overlay config runs clamped the flag to 1 up
1025
+ // top: they have no single overlay for the window to hold.
1026
+ // `activate()` is a no-op for models arms.)
1027
+ // - Console is silenced ONCE (the per-run `quiet()` swap is not
1028
+ // concurrency-safe) and one spinner reports every repeat — the same
1029
+ // trade the parallel branches below make.
1030
+ // - RunIds were pre-assigned (`preassignedRunIds`), so simultaneous
1031
+ // launches cannot collide on the second; each repeat's `RepeatState`
1032
+ // carries its own id, index, and accumulators.
1033
+ //
1034
+ // Within a repeat, cases run through the same arms-outer work list with
1035
+ // `--concurrency` bounding cases in flight — so the total is
1036
+ // repeatConcurrency × concurrency. In models mode several arms' cases
1037
+ // interleave here (no overlay is at stake, only the provider rate limit);
1038
+ // the per-family grouping of the parallel branch is deliberately not
1039
+ // reproduced — repeat-level parallelism is the throughput lever.
1040
+ const states = preassignedRunIds.map((rid, i) => ({
1041
+ runId: rid,
1042
+ index: i + 1,
1043
+ all: [],
1044
+ harnessErrors: 0,
1045
+ completed: 0,
1046
+ running: new Set(),
1047
+ trialOf: new Map(),
1048
+ }));
1049
+ p.log.step(`${chalk.bold(`repeats ×${repeats}, up to ${repeatConcurrency} at once`)}\n` +
1050
+ chalk.dim(states.map((st) => ` ${st.index}/${repeats} ${st.runId}`).join("\n")));
1051
+ for (const arm of arms)
1052
+ arm.activate();
1053
+ const grandTotal = total * repeats;
1054
+ const grand = () => states.reduce((n, st) => n + st.completed, 0);
782
1055
  const s = makeSpinner();
1056
+ const status = () => `${chalk.dim(`${grand()}/${grandTotal}`)} ${states
1057
+ .map((st) => {
1058
+ const seg = `r${st.index} ${st.completed}/${total}`;
1059
+ return st.completed === total ? chalk.green(seg) : seg;
1060
+ })
1061
+ .join(chalk.dim(" · "))}`;
783
1062
  s.start(status());
784
1063
  const restoreConsole = silenceConsole();
785
1064
  const verdicts = [];
786
1065
  try {
787
- await Promise.all([...byFamily].map(async ([f, items]) => {
788
- for (const w of items) {
789
- const k = caseKey(w.tierName, w.arm.label, w.inst.instance_id);
790
- running.add(k);
791
- refresh();
792
- const result = await runItem(w, () => {
793
- fam.get(f).done++;
1066
+ await mapPool(states, repeatConcurrency, async (st) => {
1067
+ // Repeat 1's live scorecard was seeded before the server started /
1068
+ // the prefetch ran, like any run's; a later repeat seeds its sibling
1069
+ // dir the moment it LAUNCHES here, so the dashboard sees it live.
1070
+ if (st.index > 1)
1071
+ seedScorecards(st.runId, st.index);
1072
+ activeStates.add(st);
1073
+ try {
1074
+ await mapPool(work, concurrency, async (w) => {
1075
+ const k = caseKey(w.tierName, w.arm.label, w.inst.instance_id);
1076
+ st.running.add(k);
1077
+ refresh(st);
1078
+ const result = await runItem(w, () => {
1079
+ s.message(status());
1080
+ refresh(st);
1081
+ }, st);
1082
+ st.running.delete(k);
1083
+ st.all.push(result);
1084
+ if (result.error)
1085
+ st.harnessErrors++;
1086
+ const mark = result.error ? chalk.red("✗") : result.blocked ? chalk.yellow("■") : chalk.green("✓");
1087
+ verdicts.push(`${mark} ${chalk.dim(`r${st.index}/${repeats}`)} ${verdictLine(w.tierName, w.inst, result)}`);
794
1088
  s.message(status());
795
- refresh();
1089
+ refresh(st);
796
1090
  });
797
- running.delete(k);
798
- all.push(result);
799
- if (result.error)
800
- harnessErrors++;
801
- const mark = result.error ? chalk.red("✗") : result.blocked ? chalk.yellow("■") : chalk.green("✓");
802
- verdicts.push(`${mark} ${chalk.dim(familyLabel(f))} ${verdictLine(w.tierName, w.inst, result)}`);
803
- refresh();
804
1091
  }
805
- }));
1092
+ finally {
1093
+ activeStates.delete(st);
1094
+ }
1095
+ // Finalize THIS repeat as it lands — its siblings may still be running.
1096
+ const generatedAt = new Date().toISOString();
1097
+ for (const tier of tiers) {
1098
+ const tierResults = st.all.filter((r) => (r.tier ?? "") === tier);
1099
+ writeArtifacts(resultsDirFor(tier, st.runId), withMeta(summarize(tierResults), {
1100
+ ...baseMetaFor(tier, st.runId, st.index),
1101
+ generatedAt,
1102
+ live: false,
1103
+ martian: martianFor(tier, tierResults),
1104
+ }));
1105
+ repeatDirs.push(resultsDirFor(tier, st.runId));
1106
+ }
1107
+ totalHarnessErrors += st.harnessErrors;
1108
+ totalCompleted += st.completed;
1109
+ totalCases += st.all.length;
1110
+ });
806
1111
  }
807
1112
  finally {
808
1113
  restoreConsole();
1114
+ // The batch-wide overlay window closes exactly once, whatever happened.
1115
+ releaseOverlayGuard();
809
1116
  }
810
- s.stop(`${chalk.dim(`${completed}/${total}`)} ${chalk.green("done")}`);
1117
+ s.stop(`${chalk.dim(`${grand()}/${grandTotal}`)} ${chalk.green("done")}`);
811
1118
  p.log.message(verdicts.join("\n"));
812
1119
  }
813
- else {
814
- // Serial: one spinner per case (updates per trial) + a verdict line. On
815
- // each arm change `arm.activate()` repoints the (process-global) asset root
816
- // to that arm's overlay (a no-op for models arms); work is arms-outer, so
817
- // it fires at most once per arm. `releaseOverlayGuard()` lets the next arm
818
- // switch overlays — without it the guard treats the switch as a concurrent
819
- // overlay and throws (ADR 0001).
820
- let currentArm;
821
- for (let i = 0; i < work.length; i++) {
822
- const w = work[i];
823
- if (w.arm !== currentArm) {
824
- if (currentArm)
825
- releaseOverlayGuard();
826
- w.arm.activate();
827
- currentArm = w.arm;
1120
+ else
1121
+ // `--repeats N` (sequential, the default): the WHOLE arm, N times, one
1122
+ // after another. Overlapping is opt-in (`--repeat-concurrency`, the branch
1123
+ // above) because concurrent repeats contend for CPU/network and the same
1124
+ // provider rate limit — which contaminates the per-phase latency this
1125
+ // sequential path keeps clean.
1126
+ for (repeatIndex = 1; repeatIndex <= repeats; repeatIndex++) {
1127
+ if (repeatIndex > 1) {
1128
+ // A fresh SIBLING run id. Every closure that names a run dir reads
1129
+ // `runId` at call time, so this one assignment repoints all of them.
1130
+ // `makeRunId`'s `-2`/`-3` suffix covers two repeats landing in the same
1131
+ // second (a `--limit 1` smoke run can).
1132
+ runId = makeRunId(new Date(), gitSha, tierResultsDir(tierKeyFor(tiers[0])));
1133
+ // Reset the accumulators IN PLACE — `refresh`/`runItem` closed over these
1134
+ // objects, so rebinding them would leave the closures on the old ones.
1135
+ // `keptWorkspaces` is deliberately NOT reset (see its declaration).
1136
+ all.length = 0;
1137
+ harnessErrors = 0;
1138
+ completed = 0;
1139
+ running.clear();
1140
+ trialOf.clear();
1141
+ seedScorecards();
1142
+ p.log.step(`${chalk.bold(`repeat ${repeatIndex}/${repeats}`)} ${chalk.dim(runId)}`);
1143
+ }
1144
+ try {
1145
+ if (parallel) {
1146
+ // Per-family progress for the aggregate spinner line.
1147
+ const fam = new Map();
1148
+ for (const [f, items] of byFamily)
1149
+ fam.set(f, { done: 0, total: items.length * runs });
1150
+ const status = () => {
1151
+ const segs = [...fam].map(([f, c]) => {
1152
+ const done = c.done === c.total ? chalk.green(`${c.done}/${c.total}`) : `${c.done}/${c.total}`;
1153
+ return `${familyLabel(f)} ${done}`;
1154
+ });
1155
+ return `${chalk.dim(`${completed}/${total}`)} ${segs.join(chalk.dim(" · "))}`;
1156
+ };
1157
+ const s = makeSpinner();
1158
+ s.start(status());
1159
+ const restoreConsole = silenceConsole();
1160
+ const verdicts = [];
1161
+ try {
1162
+ await Promise.all([...byFamily].map(async ([f, items]) => {
1163
+ // Within a family, `--concurrency` bounds how many of its cases are
1164
+ // in flight. `models` arms never switch overlays (`activate()` is a
1165
+ // no-op for them), which is why this branch needs no arm grouping —
1166
+ // see the `config`/overlay note in the serial branch below.
1167
+ await mapPool(items, concurrency, async (w) => {
1168
+ const k = caseKey(w.tierName, w.arm.label, w.inst.instance_id);
1169
+ running.add(k);
1170
+ refresh();
1171
+ const result = await runItem(w, () => {
1172
+ fam.get(f).done++;
1173
+ s.message(status());
1174
+ refresh();
1175
+ });
1176
+ running.delete(k);
1177
+ all.push(result);
1178
+ if (result.error)
1179
+ harnessErrors++;
1180
+ const mark = result.error ? chalk.red("✗") : result.blocked ? chalk.yellow("■") : chalk.green("✓");
1181
+ verdicts.push(`${mark} ${chalk.dim(familyLabel(f))} ${verdictLine(w.tierName, w.inst, result)}`);
1182
+ refresh();
1183
+ });
1184
+ }));
1185
+ }
1186
+ finally {
1187
+ restoreConsole();
1188
+ }
1189
+ s.stop(`${chalk.dim(`${completed}/${total}`)} ${chalk.green("done")}`);
1190
+ p.log.message(verdicts.join("\n"));
1191
+ }
1192
+ else if (concurrency > 1) {
1193
+ // Concurrent WITHIN one arm, strictly serial ACROSS arms.
1194
+ //
1195
+ // The asset root core resolves workflows/skills/prompts from is a module
1196
+ // GLOBAL (ADR 0001), so two overlays can never be live at once — which is
1197
+ // what `activateOverlay`/`releaseOverlayGuard` enforce by throwing. Running
1198
+ // arms concurrently would therefore either throw or, worse, measure one
1199
+ // arm's cases against another arm's prompts. So the pool is opened and
1200
+ // closed inside each arm's overlay window, never across it.
1201
+ //
1202
+ // Cases within one arm have nothing global between them: per-run `mkdtemp`
1203
+ // stateDir, per-run fake-GitHub port, a threaded `cwd` (never
1204
+ // `process.chdir`), and the static-token env installed once for the batch.
1205
+ const groups = [];
1206
+ for (const w of work) {
1207
+ const tail = groups[groups.length - 1];
1208
+ if (tail && tail.arm === w.arm)
1209
+ tail.items.push(w);
1210
+ else
1211
+ groups.push({ arm: w.arm, items: [w] });
1212
+ }
1213
+ const s = makeSpinner();
1214
+ const status = () => `${chalk.dim(`${completed}/${total}`)} ${chalk.dim(`×${concurrency}`)}`;
1215
+ s.start(status());
1216
+ // The per-case `quiet()` swap saves/restores `console` and is NOT
1217
+ // re-entrant, so concurrency drops console for the batch instead — the
1218
+ // same trade the family-parallel branch makes.
1219
+ const restoreConsole = silenceConsole();
1220
+ const verdicts = [];
1221
+ try {
1222
+ for (const g of groups) {
1223
+ g.arm.activate();
1224
+ try {
1225
+ await mapPool(g.items, concurrency, async (w) => {
1226
+ // The invariant this whole branch is built on, asserted rather
1227
+ // than assumed: one overlay is live and it is this arm's.
1228
+ if (w.arm !== g.arm)
1229
+ throw new Error(`concurrency crossed an arm boundary: ${w.arm.label} in ${g.arm.label}`);
1230
+ const k = caseKey(w.tierName, w.arm.label, w.inst.instance_id);
1231
+ running.add(k);
1232
+ refresh();
1233
+ const result = await runItem(w, () => {
1234
+ s.message(status());
1235
+ refresh();
1236
+ });
1237
+ running.delete(k);
1238
+ all.push(result);
1239
+ if (result.error)
1240
+ harnessErrors++;
1241
+ const mark = result.error ? chalk.red("✗") : result.blocked ? chalk.yellow("■") : chalk.green("✓");
1242
+ verdicts.push(`${mark} ${chalk.dim(labels[w.arm.label] ?? w.arm.label)} ${verdictLine(w.tierName, w.inst, result)}`);
1243
+ s.message(status());
1244
+ refresh();
1245
+ });
1246
+ }
1247
+ finally {
1248
+ // Release before the next arm activates, or the guard reads the
1249
+ // switch as a second concurrent overlay and throws.
1250
+ releaseOverlayGuard();
1251
+ }
1252
+ }
1253
+ }
1254
+ finally {
1255
+ restoreConsole();
1256
+ }
1257
+ s.stop(`${chalk.dim(`${completed}/${total}`)} ${chalk.green("done")}`);
1258
+ p.log.message(verdicts.join("\n"));
1259
+ }
1260
+ else {
1261
+ // Serial: one spinner per case (updates per trial) + a verdict line. On
1262
+ // each arm change `arm.activate()` repoints the (process-global) asset root
1263
+ // to that arm's overlay (a no-op for models arms); work is arms-outer, so
1264
+ // it fires at most once per arm. `releaseOverlayGuard()` lets the next arm
1265
+ // switch overlays — without it the guard treats the switch as a concurrent
1266
+ // overlay and throws (ADR 0001).
1267
+ let currentArm;
1268
+ for (let i = 0; i < work.length; i++) {
1269
+ const w = work[i];
1270
+ if (w.arm !== currentArm) {
1271
+ if (currentArm)
1272
+ releaseOverlayGuard();
1273
+ w.arm.activate();
1274
+ currentArm = w.arm;
1275
+ }
1276
+ const s = makeSpinner();
1277
+ const head = `${chalk.dim(`[${i + 1}/${work.length}]`)} ${chalk.cyan(w.tierName)}/${w.inst.instance_id} ${chalk.dim(labels[w.arm.label] ?? w.arm.label)}`;
1278
+ s.start(head);
1279
+ const k = caseKey(w.tierName, w.arm.label, w.inst.instance_id);
1280
+ running.add(k);
1281
+ refresh();
1282
+ let t = 0;
1283
+ const { value: result, logs } = await quiet(() => runItem(w, () => {
1284
+ t++;
1285
+ if (runs > 1)
1286
+ s.message(`${head} ${chalk.dim(`trial ${t}/${runs}`)}`);
1287
+ refresh();
1288
+ }));
1289
+ running.delete(k);
1290
+ all.push(result);
1291
+ if (result.error)
1292
+ harnessErrors++;
1293
+ const mark = result.error ? chalk.red("✗") : result.blocked ? chalk.yellow("■") : chalk.green("✓");
1294
+ s.stop(`${chalk.dim(`[${i + 1}/${work.length}]`)} ${mark} ${verdictLine(w.tierName, w.inst, result)}`);
1295
+ if (result.error) {
1296
+ p.log.error(chalk.dim(result.error));
1297
+ const tail = logs.split("\n").filter(Boolean).slice(-12).join("\n");
1298
+ if (tail)
1299
+ p.log.message(chalk.dim(tail));
1300
+ }
1301
+ refresh();
1302
+ }
1303
+ }
1304
+ }
1305
+ finally {
1306
+ // Release the overlay guard at the END OF EVERY REPEAT, not just on an
1307
+ // arm change. The serial branch above declares `currentArm` inside itself
1308
+ // and only releases when the arm CHANGES (never after the last one), so
1309
+ // repeat 2 would call `w.arm.activate()` while repeat 1's overlay is
1310
+ // still marked active and `activateOverlay` would THROW (arm.ts). The
1311
+ // concurrent branch already releases per arm in its own `finally`, and a
1312
+ // second release is a harmless no-op — the guard is two booleans.
1313
+ releaseOverlayGuard();
828
1314
  }
829
- const s = makeSpinner();
830
- const head = `${chalk.dim(`[${i + 1}/${work.length}]`)} ${chalk.cyan(w.tierName)}/${w.inst.instance_id} ${chalk.dim(labels[w.arm.label] ?? w.arm.label)}`;
831
- s.start(head);
832
- const k = caseKey(w.tierName, w.arm.label, w.inst.instance_id);
833
- running.add(k);
834
- refresh();
835
- let t = 0;
836
- const { value: result, logs } = await quiet(() => runItem(w, () => {
837
- t++;
838
- if (runs > 1)
839
- s.message(`${head} ${chalk.dim(`trial ${t}/${runs}`)}`);
840
- refresh();
841
- }));
842
- running.delete(k);
843
- all.push(result);
844
- if (result.error)
845
- harnessErrors++;
846
- const mark = result.error ? chalk.red("✗") : result.blocked ? chalk.yellow("■") : chalk.green("✓");
847
- s.stop(`${chalk.dim(`[${i + 1}/${work.length}]`)} ${mark} ${verdictLine(w.tierName, w.inst, result)}`);
848
- if (result.error) {
849
- p.log.error(chalk.dim(result.error));
850
- const tail = logs.split("\n").filter(Boolean).slice(-12).join("\n");
851
- if (tail)
852
- p.log.message(chalk.dim(tail));
1315
+ // Final, static scorecard + machine artifacts FOR THIS REPEAT. The
1316
+ // run-level metadata is persisted into scorecard.json so the dashboard can
1317
+ // label, order, and (no longer) live-poll the run without re-deriving from
1318
+ // the current config.
1319
+ const generatedAt = new Date().toISOString();
1320
+ for (const tier of tiers) {
1321
+ const tierResults = all.filter((r) => (r.tier ?? "") === tier);
1322
+ writeArtifacts(resultsDirFor(tier), withMeta(summarize(tierResults), { ...baseMetaFor(tier), generatedAt, live: false, martian: martianFor(tier, tierResults) }));
1323
+ repeatDirs.push(resultsDirFor(tier));
853
1324
  }
854
- refresh();
1325
+ totalHarnessErrors += harnessErrors;
1326
+ totalCompleted += completed;
1327
+ totalCases += all.length;
855
1328
  }
856
- }
857
1329
  }
858
1330
  finally {
859
1331
  clearInterval(heartbeat);
860
1332
  restoreEvalEnv();
861
1333
  }
862
- // Final, static scorecard + machine artifacts. The run-level metadata is
863
- // persisted into scorecard.json so the dashboard can label, order, and (no
864
- // longer) live-poll the run without re-deriving from the current config.
865
- const generatedAt = new Date().toISOString();
866
- for (const tier of tiers) {
867
- const tierResults = all.filter((r) => (r.tier ?? "") === tier);
868
- writeArtifacts(resultsDirFor(tier), withMeta(summarize(tierResults), { ...baseMetaFor(tier), generatedAt, live: false, martian: martianFor(tier, tierResults) }));
1334
+ p.log.success(`Artifacts → ${chalk.cyan(repeatDirs.join("\n "))}\n /{scorecard.json,predictions.jsonl,sessions/}`);
1335
+ // `--keep-workspace` is only useful if you can find what it kept. The paths
1336
+ // are on every result too (`workspaceDir`); this is the line that stops a
1337
+ // kept batch from being N anonymous temp dirs, and the reminder that nothing
1338
+ // else will ever delete them.
1339
+ if (keptWorkspaces.length) {
1340
+ p.log.warn(`Kept ${keptWorkspaces.length} workspace${keptWorkspaces.length === 1 ? "" : "s"} (--keep-workspace) — nothing else will remove them:\n` +
1341
+ keptWorkspaces
1342
+ .map((w) => ` ${w.id}${repeats > 1 ? ` (repeat ${w.repeat})` : ""}${runs > 1 ? ` (trial ${w.trial})` : ""} · ${chalk.cyan(w.path)}`)
1343
+ .join("\n"));
1344
+ }
1345
+ // `--repeats` suppressed the auto-served dashboard (see the flag's note), so
1346
+ // say — once, at the end — how to bring one up over the whole band instead of
1347
+ // leaving N servers running for the length of the run.
1348
+ if (repeats > 1) {
1349
+ p.log.info(`${repeats} repeats written as sibling runs (band ${chalk.cyan(repeatGroup)}). Browse the band:\n` +
1350
+ ` ${chalk.cyan(`cd apps/evals && LASTLIGHT_EVALS_OUT=${resultsRoot()} npx tsx src/run.ts serve --port 4400`)}`);
869
1351
  }
870
- p.log.success(`Artifacts → ${chalk.cyan(tiers.map((t) => resultsDirFor(t)).join("\n "))}\n /{scorecard.json,predictions.jsonl,sessions/}`);
871
- const ran = runs > 1 ? `${completed} runs (${all.length} cases × ${runs})` : `${all.length} runs`;
1352
+ const ran = runs > 1 ? `${totalCompleted} runs (${totalCases} cases × ${runs})` : `${totalCases} runs`;
872
1353
  // Keep the dashboard server alive so the just-finished run stays viewable —
873
1354
  // until the user stops it (Ctrl-C, or `kill`/stop for a detached run). The
874
1355
  // dashboard is opt-in already (`--no-open`, or CI, ⇒ no server was started), so
@@ -879,21 +1360,22 @@ async function runEval() {
879
1360
  if (server) {
880
1361
  const runUrl = `${server.url}/#/${encodeURIComponent(tierKeyFor(tiers[0]))}/${encodeURIComponent(runId)}`;
881
1362
  p.log.success(`Dashboard → ${chalk.cyan(runUrl)} ${chalk.dim("(serving · Ctrl-C or kill to stop)")}`);
882
- const tail = harnessErrors > 0 ? chalk.yellow(`done — ${ran}, ${harnessErrors} harness error${harnessErrors === 1 ? "" : "s"} (see above)`) : chalk.green(`done — ${ran}`);
1363
+ const tail = totalHarnessErrors > 0 ? chalk.yellow(`done — ${ran}, ${totalHarnessErrors} harness error${totalHarnessErrors === 1 ? "" : "s"} (see above)`) : chalk.green(`done — ${ran}`);
883
1364
  p.outro(tail);
884
1365
  await waitForSigint();
885
1366
  await server.close();
886
1367
  }
887
1368
  else {
888
- if (harnessErrors > 0) {
889
- p.outro(chalk.yellow(`done — ${ran}, ${harnessErrors} harness error${harnessErrors === 1 ? "" : "s"} (see above)`));
1369
+ if (totalHarnessErrors > 0) {
1370
+ p.outro(chalk.yellow(`done — ${ran}, ${totalHarnessErrors} harness error${totalHarnessErrors === 1 ? "" : "s"} (see above)`));
890
1371
  }
891
1372
  else {
892
1373
  p.outro(chalk.green(`done — ${ran}`));
893
1374
  }
894
1375
  }
895
- // Non-zero ONLY on harness failure — model quality is the measurement.
896
- return harnessErrors > 0 ? 1 : 0;
1376
+ // Non-zero ONLY on harness failure — model quality is the measurement. Summed
1377
+ // over every repeat: a band whose middle repeat blew up did not run clean.
1378
+ return totalHarnessErrors > 0 ? 1 : 0;
897
1379
  }
898
1380
  /** Resolve on the first SIGINT (Ctrl-C) so `run`/`serve` can keep a server up
899
1381
  * for browsing and shut it down cleanly when the user is done. */
@@ -969,7 +1451,31 @@ Run options:
969
1451
  --compare Cross-vendor set (only models whose provider key is present)
970
1452
  --instance <id[,id]> Only run these instance_id(s) (or set EVAL_INSTANCE). Exact match.
971
1453
  --limit <n> Run only the first n instances per tier (after --instance).
972
- --runs <n> Repeat each case n× (worst-case verdict, mean metrics)
1454
+ --runs <n> Repeat each CASE n× (worst-case verdict, mean metrics).
1455
+ Folds the trials into one result — no per-trial detail.
1456
+ --repeats <n> Repeat the whole ARM n×, sequentially, as n SIBLING runs,
1457
+ so a result reads as a band instead of a point. Three
1458
+ identical arms measured 0.320/0.080/0.200 micro-recall, so
1459
+ one arm is not a measurement of that arm. Each repeat is a
1460
+ normal run (its own runId + scorecard) tagged with
1461
+ meta.repeat={group,index,of}; scripts/band.ts and the
1462
+ dashboard roll them up into a band (mean/min/max + union
1463
+ and intersection recall). Implies --keep-workspace (you will
1464
+ want each repeat's evidence) and --no-open (a run holds a
1465
+ dashboard server open forever; n of them leak). Or
1466
+ EVAL_REPEATS.
1467
+ --repeat-concurrency <n> Run up to n of the --repeats repeats at once
1468
+ (default 1 = sequential, the exact path above). RunIds are
1469
+ pre-assigned so simultaneous launches never collide; each
1470
+ repeat stays a normal sibling run. Repeats share ONE
1471
+ overlay, so multi-overlay --mode config runs clamp to 1
1472
+ (as does --sandbox gondolin). Composes with --concurrency:
1473
+ total in-flight cases = repeat-concurrency × concurrency
1474
+ (no cap — size the product against your rate limit).
1475
+ CAVEAT: concurrent repeats contend for CPU/network, so
1476
+ per-phase durationMs/agentMs are contaminated; each
1477
+ repeat's meta.repeat.concurrency records n so latency
1478
+ reads can be discounted. Verdicts/cost/recall unaffected.
973
1479
  --f-beta <n> pr-review F-beta β (default 1 = F1; 0.5 = precision 2×). Or EVAL_F_BETA.
974
1480
  --judge-with-diff pr-review: feed the PR diff to the judge (higher fidelity,
975
1481
  off by default — Martian's offline judge is diff-blind)
@@ -981,6 +1487,15 @@ Run options:
981
1487
  in-process (fast, CI). gondolin isolates the agent's tools
982
1488
  in a QEMU micro-VM so it can't read host gold data (needs
983
1489
  QEMU natively — brew install qemu). Or EVAL_SANDBOX.
1490
+ --keep-workspace Don't delete each trial's workspace. Keeps the evidence
1491
+ pipeline's artifacts (.lastlight/pr-review/facts.json,
1492
+ obligations/, hypotheses/, probes/) readable after the run;
1493
+ the path lands on each result as workspaceDir. Costs disk.
1494
+ --concurrency <n> Run n cases of the SAME arm at once (default 1 = serial).
1495
+ Arms always stay serial (one overlay at a time, ADR 0001);
1496
+ --runs trials within a case stay serial too. Bounded by the
1497
+ provider's rate limit, not by correctness. Forced to 1 on
1498
+ --sandbox gondolin (a QEMU micro-VM per case).
984
1499
  --serial Force serial execution across provider families
985
1500
  --datasets <dir> Extra datasets root to discover tiers from
986
1501
  --models-file <f> Use an explicit models.json