opencode-harness-kit 0.6.4 → 0.7.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.
package/eval/ab.mjs CHANGED
@@ -23,8 +23,8 @@
23
23
  // spawning a single agent. The grader has been wrong five times in this repo and
24
24
  // every fix so far would otherwise have cost another twenty runs.
25
25
 
26
- import { spawnSync } from "node:child_process"
27
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync, cpSync } from "node:fs"
26
+ import { spawn } from "node:child_process"
27
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, cpSync } from "node:fs"
28
28
  import { createHash } from "node:crypto"
29
29
  import { homedir, tmpdir } from "node:os"
30
30
  import { join } from "node:path"
@@ -34,6 +34,7 @@ import { countFlag, numberFlag } from "./flags.mjs"
34
34
  import { gradeAnswer, gradeMutation, gradeMutationMany } from "./verify.mjs"
35
35
  import { checkPremises } from "./premises.mjs"
36
36
  import { parseJsonc, configDir } from "../src/profile.js"
37
+ import { requestTokens } from "../src/budget.js"
37
38
 
38
39
  /**
39
40
  * The message of a caught value, whatever it turned out to be.
@@ -81,28 +82,112 @@ const suitePath = flag("--suite")
81
82
  // has been wrong five times in this repo and every fix would otherwise have
82
83
  // cost another twenty runs.
83
84
  const rescoreDir = flag("--rescore")
85
+ // Where the streams go, and therefore whether a suite can be continued.
86
+ //
87
+ // Without it every run lands in a fresh temp directory, so an interrupted
88
+ // suite has nothing to continue from: a 36-run suite stopped at run 13 pays
89
+ // for those 13 again. With `--out` the verdict for each run is written beside
90
+ // its stream, and a later invocation with the same `--out` skips what is
91
+ // already there.
92
+ const outFlag = flag("--out")
93
+ // A ceiling on what the whole suite may spend, in context tokens - the same
94
+ // number the guard counts. The free tier is one shared window and the suite is
95
+ // the biggest single consumer of it; a cap is the difference between "the
96
+ // suite stopped" and "the account is out until the window rolls". Counting
97
+ // uncached input here made the cap 5.6 times too generous on the measured
98
+ // streams, and 43 times on a single fully cached step.
99
+ let suiteBudget = 0
100
+ try {
101
+ const n = numberFlag("--budget")
102
+ if (n !== null) {
103
+ if (!(n > 0)) throw new Error("--budget must be more than 0 tokens, or the cap is removed")
104
+ suiteBudget = Math.floor(n)
105
+ }
106
+ } catch (err) {
107
+ console.error(String(errorMessage(err)))
108
+ process.exit(1)
109
+ }
84
110
  // The lane to run, for a suite that measures the kit's PROCESS rather than
85
111
  // its answers: a reviewer that does not edit, a verify that does not fix.
86
112
  const agent = flag("--agent")
87
113
  // One pathological run burned 4.4 minutes once. Without a ceiling a single bad
88
114
  // arm can hold the whole suite open indefinitely.
89
115
  //
90
- // A timeout of zero would REMOVE the ceiling - node only applies a timeout
91
- // when it is above zero - which is the opposite of what the flag says. And a
92
- // non-numeric one was NaN, which node rejects with a RangeError out of
93
- // spawnSync.
116
+ // A timeout of zero would REMOVE the ceiling - a zero-delay timer fires at
117
+ // once - which is the opposite of what the flag says. And a non-numeric one
118
+ // was NaN, which a setTimeout treats as zero and kills every run instantly.
94
119
  let runTimeoutMs
95
120
  try {
96
- const minutes = numberFlag("--timeout") ?? 600
97
- if (minutes <= 0) throw new Error("--timeout must be more than 0, or the ceiling is removed")
98
- runTimeoutMs = minutes * 1000
121
+ const seconds = numberFlag("--timeout") ?? 600
122
+ if (seconds <= 0) throw new Error("--timeout must be more than 0, or the ceiling is removed")
123
+ runTimeoutMs = seconds * 1000
99
124
  } catch (err) {
100
125
  console.error(String(errorMessage(err)))
101
126
  process.exit(1)
102
127
  }
103
128
 
129
+ // A spent free route does not fail: it hangs, then the per-run ceiling kills
130
+ // it, and the run is recorded as a timeout with no tool calls and no text.
131
+ // At 600s a dead route costs ten minutes per attempt, and a 36-run suite
132
+ // spends six hours measuring the provider's quota rather than the kit.
133
+ //
134
+ // Two in a row is not a slow task, it is a route that is not answering. Stop
135
+ // and say so, with the raw streams kept so `--rescore` can run later.
136
+ let failFast
137
+ try {
138
+ failFast = countFlag("--fail-fast") ?? 2
139
+ } catch (err) {
140
+ console.error(String(errorMessage(err)))
141
+ process.exit(1)
142
+ }
143
+
144
+ // The free tier does not always fail the process. `opencode run --format json`
145
+ // prints an error event on stdout, then keeps retrying or exits 0 - and the
146
+ // A/B treated that as an empty answer, kept going, and spent the suite
147
+ // measuring the provider. The same text can appear on stderr. Either one is
148
+ // the provider refusing the run, so the suite stops on the first one.
149
+ const QUOTA_RE = /Free usage limit reached|FreeUsageLimitError|HTTP 429|"statusCode"\s*:\s*429/i
150
+
151
+ /**
152
+ * The provider's own words when a run was refused for quota, or null.
153
+ *
154
+ * @param {string} stdout
155
+ * @param {string} stderr
156
+ * @returns {string | null}
157
+ */
158
+ /**
159
+ * The first error event in a stream, or null.
160
+ *
161
+ * `opencode run --format json` reports a failure as a `{"type":"error"}` line
162
+ * and exits 0 or 1; the message is all the caller gets. The first one is the
163
+ * one that stopped the run.
164
+ *
165
+ * @param {string} stdout
166
+ * @returns {string | null}
167
+ */
168
+ function streamError(stdout) {
169
+ for (const line of stdout.split("\n")) {
170
+ if (!line.includes('"type":"error"')) continue
171
+ try {
172
+ const ev = JSON.parse(line)
173
+ const msg = ev?.error?.data?.message ?? ev?.error?.message ?? ev?.error?.name
174
+ if (msg) return String(msg).slice(0, 240)
175
+ } catch {
176
+ return line.trim().slice(0, 240)
177
+ }
178
+ }
179
+ return null
180
+ }
181
+
182
+ function quotaReason(stdout, stderr) {
183
+ const text = `${stdout}\n${stderr}`
184
+ if (!QUOTA_RE.test(text)) return null
185
+ const line = text.split("\n").find((l) => QUOTA_RE.test(l)) ?? ""
186
+ return line.trim().slice(0, 240)
187
+ }
188
+
104
189
  if (!target || (!task && !suitePath)) {
105
- console.error('usage: node eval/ab.mjs --target <dir> (--task "<prompt>" | --suite <file>) [--reps 3] [--expect token]... [--timeout 600]')
190
+ console.error('usage: node eval/ab.mjs --target <dir> (--task "<prompt>" | --suite <file>) [--reps 3] [--expect token]... [--timeout 600] [--fail-fast 2]')
106
191
  process.exit(1)
107
192
  }
108
193
  if (!existsSync(target)) {
@@ -409,6 +494,13 @@ function liveModel() {
409
494
  // The comparison this file exists to make is "the same model, with the kit
410
495
  // and without it". The model is now pinned to make that literally true, and
411
496
  // the report prints it so a reader can check.
497
+ //
498
+ // The SMALL model is pinned too. `model` only covers the main loop; the title
499
+ // agent runs on `small_model`, and an empty config leaves that at opencode's
500
+ // default - which is a PAID model. A real A/B run made 1,073 title calls to
501
+ // `gpt-5.4-nano` and every one came back "Insufficient account funds". It did
502
+ // not consume the free window, but on a funded account it would have spent
503
+ // money on titles for a benchmark that is supposed to be free.
412
504
  function makeNokitHome(model) {
413
505
  const home = mkdtempSync(join(tmpdir(), "opencode-ab-"))
414
506
  mkdirSync(join(home, ".config/opencode"), { recursive: true })
@@ -418,12 +510,26 @@ function makeNokitHome(model) {
418
510
  // not exist" off for exactly that case.
419
511
  /** @type {Record<string, string>} */
420
512
  const config = { $schema: "https://opencode.ai/config.json" }
421
- if (model) config.model = model
513
+ if (model) {
514
+ config.model = model
515
+ config.small_model = model
516
+ }
422
517
  writeFileSync(join(home, ".config/opencode/opencode.json"), JSON.stringify(config, null, 2))
423
518
  const link = join(home, ".local/share/opencode")
424
519
  try {
425
520
  symlinkSync(join(REAL_HOME, ".local/share/opencode"), link, "dir")
426
521
  } catch {}
522
+ // And the cache, which is where opencode keeps `models.json` - the catalogue
523
+ // it resolves a `provider/model` string against. Without it a bare
524
+ // `model: "opencode/deepseek-v4.1-flash-free"` is a model the off arm has
525
+ // never heard of: every off run failed with "Unexpected server error" and
526
+ // the cause was only visible in `--print-logs` as ProviderModelNotFoundError.
527
+ // The cache is opencode's, not the kit's, so sharing it keeps the arm
528
+ // kit-free without making it a different installation.
529
+ mkdirSync(join(home, ".cache"), { recursive: true })
530
+ try {
531
+ symlinkSync(join(REAL_HOME, ".cache/opencode"), join(home, ".cache/opencode"), "dir")
532
+ } catch {}
427
533
  return home
428
534
  }
429
535
 
@@ -450,7 +556,7 @@ function fingerprint(dir) {
450
556
 
451
557
  function parseStream(file, root = TARGET_REAL) {
452
558
  const reads = [], searches = [], others = [], escaped = new Set(), readOutside = new Set()
453
- let steps = 0, inputTokens = 0, reasoning = 0, finalCtx = 0, text = "", first = null, last = null
559
+ let steps = 0, inputTokens = 0, contextTokens = 0, reasoning = 0, finalCtx = 0, text = "", first = null, last = null
454
560
  for (const line of readFileSync(file, "utf8").split("\n")) {
455
561
  const s = line.trim()
456
562
  if (!s) continue
@@ -485,7 +591,14 @@ function parseStream(file, root = TARGET_REAL) {
485
591
  } else if (d.type === "step_finish") {
486
592
  const t = d.part?.tokens ?? {}
487
593
  steps++
594
+ // `inputTokens` is the uncached part only. It is kept because it is what
595
+ // every table before this one reported, and comparing against those
596
+ // numbers needs the same definition. `contextTokens` is the cost: the
597
+ // same step reports a few hundred uncached against tens of thousands
598
+ // cached, so a suite budget built on `inputTokens` was 5.6 times
599
+ // too generous.
488
600
  inputTokens += t.input ?? 0
601
+ contextTokens += requestTokens(t)
489
602
  reasoning += t.reasoning ?? 0
490
603
  finalCtx = (t.input ?? 0) + (t.cache?.read ?? 0)
491
604
  } else if (d.type === "text") {
@@ -502,6 +615,7 @@ function parseStream(file, root = TARGET_REAL) {
502
615
  searches: searches.length,
503
616
  other: others.reduce((a, t) => (a[t] = (a[t] ?? 0) + 1, a), {}),
504
617
  inputTokens,
618
+ contextTokens,
505
619
  reasoning,
506
620
  finalCtx,
507
621
  seconds: first !== null && last !== null ? elapsedSeconds(first, last) : null,
@@ -511,51 +625,140 @@ function parseStream(file, root = TARGET_REAL) {
511
625
  }
512
626
  }
513
627
 
514
- function runArmCapture(arm, home, id, idx, outDir, prompt, runDir) {
515
- const out = join(outDir, `${id}-${arm}-${idx}.json`)
516
- const env = { ...process.env }
517
- if (arm === "off") {
518
- env.HOME = home
519
- // The kit-free home is only kit-free if the config is not found
520
- // somewhere else. `OPENCODE_CONFIG_DIR` is what opencode looks at
521
- // FIRST, so with it set the "off" arm loaded the kit's own config, used
522
- // its rules, and used its model - and the arm was not an off arm at all.
523
- delete env.OPENCODE_CONFIG_DIR
628
+ // The child that is running right now, if any. Needed because `spawn` is
629
+ // async: the event loop stays free, so a Ctrl-C reaches this file instead of
630
+ // being eaten by a blocked spawnSync. The old code could not react to one -
631
+ // the parent died, the agent was reparented to systemd and kept running
632
+ // against the real auth store. One such run survived 2h54m.
633
+ /** @type {import("node:child_process").ChildProcess | null} */
634
+ let activeChild = null
635
+
636
+ function killActiveChild() {
637
+ const child = activeChild
638
+ if (!child || child.killed) return
639
+ // The child is its own process-group leader (detached), so a negative pid
640
+ // reaches the helpers `opencode` starts. A plain child.kill() left those
641
+ // running after the parent was gone.
642
+ try {
643
+ process.kill(-child.pid, "SIGKILL")
644
+ } catch {
645
+ try {
646
+ child.kill("SIGKILL")
647
+ } catch {}
524
648
  }
525
- const t0 = Date.now()
526
- // --dir is not optional. Nested inside an opencode session, `opencode run`
527
- // otherwise inherits the *parent* session's project directory and reads the
528
- // wrong repository entirely - which it did on the first pilot.
529
- // The LANE, when the suite wants one. The kit-off arm has no kit and
530
- // therefore no lanes, so the flag is only for the arm that has them - which
531
- // is the point: the lane is part of the kit, and measuring it means
532
- // measuring the kit.
533
- const argv = ["run", "--dir", runDir, "--format", "json"]
534
- if (arm === "on" && agent) argv.push("--agent", agent)
535
- argv.push(prompt)
536
- const res = spawnSync("opencode", argv, {
537
- cwd: runDir, env, encoding: "utf8", maxBuffer: 256 * 1024 * 1024,
538
- timeout: runTimeoutMs, killSignal: "SIGKILL",
649
+ }
650
+
651
+ for (const [sig, code] of /** @type {const} */ ([["SIGINT", 130], ["SIGTERM", 143]])) {
652
+ process.on(sig, () => {
653
+ killActiveChild()
654
+ process.exit(code)
655
+ })
656
+ }
657
+
658
+ /**
659
+ * Run one arm and capture its stream.
660
+ *
661
+ * Async on purpose. The timeout is a timer that kills the process GROUP; the
662
+ * signal handlers above kill the same group on Ctrl-C. Neither is possible
663
+ * with spawnSync, which blocks the event loop for the whole run.
664
+ *
665
+ * @param {"on" | "off"} arm
666
+ * @param {string | null} home
667
+ * @param {string} id
668
+ * @param {number} idx
669
+ * @param {string} outDir
670
+ * @param {string} prompt
671
+ * @param {string} runDir
672
+ */
673
+ function runArmCapture(arm, home, id, idx, outDir, prompt, runDir) {
674
+ return new Promise((resolve) => {
675
+ const out = join(outDir, `${id}-${arm}-${idx}.json`)
676
+ const env = { ...process.env }
677
+ // The kit's own guard must not decide the outcome of the suite. 120 runs
678
+ // spend more than the shared window's stop, so from the run that crosses it
679
+ // the guard would refuse tool calls - and every run after that point would
680
+ // be scored as a wrong answer for a reason the suite is not measuring. The
681
+ // suite has its own cap (`--budget`), and it is the one that counts here.
682
+ // The ledger is pointed at the run directory for the same reason: a
683
+ // benchmark should not charge the account's real window, or leave it
684
+ // looking spent to the session that started the benchmark.
685
+ env.OC_HARNESS_BUDGET_WARN = "0"
686
+ env.OC_HARNESS_BUDGET_STOP = "0"
687
+ env.OC_HARNESS_BUDGET_WINDOW_WARN = "0"
688
+ env.OC_HARNESS_BUDGET_WINDOW_STOP = "0"
689
+ env.OC_HARNESS_BUDGET_FILE = join(outDir, "budget.json")
690
+ if (arm === "off" && home) {
691
+ env.HOME = home
692
+ // The kit-free home is only kit-free if the config is not found
693
+ // somewhere else. `OPENCODE_CONFIG_DIR` is what opencode looks at
694
+ // FIRST, so with it set the "off" arm loaded the kit's own config, used
695
+ // its rules, and used its model - and the arm was not an off arm at all.
696
+ delete env.OPENCODE_CONFIG_DIR
697
+ }
698
+ const t0 = Date.now()
699
+ // --dir is not optional. Nested inside an opencode session, `opencode run`
700
+ // otherwise inherits the *parent* session's project directory and reads the
701
+ // wrong repository entirely - which it did on the first pilot.
702
+ // The LANE, when the suite wants one. The kit-off arm has no kit and
703
+ // therefore no lanes, so the flag is only for the arm that has them - which
704
+ // is the point: the lane is part of the kit, and measuring it means
705
+ // measuring the kit.
706
+ const argv = ["run", "--dir", runDir, "--format", "json"]
707
+ if (arm === "on" && agent) argv.push("--agent", agent)
708
+ argv.push(prompt)
709
+ const child = spawn("opencode", argv, {
710
+ cwd: runDir,
711
+ env,
712
+ // A process-group leader, so the timeout and the signal handlers can
713
+ // take the agent's helpers with it.
714
+ detached: true,
715
+ stdio: ["ignore", "pipe", "pipe"],
716
+ })
717
+ activeChild = child
718
+ let stdout = ""
719
+ let stderr = ""
720
+ let timedOut = false
721
+ let settled = false
722
+ /** @type {NodeJS.Timeout | undefined} */
723
+ let timer
724
+
725
+ const finish = (code) => {
726
+ if (settled) return
727
+ settled = true
728
+ clearTimeout(timer)
729
+ if (activeChild === child) activeChild = null
730
+ // The stream is the evidence --rescore needs later, so it is written
731
+ // even when the run was killed or refused.
732
+ writeFileSync(out, stdout)
733
+ resolve({
734
+ out,
735
+ code: code === 0 ? 0 : 1,
736
+ timedOut,
737
+ wall: elapsedSeconds(t0, Date.now()),
738
+ stderr,
739
+ quota: quotaReason(stdout, stderr),
740
+ })
741
+ }
742
+
743
+ child.stdout?.setEncoding("utf8")
744
+ child.stdout?.on("data", (d) => {
745
+ stdout += d
746
+ })
747
+ child.stderr?.setEncoding("utf8")
748
+ child.stderr?.on("data", (d) => {
749
+ stderr += d
750
+ })
751
+ child.on("error", (err) => {
752
+ stderr += `\n${errorMessage(err)}`
753
+ finish(null)
754
+ })
755
+ child.on("close", (code) => finish(code))
756
+
757
+ timer = setTimeout(() => {
758
+ timedOut = true
759
+ killActiveChild()
760
+ }, runTimeoutMs)
539
761
  })
540
- const stdout = res.stdout ?? ""
541
- writeFileSync(out, stdout)
542
- // Whether the run was killed by the ceiling.
543
- //
544
- // The old check was `stdout === null`, and `spawnSync` NEVER returns null
545
- // for stdout - it returns the partial output and reports the kill through
546
- // `error` and `signal`. So the timeout branch could never fire, every run
547
- // scored as a success, and a run that hung until the ceiling was counted in
548
- // the correctness ratio and in every median.
549
- const timedOut =
550
- /** @type {any} */ (res.error)?.code === "ETIMEDOUT" ||
551
- res.signal === "SIGKILL" ||
552
- (res.status === null && Date.now() - t0 >= runTimeoutMs)
553
- return {
554
- out,
555
- code: res.status === 0 ? 0 : 1,
556
- timedOut,
557
- wall: elapsedSeconds(t0, Date.now()),
558
- }
559
762
  }
560
763
 
561
764
  const armModel = liveModel()
@@ -564,19 +767,227 @@ if (!armModel) {
564
767
  console.warn(" which is not necessarily the model the kit-on arm uses.")
565
768
  }
566
769
  const nokit = rescoreDir ? null : makeNokitHome(armModel)
567
- const outDir = rescoreDir ?? mkdtempSync(join(tmpdir(), "opencode-ab-out-"))
770
+ // `--out` is what makes a suite resumable: the streams and their verdicts go
771
+ // to a directory the caller names, so a second invocation can pick up where
772
+ // the first stopped instead of paying for the same runs again.
773
+ const outDir = rescoreDir ?? outFlag ?? mkdtempSync(join(tmpdir(), "opencode-ab-out-"))
774
+ if (outFlag && !rescoreDir) mkdirSync(outFlag, { recursive: true })
568
775
  const before = fingerprint(target)
569
776
 
777
+ // A resume trusts the sidecars in `--out`, and every one of them was measured
778
+ // against a target, a suite and a model. Two of those can change between
779
+ // invocations without any file name changing, and the mixed run reads as one
780
+ // arm's result. The manifest is written once, when the directory is new; a
781
+ // directory from before the manifest existed is adopted with a warning rather
782
+ // than refused, because the runs in it were still measured against something
783
+ // and the warning says which.
784
+ const suiteHash = createHash("sha256")
785
+ .update(suitePath ? readFileSync(suitePath) : (task ?? ""))
786
+ .digest("hex")
787
+ const manifestFile = join(outDir, "run-manifest.json")
788
+ const manifest = { v: 1, suiteHash, task: task ?? null, target: TARGET_REAL, fingerprint: before, model: armModel, reps, timeoutMs: runTimeoutMs }
789
+ if (outFlag && !rescoreDir) {
790
+ if (existsSync(manifestFile)) {
791
+ const old = JSON.parse(readFileSync(manifestFile, "utf8"))
792
+ const changed = ["fingerprint", "suiteHash", "model"].filter((k) => old[k] !== manifest[k])
793
+ if (changed.length) {
794
+ console.error(`FAIL: --out ${outDir} was measured against a different ${changed.join(", ")}, so resuming it would mix two experiments.`)
795
+ console.error(` manifest: ${manifestFile}`)
796
+ console.error(` recorded ${changed.map((k) => `${k}=${JSON.stringify(old[k])}`).join(", ")}`)
797
+ console.error(` now ${changed.map((k) => `${k}=${JSON.stringify(manifest[k])}`).join(", ")}`)
798
+ console.error(` use a new --out, or restore the target/suite/model the manifest names.`)
799
+ process.exit(2)
800
+ }
801
+ } else {
802
+ const sidecars = readdirSync(outDir).filter((f) => f.endsWith(".result.json"))
803
+ if (sidecars.length) {
804
+ console.warn(` warn: ${outDir} has ${sidecars.length} result(s) but no run-manifest.json;`)
805
+ console.warn(` adopting them as measured against this target, suite and model.`)
806
+ manifest.adopted = true
807
+ }
808
+ writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + "\n")
809
+ }
810
+ }
811
+
812
+ /**
813
+ * The recorded verdict for one run, if `--out` was used and it is there.
814
+ *
815
+ * A sidecar rather than the stream alone, because grading a mutation needs the
816
+ * copy of the repository the run produced, and that copy is deleted. The
817
+ * verdict is written when it is known, and read back unchanged on a resume -
818
+ * re-grading a saved answer would be a second, possibly different, opinion.
819
+ *
820
+ * @param {string} id
821
+ * @param {"on" | "off"} arm
822
+ * @param {number} i
823
+ * @returns {any | null}
824
+ */
825
+ function resumeRun(id, arm, i) {
826
+ if (!outFlag || rescoreDir) return null
827
+ const file = join(outDir, `${id}-${arm}-${i}.result.json`)
828
+ if (!existsSync(file)) return null
829
+ try {
830
+ const saved = JSON.parse(readFileSync(file, "utf8"))
831
+ // A sidecar from a different suite or a half-written file is not a
832
+ // result: the id and arm have to match, and there has to be a verdict.
833
+ if (saved?.id !== id || saved?.arm !== arm || saved?.i !== i) return null
834
+ if (!("correct" in saved)) return null
835
+ // A sidecar with no verdict and no step is not a result: the route was
836
+ // already dead when it was written. Skipping it would make the hole
837
+ // permanent on every later resume - a dead window that leaves one empty
838
+ // sidecar per attempt never fills in. A timeout that did work is a
839
+ // censored result, and stays skipped.
840
+ if (saved.correct === null && (saved.steps ?? 0) === 0) return null
841
+ // A sidecar written by an older version is missing fields the report
842
+ // reads - `escaped` crashed the whole report on a resumed run, after every
843
+ // run had already been paid for. The report treats a missing list as an
844
+ // empty one; a resume must not turn a finished suite into a stack trace.
845
+ if (!Array.isArray(saved.escaped)) saved.escaped = []
846
+ if (!Array.isArray(saved.tools)) saved.tools = []
847
+ if (typeof saved.text !== "string") saved.text = ""
848
+ if (typeof saved.steps !== "number") saved.steps = 0
849
+ // The sidecar holds the verdict, the stream holds the cost. A sidecar
850
+ // written before the counter counted cached context would otherwise resume
851
+ // with a number 5.6 times too small, and the suite's own cap would
852
+ // read as unspent. Only the token fields are taken: `escaped` is relative
853
+ // to a run copy that is gone, and re-deriving it here would report every
854
+ // path as an escape.
855
+ const stream = join(outDir, `${id}-${arm}-${i}.json`)
856
+ if (existsSync(stream)) {
857
+ try {
858
+ const m = parseStream(stream, TARGET_REAL)
859
+ if (m.steps > 0) {
860
+ saved.inputTokens = m.inputTokens
861
+ saved.contextTokens = m.contextTokens
862
+ // The report prints medians of these too, and a sidecar from before
863
+ // the counter counted the cached prefix has both at zero. Leaving
864
+ // them stale would make "final ctx" disagree with "context tokens"
865
+ // for the same run.
866
+ saved.finalCtx = m.finalCtx
867
+ saved.reasoning = m.reasoning
868
+ // Write it back: the correction is the whole reason the stream is
869
+ // read, and a second resume should not depend on the stream still
870
+ // being there.
871
+ saveRun(saved)
872
+ }
873
+ } catch {
874
+ // An unreadable stream is not a reason to throw away a scored run.
875
+ }
876
+ }
877
+ return saved
878
+ } catch {
879
+ return null
880
+ }
881
+ }
882
+
883
+ /**
884
+ * Record one run's verdict so a later invocation can reuse it.
885
+ *
886
+ * @param {any} run
887
+ */
888
+ function saveRun(run) {
889
+ if (!outFlag || rescoreDir) return
890
+ const file = join(outDir, `${run.id}-${run.arm}-${run.i}.result.json`)
891
+ const tmp = `${file}.${process.pid}.tmp`
892
+ writeFileSync(tmp, JSON.stringify(run))
893
+ renameSync(tmp, file)
894
+ }
895
+
570
896
  console.log(`A/B: ${jobs.length} task(s) x ${reps} rep(s) per arm${rescoreDir ? ", rescored" : ", alternating"}`)
571
897
  console.log(` target ${target}`)
572
898
  for (const j of jobs) console.log(` task ${j.id}`)
573
- console.log(` timeout ${runTimeoutMs / 1000}s per run`)
899
+ console.log(` timeout ${runTimeoutMs / 1000}s per run, giving up after ${failFast} empty run(s)`)
574
900
  console.log(` model ${armModel ?? "(opencode's default - NOT pinned)"}`)
575
901
  if (nokit) console.log(` kit-off home ${nokit}`)
576
902
  console.log("")
577
903
 
904
+ // One cheap run before the suite, because the expensive failure is not a
905
+ // wrong answer, it is spending the first twenty runs discovering the route is
906
+ // closed. A preflight costs a few hundred tokens and turns that into one.
907
+ //
908
+ // `--no-preflight` skips it: a paid route has no window to spend, and the
909
+ // check is a round trip.
910
+ if (!rescoreDir && !args.includes("--no-preflight")) {
911
+ const probeDir = mkdtempSync(join(tmpdir(), "opencode-ab-preflight-"))
912
+ process.stdout.write("preflight ... ")
913
+ // Into the probe directory, not `--out`: a preflight stream has no task and
914
+ // would only be skipped with a warning by a later --rescore.
915
+ const probe = await runArmCapture("off", nokit, "preflight", 0, probeDir, "Reply with the single word: ok", probeDir)
916
+ if (probe.quota) {
917
+ console.log("")
918
+ console.error("FAIL: the model route is out of quota before the suite started, so nothing was measured.")
919
+ console.error(` ${probe.quota}`)
920
+ console.error(" The free tier is one shared window: this suite and every chat session draw on it.")
921
+ console.error(` preflight stream kept: ${probe.out}`)
922
+ process.exit(2)
923
+ }
924
+ if (probe.timedOut) {
925
+ console.log("")
926
+ console.error(`FAIL: the preflight did not answer within ${runTimeoutMs / 1000}s.`)
927
+ console.error(" A spent or unreachable route looks like this. Nothing was measured.")
928
+ console.error(" Check it by hand: opencode run --print-logs -m <model> hi")
929
+ console.error(` preflight stream kept: ${probe.out}`)
930
+ process.exit(2)
931
+ }
932
+ // Answering is the test, not exiting. A run that reports an error and exits
933
+ // in three seconds used to print "ok" here - the first suite with this
934
+ // preflight passed it and then spent eighteen runs discovering that every
935
+ // off arm had failed.
936
+ const probeErr = streamError(readFileSync(probe.out, "utf8"))
937
+ if (probeErr) {
938
+ console.log("")
939
+ console.error("FAIL: the preflight run failed before the suite started, so nothing was measured.")
940
+ console.error(` ${probeErr}`)
941
+ console.error(" A model the installation cannot resolve, or a provider that refuses the request,")
942
+ console.error(" fails every run the same way. Run one by hand to see the whole log:")
943
+ console.error(" opencode run --print-logs -m <model> hi")
944
+ console.error(` preflight stream kept: ${probe.out}`)
945
+ process.exit(2)
946
+ }
947
+ // Exiting is not answering. A spent route can exit 0 with an empty stream -
948
+ // the shape the fail-fast test uses - and a preflight that reads only the
949
+ // exit code calls that "ok", after which the suite spends one timeout per
950
+ // run learning otherwise. The suite needs a step and a word.
951
+ const probeMetrics = parseStream(probe.out, realpathSync(probeDir))
952
+ if (probe.code !== 0 || probeMetrics.steps === 0 || probeMetrics.text.trim() === "") {
953
+ console.log("")
954
+ console.error("FAIL: the preflight produced no answer, so nothing was measured.")
955
+ console.error(` exit code ${probe.code}, ${probeMetrics.steps} step(s), ${probeMetrics.text.trim().length} character(s) of text.`)
956
+ console.error(" A spent route or a broken install looks like this. Nothing was measured.")
957
+ console.error(" Check it by hand: opencode run --print-logs -m <model> hi")
958
+ console.error(` preflight stream kept: ${probe.out}`)
959
+ process.exit(2)
960
+ }
961
+ console.log(`ok (${probe.wall}s)`)
962
+ }
963
+
578
964
  const runs = []
579
965
  const keptCopies = []
966
+ // Consecutive runs that produced neither a tool call nor a word. See
967
+ // --fail-fast above: this is the shape of a spent route, not of a slow task.
968
+ let deadStreak = 0
969
+
970
+ /**
971
+ * Stop the suite when the route, not the task, is what failed.
972
+ *
973
+ * @param {number} n
974
+ * @param {string} detail
975
+ * @param {{ id: string }} job
976
+ * @param {"on" | "off"} arm
977
+ * @param {number} i
978
+ */
979
+ function stopDeadRoute(n, detail, job, arm, i) {
980
+ console.log("")
981
+ console.error(`FAIL: the model route is not answering.`)
982
+ console.error(` ${n} runs in a row ${detail},`)
983
+ console.error(` most recently ${job.id} ${arm}-${i} on ${armModel ?? "opencode's default model"}.`)
984
+ console.error(` A spent free route looks exactly like this; the provider's reason is only`)
985
+ console.error(` printed by \`opencode run --print-logs\`. Nothing was measured, so this`)
986
+ console.error(` stops instead of timing out one run at a time.`)
987
+ console.error(` raw streams kept: ${outDir}`)
988
+ console.error(` rescore them later: node eval/ab.mjs --rescore ${outDir}, with the same --suite or --task`)
989
+ process.exit(2)
990
+ }
580
991
  if (rescoreDir) {
581
992
  const byId = new Map(jobs.map((j) => [j.id, j]))
582
993
  for (const f of readdirSync(rescoreDir).filter((x) => x.endsWith(".json")).sort()) {
@@ -589,11 +1000,29 @@ if (rescoreDir) {
589
1000
  }
590
1001
  const file = join(rescoreDir, f)
591
1002
  const s = parseStream(file, TARGET_REAL)
592
- const invalid = s.steps === 0
1003
+ // The sidecar is the only place that knows a run hit the ceiling: its
1004
+ // stream stops mid-sentence and looks exactly like a run that answered
1005
+ // nothing. Grading it as wrong is the mistake this file warns about, and
1006
+ // the re-score path made it - four runs that timed out at 12 steps came
1007
+ // back as four wrong answers.
1008
+ let timedOut = false
1009
+ let sideSaved = null
1010
+ const side = join(rescoreDir, f.replace(/\.json$/, ".result.json"))
1011
+ if (existsSync(side)) {
1012
+ try {
1013
+ sideSaved = JSON.parse(readFileSync(side, "utf8"))
1014
+ timedOut = sideSaved.timedOut === true
1015
+ } catch {
1016
+ // A sidecar that will not parse leaves the run scored on its stream.
1017
+ }
1018
+ }
1019
+ const invalid = s.steps === 0 || timedOut
593
1020
  let ok = null
594
1021
  let why
595
1022
  if (job.mutate) {
596
1023
  why = "mutation not rescored"
1024
+ } else if (timedOut) {
1025
+ why = "TIMED OUT, not scored"
597
1026
  } else if (invalid) {
598
1027
  why = "no output, not scored"
599
1028
  } else {
@@ -601,17 +1030,55 @@ if (rescoreDir) {
601
1030
  // the answers differently from the run it is re-scoring would be a
602
1031
  // measurement of the grader, and the number it produced would not be
603
1032
  // comparable with the one it is meant to explain.
604
- const g = gradeAnswer(target, s.text, job.want, { exclude: SUITE_FILES })
1033
+ // `want` is the suite's field and `expect` is the one a bare `--task`
1034
+ // sets; reading only the first graded every single-task re-score against
1035
+ // the string "undefined" and reported "none held" for a right answer.
1036
+ const g = gradeAnswer(target, s.text, job.want ?? job.expect, { exclude: SUITE_FILES })
605
1037
  ok = g.ok
606
1038
  why = g.ok ? `cited ${g.cited}` : g.cited === 0 ? "no citation" : `cited ${g.cited}, none held`
607
1039
  }
608
- runs.push({ id: job.id, arm: m[2], i: Number(m[3]), ...s, wall: s.seconds, code: 0, timedOut: false, invalid: invalid || job.mutate, correct: ok, why })
1040
+ // The wall time is the ceiling the run was measured against, not the
1041
+ // span of its last event. A run killed at 180s has a stream that stops
1042
+ // at 58s, and reading the stream alone reported it as a 58s run.
1043
+ runs.push({ id: job.id, arm: m[2], i: Number(m[3]), ...s, wall: sideSaved?.wall ?? s.seconds, code: 0, timedOut, invalid: invalid || job.mutate, correct: ok, why })
609
1044
  console.log(` ${job.id} ${m[2]}-${m[3]} ... ${why}`)
610
1045
  }
611
1046
  console.log("")
612
1047
  } else for (const job of jobs) {
613
1048
  for (let i = 1; i <= reps; i++) {
614
- for (const arm of ["on", "off"]) {
1049
+ for (const arm of /** @type {const} */ (["on", "off"])) {
1050
+ // A run already in `--out` is not run again. This is the difference
1051
+ // between an interrupted suite costing its remaining runs and costing
1052
+ // all of them: a 36-run suite stopped at 13 paid for 13 twice.
1053
+ const resumed = resumeRun(job.id, arm, i)
1054
+ if (resumed) {
1055
+ runs.push(resumed)
1056
+ console.log(` ${job.id} ${arm}-${i} ... resumed: ${resumed.why ?? (resumed.correct ? "correct" : "wrong")}`)
1057
+ continue
1058
+ }
1059
+ // Say it out loud when a sidecar was there but is not a result. Silence
1060
+ // would make the re-run look like a missing run, and the extra cost
1061
+ // like a bug in the resume.
1062
+ if (outFlag && !rescoreDir && existsSync(join(outDir, `${job.id}-${arm}-${i}.result.json`))) {
1063
+ console.log(` ${job.id} ${arm}-${i} ... retrying (previous run was not scored)`)
1064
+ }
1065
+ // A ceiling on the suite's own spend, checked before each run rather
1066
+ // than after: the point is not to buy the last run, it is to stop with
1067
+ // room left in the window.
1068
+ if (suiteBudget > 0) {
1069
+ // An older sidecar has no `contextTokens`; fall back to the uncached
1070
+ // count rather than to zero, and let resumeRun() fill the real number
1071
+ // in from the stream when the stream is still there.
1072
+ const spent = runs.reduce((n, r) => n + (r.contextTokens ?? r.inputTokens ?? 0), 0)
1073
+ if (spent >= suiteBudget) {
1074
+ console.log("")
1075
+ console.error(`STOP: the suite has spent ${spent.toLocaleString("en-US")} context tokens, its --budget is ${suiteBudget.toLocaleString("en-US")}.`)
1076
+ console.error(` ${runs.length} run(s) measured, the rest were not started. A run's cost is only`)
1077
+ console.error(` known after it finishes, so the cap stops the run after the one that crossed it.`)
1078
+ console.error(` resume later with the same --out: node eval/ab.mjs ... --out ${outDir}`)
1079
+ process.exit(2)
1080
+ }
1081
+ }
615
1082
  // Every run gets its own throwaway copy of the subject. A read-only task
616
1083
  // does not need one, but sharing a tree between an editing task and
617
1084
  // anything else is how a run inherits the previous run's work.
@@ -619,9 +1086,57 @@ if (rescoreDir) {
619
1086
  cpSync(target, runDir, { recursive: true })
620
1087
  if (sanitize && leaks.length) sanitizeCopy(runDir)
621
1088
  process.stdout.write(` ${job.id} ${arm}-${i} ... `)
622
- const r = runArmCapture(arm, nokit, job.id, i, outDir, job.task, runDir)
1089
+ const r = await runArmCapture(arm, nokit, job.id, i, outDir, job.task, runDir)
1090
+ // The provider refusing the run is not a result. It used to look like
1091
+ // one: the error event was on stdout, the A/B read it as an empty
1092
+ // stream, and --fail-fast needed two of them before stopping. One is
1093
+ // enough to know the window is closed.
1094
+ if (r.quota) {
1095
+ console.log("")
1096
+ console.error("FAIL: the model route is out of quota, so this measured the provider, not the kit.")
1097
+ console.error(` ${r.quota}`)
1098
+ console.error(" The free tier is shared: a long chat session and this suite draw on the same window.")
1099
+ console.error(" Nothing after this run was measured. Wait for the window to roll, or use a paid route.")
1100
+ console.error(` raw stream kept: ${r.out}`)
1101
+ process.exit(2)
1102
+ }
623
1103
  const runReal = realpathSync(runDir)
624
1104
  const m = parseStream(r.out, runReal)
1105
+ if (m.steps === 0 && m.text.trim() === "") {
1106
+ // An error the installation can state is not a dead route to wait out:
1107
+ // it will fail every run the same way, and --fail-fast would need two
1108
+ // of them per task. Stop now and show the message.
1109
+ const why = streamError(readFileSync(r.out, "utf8"))
1110
+ if (why) {
1111
+ console.log("")
1112
+ console.error("FAIL: the run failed before it started, so this measured the installation, not the kit.")
1113
+ console.error(` ${why}`)
1114
+ console.error(` ${job.id} ${arm}-${i} produced no tool calls and no text. Nothing after it was measured.`)
1115
+ console.error(` A model the arm cannot resolve is the usual cause: the off arm has its own HOME.`)
1116
+ console.error(` raw stream kept: ${r.out}`)
1117
+ process.exit(2)
1118
+ }
1119
+ deadStreak++
1120
+ if (deadStreak >= failFast) {
1121
+ stopDeadRoute(deadStreak, "produced no tool calls and no text", job, arm, i)
1122
+ }
1123
+ } else {
1124
+ // A timeout that kept emitting events is a slow task. A timeout that
1125
+ // went quiet well before the ceiling is a route that stopped
1126
+ // answering after one step - the 10-rep run had four of those at
1127
+ // 58-164s against a 180s ceiling, and resetting the streak on each
1128
+ // one measured the rest of the suite at 180s per run.
1129
+ const quietS = m.seconds === null ? r.wall : r.wall - m.seconds
1130
+ const stalled = r.timedOut && quietS >= Math.min(60, runTimeoutMs / 2000)
1131
+ if (stalled) {
1132
+ deadStreak++
1133
+ if (deadStreak >= failFast) {
1134
+ stopDeadRoute(deadStreak, "stalled before the ceiling", job, arm, i)
1135
+ }
1136
+ } else if (!r.timedOut) {
1137
+ deadStreak = 0
1138
+ }
1139
+ }
625
1140
  // Grade against the repository, not against a string I typed. The
626
1141
  // substring grader produced three wrong numbers in RESULTS.md; this
627
1142
  // resolves every citation the agent made and reads the real line.
@@ -682,7 +1197,11 @@ if (rescoreDir) {
682
1197
  ok = g.ok
683
1198
  why = g.ok ? `cited ${g.cited}` : g.cited === 0 ? "no citation" : `cited ${g.cited}, none held`
684
1199
  }
685
- runs.push({ id: job.id, arm, i, ...m, wall: r.wall, code: r.code, timedOut: r.timedOut, invalid, correct: ok, why })
1200
+ const run = { id: job.id, arm, i, ...m, wall: r.wall, code: r.code, timedOut: r.timedOut, invalid, correct: ok, why }
1201
+ runs.push(run)
1202
+ // Written before the escape check, because a run that escaped is still
1203
+ // a result and re-running it would repeat the escape.
1204
+ saveRun(run)
686
1205
  // Stop the moment an arm reaches outside the run directory. The first
687
1206
  // pilot wrote to the real repository while the task said not to modify
688
1207
  // anything, so a second arm is not worth the risk once this has happened.
@@ -712,11 +1231,14 @@ if (rescoreDir) {
712
1231
 
713
1232
  const after = fingerprint(target)
714
1233
  // Median, never mean. See median() in ./metrics.js for the run that proves why.
715
- // Invalid runs are excluded everywhere: a process that emitted nothing has no
716
- // tools, no tokens and no answer, and including its zeros would drag every
717
- // median toward looking as though the agent did nothing at all.
1234
+ // Invalid runs are excluded from the work metrics: a process that emitted
1235
+ // nothing has no tools, no tokens and no answer, and including its zeros would
1236
+ // drag every median toward looking as though the agent did nothing at all.
1237
+ // Wall time is the exception. A run that timed out still spent its ceiling,
1238
+ // and hiding it would report a suite that cost less time than it did.
718
1239
  const scored = runs.filter((r) => !r.invalid)
719
1240
  const stat = (arm, key) => median(scored.filter((r) => r.arm === arm).map((r) => r[key]))
1241
+ const wallStat = (arm) => median(runs.filter((r) => r.arm === arm).map((r) => r.wall))
720
1242
  const scoreOf = (arm) => {
721
1243
  const rs = scored.filter((r) => r.arm === arm)
722
1244
  return `${rs.filter((r) => r.correct).length}/${rs.length}`
@@ -734,9 +1256,10 @@ const rows = [
734
1256
  ["narrow %", stat("on", "narrow_pct"), stat("off", "narrow_pct")],
735
1257
  ["grep/glob", stat("on", "searches"), stat("off", "searches")],
736
1258
  ["steps", stat("on", "steps"), stat("off", "steps")],
737
- ["input tokens", stat("on", "inputTokens"), stat("off", "inputTokens")],
1259
+ ["context tokens", stat("on", "contextTokens"), stat("off", "contextTokens")],
1260
+ ["uncached input", stat("on", "inputTokens"), stat("off", "inputTokens")],
738
1261
  ["final ctx", stat("on", "finalCtx"), stat("off", "finalCtx")],
739
- ["wall seconds", stat("on", "wall"), stat("off", "wall")],
1262
+ ["wall seconds", wallStat("on"), wallStat("off")],
740
1263
  ["not scored", runs.filter((r) => r.arm === "on" && r.invalid).length, runs.filter((r) => r.arm === "off" && r.invalid).length],
741
1264
  // The escape check compares against a per-run copy that no longer exists when
742
1265
  // rescoring, so it would report every path as an escape. Report nothing rather