@tangle-network/agent-bench 0.3.5 → 0.3.7

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 (262) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +13 -1
  3. package/dist/adapters.d.ts +15 -0
  4. package/dist/adapters.js +43 -0
  5. package/dist/adapters.js.map +1 -0
  6. package/dist/benchmarks/_harness.d.ts +125 -0
  7. package/dist/benchmarks/_harness.js +33 -0
  8. package/dist/benchmarks/_harness.js.map +1 -0
  9. package/dist/benchmarks/aec-bench.d.ts +27 -0
  10. package/dist/benchmarks/aec-bench.js +8 -0
  11. package/dist/benchmarks/aec-bench.js.map +1 -0
  12. package/dist/benchmarks/agentbench.d.ts +16 -0
  13. package/dist/benchmarks/agentbench.js +10 -0
  14. package/dist/benchmarks/agentbench.js.map +1 -0
  15. package/dist/benchmarks/appworld.d.ts +37 -0
  16. package/dist/benchmarks/appworld.js +14 -0
  17. package/dist/benchmarks/appworld.js.map +1 -0
  18. package/dist/benchmarks/bfcl.d.ts +18 -0
  19. package/dist/benchmarks/bfcl.js +10 -0
  20. package/dist/benchmarks/bfcl.js.map +1 -0
  21. package/dist/benchmarks/cad-design.d.ts +45 -0
  22. package/dist/benchmarks/cad-design.js +7 -0
  23. package/dist/benchmarks/cad-design.js.map +1 -0
  24. package/dist/benchmarks/cadbench.d.ts +19 -0
  25. package/dist/benchmarks/cadbench.js +8 -0
  26. package/dist/benchmarks/cadbench.js.map +1 -0
  27. package/dist/benchmarks/cadgenbench.d.ts +22 -0
  28. package/dist/benchmarks/cadgenbench.js +8 -0
  29. package/dist/benchmarks/cadgenbench.js.map +1 -0
  30. package/dist/benchmarks/commit0.d.ts +31 -0
  31. package/dist/benchmarks/commit0.js +10 -0
  32. package/dist/benchmarks/commit0.js.map +1 -0
  33. package/dist/benchmarks/crag.d.ts +14 -0
  34. package/dist/benchmarks/crag.js +9 -0
  35. package/dist/benchmarks/crag.js.map +1 -0
  36. package/dist/benchmarks/dabstep.d.ts +18 -0
  37. package/dist/benchmarks/dabstep.js +10 -0
  38. package/dist/benchmarks/dabstep.js.map +1 -0
  39. package/dist/benchmarks/enterpriseops-gym.d.ts +38 -0
  40. package/dist/benchmarks/enterpriseops-gym.js +10 -0
  41. package/dist/benchmarks/enterpriseops-gym.js.map +1 -0
  42. package/dist/benchmarks/finresearchbench.d.ts +15 -0
  43. package/dist/benchmarks/finresearchbench.js +8 -0
  44. package/dist/benchmarks/finresearchbench.js.map +1 -0
  45. package/dist/benchmarks/finsearchcomp.d.ts +49 -0
  46. package/dist/benchmarks/finsearchcomp.js +7 -0
  47. package/dist/benchmarks/finsearchcomp.js.map +1 -0
  48. package/dist/benchmarks/frames.d.ts +59 -0
  49. package/dist/benchmarks/frames.js +13 -0
  50. package/dist/benchmarks/frames.js.map +1 -0
  51. package/dist/benchmarks/hotpotqa.d.ts +48 -0
  52. package/dist/benchmarks/hotpotqa.js +15 -0
  53. package/dist/benchmarks/hotpotqa.js.map +1 -0
  54. package/dist/benchmarks/humaneval.d.ts +62 -0
  55. package/dist/benchmarks/humaneval.js +17 -0
  56. package/dist/benchmarks/humaneval.js.map +1 -0
  57. package/dist/benchmarks/mind2web.d.ts +41 -0
  58. package/dist/benchmarks/mind2web.js +9 -0
  59. package/dist/benchmarks/mind2web.js.map +1 -0
  60. package/dist/benchmarks/nomiracl.d.ts +15 -0
  61. package/dist/benchmarks/nomiracl.js +9 -0
  62. package/dist/benchmarks/nomiracl.js.map +1 -0
  63. package/dist/benchmarks/open-rag-bench.d.ts +14 -0
  64. package/dist/benchmarks/open-rag-bench.js +9 -0
  65. package/dist/benchmarks/open-rag-bench.js.map +1 -0
  66. package/dist/benchmarks/programbench.d.ts +38 -0
  67. package/dist/benchmarks/programbench.js +10 -0
  68. package/dist/benchmarks/programbench.js.map +1 -0
  69. package/dist/benchmarks/rag-shared.d.ts +42 -0
  70. package/dist/benchmarks/rag-shared.js +39 -0
  71. package/dist/benchmarks/rag-shared.js.map +1 -0
  72. package/dist/benchmarks/ragbench.d.ts +16 -0
  73. package/dist/benchmarks/ragbench.js +9 -0
  74. package/dist/benchmarks/ragbench.js.map +1 -0
  75. package/dist/benchmarks/simpleqa.d.ts +64 -0
  76. package/dist/benchmarks/simpleqa.js +11 -0
  77. package/dist/benchmarks/simpleqa.js.map +1 -0
  78. package/dist/benchmarks/swe-bench.d.ts +56 -0
  79. package/dist/benchmarks/swe-bench.js +14 -0
  80. package/dist/benchmarks/swe-bench.js.map +1 -0
  81. package/dist/benchmarks/t2-ragbench.d.ts +14 -0
  82. package/dist/benchmarks/t2-ragbench.js +9 -0
  83. package/dist/benchmarks/t2-ragbench.js.map +1 -0
  84. package/dist/benchmarks/tau-bench-shared.d.ts +26 -0
  85. package/dist/benchmarks/tau-bench-shared.js +10 -0
  86. package/dist/benchmarks/tau-bench-shared.js.map +1 -0
  87. package/dist/benchmarks/tau2-bench.d.ts +7 -0
  88. package/dist/benchmarks/tau2-bench.js +11 -0
  89. package/dist/benchmarks/tau2-bench.js.map +1 -0
  90. package/dist/benchmarks/tau3-banking.d.ts +15 -0
  91. package/dist/benchmarks/tau3-banking.js +9 -0
  92. package/dist/benchmarks/tau3-banking.js.map +1 -0
  93. package/dist/benchmarks/terminal-bench.d.ts +24 -0
  94. package/dist/benchmarks/terminal-bench.js +8 -0
  95. package/dist/benchmarks/terminal-bench.js.map +1 -0
  96. package/dist/benchmarks/toollm.d.ts +16 -0
  97. package/dist/benchmarks/toollm.js +10 -0
  98. package/dist/benchmarks/toollm.js.map +1 -0
  99. package/dist/benchmarks/trata-hedge.d.ts +32 -0
  100. package/dist/benchmarks/trata-hedge.js +7 -0
  101. package/dist/benchmarks/trata-hedge.js.map +1 -0
  102. package/dist/benchmarks/types.d.ts +107 -0
  103. package/dist/benchmarks/types.js +1 -0
  104. package/dist/benchmarks/types.js.map +1 -0
  105. package/dist/benchmarks/webarena-verified.d.ts +16 -0
  106. package/dist/benchmarks/webarena-verified.js +10 -0
  107. package/dist/benchmarks/webarena-verified.js.map +1 -0
  108. package/dist/chunk-2PVVP7GN.js +197 -0
  109. package/dist/chunk-2PVVP7GN.js.map +1 -0
  110. package/dist/chunk-2XU6OGEN.js +170 -0
  111. package/dist/chunk-2XU6OGEN.js.map +1 -0
  112. package/dist/chunk-53UPUNBZ.js +325 -0
  113. package/dist/chunk-53UPUNBZ.js.map +1 -0
  114. package/dist/chunk-5H5XV76F.js +240 -0
  115. package/dist/chunk-5H5XV76F.js.map +1 -0
  116. package/dist/chunk-7WSD27QQ.js +118 -0
  117. package/dist/chunk-7WSD27QQ.js.map +1 -0
  118. package/dist/chunk-C7T7WEK2.js +103 -0
  119. package/dist/chunk-C7T7WEK2.js.map +1 -0
  120. package/dist/chunk-CKUVRZ2T.js +251 -0
  121. package/dist/chunk-CKUVRZ2T.js.map +1 -0
  122. package/dist/chunk-HBSWHQNJ.js +30 -0
  123. package/dist/chunk-HBSWHQNJ.js.map +1 -0
  124. package/dist/chunk-HHXFIHXC.js +116 -0
  125. package/dist/chunk-HHXFIHXC.js.map +1 -0
  126. package/dist/chunk-IFAV6KEM.js +276 -0
  127. package/dist/chunk-IFAV6KEM.js.map +1 -0
  128. package/dist/chunk-INNOYXCP.js +387 -0
  129. package/dist/chunk-INNOYXCP.js.map +1 -0
  130. package/dist/chunk-J3KDJNX2.js +182 -0
  131. package/dist/chunk-J3KDJNX2.js.map +1 -0
  132. package/dist/chunk-JRWWGMK7.js +148 -0
  133. package/dist/chunk-JRWWGMK7.js.map +1 -0
  134. package/dist/chunk-JTHWEDEW.js +32 -0
  135. package/dist/chunk-JTHWEDEW.js.map +1 -0
  136. package/dist/chunk-KDIKRJGB.js +120 -0
  137. package/dist/chunk-KDIKRJGB.js.map +1 -0
  138. package/dist/chunk-LRRD7NAG.js +301 -0
  139. package/dist/chunk-LRRD7NAG.js.map +1 -0
  140. package/dist/chunk-ODT47UAY.js +221 -0
  141. package/dist/chunk-ODT47UAY.js.map +1 -0
  142. package/dist/chunk-PA2ZKHJC.js +230 -0
  143. package/dist/chunk-PA2ZKHJC.js.map +1 -0
  144. package/dist/chunk-PUIRNYI7.js +189 -0
  145. package/dist/chunk-PUIRNYI7.js.map +1 -0
  146. package/dist/chunk-PWQVGAJB.js +144 -0
  147. package/dist/chunk-PWQVGAJB.js.map +1 -0
  148. package/dist/chunk-R36V2VP7.js +169 -0
  149. package/dist/chunk-R36V2VP7.js.map +1 -0
  150. package/dist/chunk-R67DFVLO.js +142 -0
  151. package/dist/chunk-R67DFVLO.js.map +1 -0
  152. package/dist/chunk-SEVJPLZC.js +260 -0
  153. package/dist/chunk-SEVJPLZC.js.map +1 -0
  154. package/dist/chunk-SYDW647C.js +318 -0
  155. package/dist/chunk-SYDW647C.js.map +1 -0
  156. package/dist/chunk-TBKU5XQI.js +228 -0
  157. package/dist/chunk-TBKU5XQI.js.map +1 -0
  158. package/dist/chunk-TSWPNOYM.js +147 -0
  159. package/dist/chunk-TSWPNOYM.js.map +1 -0
  160. package/dist/chunk-UAIOHCUK.js +27 -0
  161. package/dist/chunk-UAIOHCUK.js.map +1 -0
  162. package/dist/chunk-UPAMRDX4.js +233 -0
  163. package/dist/chunk-UPAMRDX4.js.map +1 -0
  164. package/dist/chunk-VQRS7VUC.js +342 -0
  165. package/dist/chunk-VQRS7VUC.js.map +1 -0
  166. package/dist/chunk-X3BTXCJ4.js +262 -0
  167. package/dist/chunk-X3BTXCJ4.js.map +1 -0
  168. package/dist/chunk-X5YKXC6V.js +211 -0
  169. package/dist/chunk-X5YKXC6V.js.map +1 -0
  170. package/dist/chunk-Y6O2OCUO.js +130 -0
  171. package/dist/chunk-Y6O2OCUO.js.map +1 -0
  172. package/dist/chunk-YCGY7UIZ.js +208 -0
  173. package/dist/chunk-YCGY7UIZ.js.map +1 -0
  174. package/dist/chunk-Z7ML6L77.js +162 -0
  175. package/dist/chunk-Z7ML6L77.js.map +1 -0
  176. package/dist/chunk-ZEWMTR5M.js +136 -0
  177. package/dist/chunk-ZEWMTR5M.js.map +1 -0
  178. package/dist/index.d.ts +355 -0
  179. package/dist/index.js +1908 -0
  180. package/dist/index.js.map +1 -0
  181. package/package.json +26 -9
  182. package/scripts/run-package-tests.mjs +30 -8
  183. package/scripts/verify-packed-consumer.mjs +12 -1
  184. package/scripts/verify-pier-agent.mts +1 -0
  185. package/src/benchmarks/humaneval.test.mts +122 -0
  186. package/src/benchmarks/humaneval.ts +100 -27
  187. package/src/david-attribution.mts +78 -0
  188. package/src/david-goliath.mts +149 -0
  189. package/src/hev-improve.mts +25 -6
  190. package/src/humaneval-object-ablation.mts +201 -0
  191. package/src/live-improve-campaign-mbpp.mts +641 -0
  192. package/src/live-improve-campaign.mts +500 -0
  193. package/src/mbpp-structural.mts +12 -7
  194. package/src/stream-observe.py +45 -0
  195. package/src/stream-observe.tpl.html +247 -0
  196. package/src/supervisor-arena.mts +816 -0
  197. package/src/swe-arena/analyze.ts +211 -0
  198. package/src/swe-arena/arms.ts +788 -0
  199. package/src/swe-arena/bootstrap-meta.mts +188 -0
  200. package/src/swe-arena/bootstrap-meta.test.mts +51 -0
  201. package/src/swe-arena/calibrate.ts +116 -0
  202. package/src/swe-arena/capabilities.mts +76 -0
  203. package/src/swe-arena/capabilities.test.mts +57 -0
  204. package/src/swe-arena/capacity.ts +194 -0
  205. package/src/swe-arena/cell-evidence.mts +405 -0
  206. package/src/swe-arena/cell-evidence.test.mts +248 -0
  207. package/src/swe-arena/diagnosis-ensemble.test.mts +210 -0
  208. package/src/swe-arena/diagnosis-ensemble.ts +520 -0
  209. package/src/swe-arena/execution.test.mts +1170 -0
  210. package/src/swe-arena/fixtures/analyze.py +80 -0
  211. package/src/swe-arena/fixtures/excludes.txt +8 -0
  212. package/src/swe-arena/fixtures/gen1-salvage/README.md +45 -0
  213. package/src/swe-arena/fixtures/gen1-salvage/cand0-e6d7361.diff +116 -0
  214. package/src/swe-arena/fixtures/gen1-salvage/cand1-76a8590.diff +293 -0
  215. package/src/swe-arena/fixtures/holdout-preregister.log +12 -0
  216. package/src/swe-arena/fixtures/holdout.json +44 -0
  217. package/src/swe-arena/fixtures/instances.json +146 -0
  218. package/src/swe-arena/fixtures/ledger.jsonl +12 -0
  219. package/src/swe-arena/fixtures/patches/pallets__flask-5014.solo.patch +36 -0
  220. package/src/swe-arena/fixtures/patches/pydata__xarray-4687.sup.patch +33 -0
  221. package/src/swe-arena/fixtures/rejudge.jsonl +15 -0
  222. package/src/swe-arena/fixtures/rematch.jsonl +3 -0
  223. package/src/swe-arena/fixtures/rematch2.jsonl +3 -0
  224. package/src/swe-arena/fixtures/rematch3.jsonl +3 -0
  225. package/src/swe-arena/fixtures/sup-journal-true.json +19 -0
  226. package/src/swe-arena/fixtures/verify/astropy__astropy-13033.sh +48 -0
  227. package/src/swe-arena/fixtures/verify/django__django-11532.sh +50 -0
  228. package/src/swe-arena/fixtures/verify/matplotlib__matplotlib-20826.sh +76 -0
  229. package/src/swe-arena/fixtures/verify/pydata__xarray-4687.sh +44 -0
  230. package/src/swe-arena/fixtures/verify/pytest-dev__pytest-6197.sh +32 -0
  231. package/src/swe-arena/fixtures/verify/sphinx-doc__sphinx-9658.sh +51 -0
  232. package/src/swe-arena/fixtures/worker-tokens.json +42 -0
  233. package/src/swe-arena/fixtures.ts +104 -0
  234. package/src/swe-arena/holdout-certify.mts +408 -0
  235. package/src/swe-arena/holdout-certify.test.mts +160 -0
  236. package/src/swe-arena/judge-child.mts +37 -0
  237. package/src/swe-arena/manifest.mts +293 -0
  238. package/src/swe-arena/manifest.test.mts +169 -0
  239. package/src/swe-arena/materialize.ts +142 -0
  240. package/src/swe-arena/outer-loop.mts +2145 -0
  241. package/src/swe-arena/outer-loop.test.mts +696 -0
  242. package/src/swe-arena/parity.test.mts +87 -0
  243. package/src/swe-arena/proc.test.mts +174 -0
  244. package/src/swe-arena/proc.ts +260 -0
  245. package/src/swe-arena/profiles/default-author.profile.json +4 -0
  246. package/src/swe-arena/proposer-fanout.mts +489 -0
  247. package/src/swe-arena/proposer-fanout.test.mts +372 -0
  248. package/src/swe-arena/reconcile.ts +0 -0
  249. package/src/swe-arena/replay.mts +183 -0
  250. package/src/swe-arena/replay.test.mts +300 -0
  251. package/src/swe-arena/run-experiment.mts +361 -0
  252. package/src/swe-arena/run-supervisor.mjs +297 -0
  253. package/src/swe-arena/run-supervisor.test.mts +498 -0
  254. package/src/swe-arena/serialized-judge.ts +414 -0
  255. package/src/swe-arena/types.ts +166 -0
  256. package/src/swe-code-improve.mts +328 -0
  257. package/src/swe-emit-patch.mts +104 -0
  258. package/src/swe-improve.mts +232 -0
  259. package/src/swe-jail.ts +2 -2
  260. package/src/swe-local-proof.mts +169 -0
  261. package/src/swe-repro-calibrate.mts +446 -0
  262. package/src/swe-stream.mts +1497 -0
@@ -0,0 +1,2145 @@
1
+ /**
2
+ * Round-4 outer loop — agent-runtime's `improve()` in the OPTIMIZER SEAT,
3
+ * proposing code changes to the loops pi supervisor, evaluated by this typed
4
+ * swe-arena harness. Replaces the human/Claude-driven rounds 1-3 recorded in
5
+ * supervisor-lab `.evolve/state.json`.
6
+ *
7
+ * tsx src/swe-arena/outer-loop.mts <config.json> # SPENDS: fires arms + judges
8
+ * tsx src/swe-arena/outer-loop.mts --write-config <path> # emit the default round-4 config
9
+ * tsx src/swe-arena/outer-loop.mts --calibration-smoke [supRunDir] [--analysts N] [--model M]
10
+ *
11
+ * One `runRound()` = one `improve()` call with `surface: 'code'`:
12
+ *
13
+ * (a) DIAGNOSE — the `analyzeGeneration` seam runs the blind diagnosis
14
+ * ensemble (diagnosis-ensemble.ts) over the PREVIOUS round's failure
15
+ * artifacts (round-3 SUP4 run dirs seeded via config) plus every fresh
16
+ * arm run this round produced, and UNIONS the fused findings with
17
+ * `rawTraceDistiller` path-context so the coding agent also greps the raw
18
+ * traces itself (`rawTraceContext: true` names the mechanism; an explicit
19
+ * `analyzeGeneration` wins, so the distiller is composed in directly).
20
+ * (b) PROPOSE — `improvementDriver` + a change-space-constrained
21
+ * `agenticGenerator` edit an isolated git worktree of loops. The DECLARED
22
+ * CHANGE-SPACE is enforced twice: in the generator's verifier (feedback →
23
+ * next shot) and fail-closed in the dispatch below (an out-of-space
24
+ * candidate never reaches a model token).
25
+ * (c) EVALUATE — each candidate surface is a loops commit; the dispatch adds
26
+ * a detached eval worktree at that commit, points the supervisor arm's
27
+ * extension path at it (armProvenance records the commit), runs the
28
+ * 3-instance improvement set through arms.ts + the serialized official
29
+ * judge. Score = resolved count; cost guard = wall ratio vs baseline.
30
+ * (d) ACCEPT/REJECT — keep-if-better per protocol_v2. The loop NEVER ships:
31
+ * `budget.holdout: 'deferred'` makes the lib dispatch zero holdout
32
+ * cells, force `hold`, and omit `lift` — the pre-registered 6-instance
33
+ * holdout costs real money and runs only in a separate, operator-
34
+ * approved run. The would-be-KEEP operator brief is computed post-run
35
+ * from campaign cells; every candidate + verdict persists as staircase
36
+ * rows in `<roundsDir>/gen-<N>.jsonl`.
37
+ *
38
+ * BASELINE: the gate's only denominator is the stored premeasured baseline
39
+ * artifact ({surfaceHash, campaign}) that the lib validates (surface hash,
40
+ * seed, reps, split digest, coverage) before skipping the baseline campaign.
41
+ * A missing artifact = the bootstrap run: the baseline is measured
42
+ * (cache-resumable) and the artifact written for every later run.
43
+ * capabilities.mts fails loud on a stale substrate install that would
44
+ * silently drop the passthrough.
45
+ *
46
+ * SCORING SOURCE: operator-brief evidence + staircase rows derive from the
47
+ * LIB's campaign cells (`improve()` result campaigns in memory; the per-cell
48
+ * `cached-result.json` caches on disk survive resume) — see cell-evidence.mts.
49
+ * The in-process RoundRecorder is dispatch-time only: fail-closed
50
+ * change-space enforcement + candidate diff writing. It is NOT a scoring
51
+ * source — that recorder role mislabeled a resumed run's baseline
52
+ * (r4-mroh3rkt) because cached cells replay without dispatching.
53
+ *
54
+ * Immutable per protocol_v2 (enforced, not advisory): judge + verify scripts,
55
+ * task prompts, model ids, budgets. `assertFrozenArm` pins the arm to the
56
+ * round-3 values; the serialized judge enforces its own 1800s floor; the
57
+ * change space keeps candidates inside extensions/pi/** and the three named
58
+ * src files (plus the `.improve/` raw-trace diagnosis artifact the agentic
59
+ * generator's evidence gate requires).
60
+ */
61
+
62
+ import { appendFile, mkdir, readFile, rm, symlink, unlink, writeFile } from 'node:fs/promises'
63
+ import { existsSync } from 'node:fs'
64
+ import process from 'node:process'
65
+ import { join } from 'node:path'
66
+ import { fileURLToPath, pathToFileURL } from 'node:url'
67
+ import {
68
+ agenticGenerator,
69
+ improve,
70
+ rawTraceDistiller,
71
+ type CandidateGenerator,
72
+ type Verifier,
73
+ } from '@tangle-network/agent-runtime'
74
+ import { runLocalHarness } from '@tangle-network/agent-runtime/mcp'
75
+ import { makeFinding } from '@tangle-network/agent-eval'
76
+ import {
77
+ FsLabeledScenarioStore,
78
+ surfaceHash,
79
+ type CampaignResult,
80
+ type CodeSurface,
81
+ type DispatchContext,
82
+ type JudgeConfig,
83
+ type MutableSurface,
84
+ type PremeasuredOptimizationBaseline,
85
+ type Scenario,
86
+ } from '@tangle-network/agent-eval/campaign'
87
+ import type { CostLedgerHandle } from '@tangle-network/agent-eval'
88
+ import { createSweBenchAdapter } from '../benchmarks/swe-bench.ts'
89
+ import {
90
+ baselineDriftWarnings,
91
+ cellsFromCampaign,
92
+ gateEvidenceFromCells,
93
+ instanceVerdictsFromCells,
94
+ loadCampaignCells,
95
+ perInstanceFromCells,
96
+ replicateCoverageComplete,
97
+ replicateRunsFromCells,
98
+ resolvedInstanceCount,
99
+ sumWallSFromCells,
100
+ decideVerdict,
101
+ type R4Artifact,
102
+ type StaircasePerInstance,
103
+ type StaircaseVerdict,
104
+ } from './cell-evidence.mts'
105
+ import { assertSubstratePassthroughs } from './capabilities.mts'
106
+ import {
107
+ loadExcludes,
108
+ runSupervisorArm,
109
+ type SecretsEnv,
110
+ type SupervisorArmSpec,
111
+ type SupervisorArmResult,
112
+ } from './arms.ts'
113
+ import { gatesForArmKind, waitForCapacity, ZAI_CODING_ENDPOINT } from './capacity.ts'
114
+ import {
115
+ defaultAnalysts,
116
+ fusedToAnalystFindings,
117
+ runDiagnosisEnsemble,
118
+ surfacesPlacementRegex,
119
+ type AnalystSpec,
120
+ type SupRunArtifacts,
121
+ } from './diagnosis-ensemble.ts'
122
+ import {
123
+ defaultProposers,
124
+ fanOutLoopsGenerator,
125
+ proposerShotHooks,
126
+ type PrefilterConfig,
127
+ type PrefilterKill,
128
+ type ProposerSpec,
129
+ type SmokeRunner,
130
+ type SmokeVerdict,
131
+ } from './proposer-fanout.mts'
132
+ import { installProcessSignalAbort, run, runOk } from './proc.ts'
133
+ import { loadInstanceImages } from './run-experiment.mts'
134
+ import {
135
+ createSerializedJudge,
136
+ JUDGE_TIMEOUT_FLOOR_MS,
137
+ type SerializedJudge,
138
+ } from './serialized-judge.ts'
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // The DECLARED CHANGE-SPACE (protocol_v2). Pure + unit-tested.
142
+ // ---------------------------------------------------------------------------
143
+
144
+ export interface ChangeSpace {
145
+ /** Directory prefixes (repo-relative, trailing '/') where edits are allowed. */
146
+ prefixes: string[]
147
+ /** Exact repo-relative files where edits are allowed. */
148
+ files: string[]
149
+ /** Non-code artifact prefixes allowed to change (the agentic generator's
150
+ * raw-trace evidence gate REQUIRES `.improve/raw-trace-diagnosis.md`, which
151
+ * finalize commits — evidence metadata, not supervisor code). */
152
+ metadataPrefixes: string[]
153
+ }
154
+
155
+ export const LOOPS_CHANGE_SPACE: ChangeSpace = {
156
+ prefixes: ['extensions/pi/'],
157
+ files: ['src/worker-evidence.ts', 'src/best-effort.ts', 'src/worker-clone.ts'],
158
+ metadataPrefixes: ['.improve/'],
159
+ }
160
+
161
+ /** Normalize a repo-relative path; `null` = un-normalizable (always a violation). */
162
+ export function normalizeRepoPath(p: string): string | null {
163
+ let s = p.trim().replace(/\\/g, '/')
164
+ if (s.startsWith('"') && s.endsWith('"') && s.length >= 2) {
165
+ // git quotes paths containing spaces/specials; minimal unquote.
166
+ s = s.slice(1, -1).replace(/\\"/g, '"')
167
+ }
168
+ while (s.startsWith('./')) s = s.slice(2)
169
+ if (s.length === 0) return null
170
+ if (s.startsWith('/')) return null // absolute — never a repo-relative candidate path
171
+ const segments = s.split('/')
172
+ if (segments.some((seg) => seg === '..' || seg === '')) return null // traversal / '//' — fail closed
173
+ return s
174
+ }
175
+
176
+ /** Paths that fall OUTSIDE the declared change-space (empty ⇒ compliant). */
177
+ export function changeSpaceViolations(paths: string[], space: ChangeSpace = LOOPS_CHANGE_SPACE): string[] {
178
+ const violations: string[] = []
179
+ for (const raw of paths) {
180
+ const p = normalizeRepoPath(raw)
181
+ if (p === null) {
182
+ violations.push(raw)
183
+ continue
184
+ }
185
+ const allowed =
186
+ space.files.includes(p) ||
187
+ space.prefixes.some((pre) => p.startsWith(pre)) ||
188
+ space.metadataPrefixes.some((pre) => p.startsWith(pre))
189
+ if (!allowed) violations.push(p)
190
+ }
191
+ return violations
192
+ }
193
+
194
+ /** Changed paths from `git status --porcelain=v1 --untracked-files=all`.
195
+ * Renames contribute BOTH sides (removing an out-of-space file is a change). */
196
+ export function porcelainChangedPaths(stdout: string): string[] {
197
+ const paths: string[] = []
198
+ for (const line of stdout.split('\n')) {
199
+ if (line.trim().length === 0) continue
200
+ const entry = line.slice(3)
201
+ const arrow = entry.indexOf(' -> ')
202
+ if (arrow !== -1) {
203
+ paths.push(entry.slice(0, arrow).trim(), entry.slice(arrow + 4).trim())
204
+ } else {
205
+ paths.push(entry.trim())
206
+ }
207
+ }
208
+ return paths.filter((p) => p.length > 0)
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Dispatch clocks. The campaign's dispatchTimeoutMs races the ENTIRE dispatch
213
+ // — including the endpoint capacity-gate wait — so a legitimate multi-hour
214
+ // capacity hold was billed to the cell's work budget (measured: a 58-min gate
215
+ // hold pushed the astropy baseline cell over the 7200s clock and the whole
216
+ // candidate became 'rejected-incomplete'). Fix: the cell's REAL work clock
217
+ // (`runWithPostGateClock`) starts only after the gates clear, and the campaign
218
+ // clock is widened to cover worst-case gate holds so it can never fire during
219
+ // a legitimate wait. Both clocks still fail loud — a hung arm is bounded by
220
+ // dispatchTimeoutMs post-gate, and the widened campaign clock is the backstop.
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /** Supervisor arms gate on BOTH endpoints (worker z.ai path + brain router path). */
224
+ export const SUPERVISOR_GATE_COUNT = 2
225
+
226
+ /** capacity.ts's default waitCeilingMs (orchestrate.sh: 300 min/gate). */
227
+ export const DEFAULT_GATE_WAIT_CEILING_MS = 300 * 60_000
228
+
229
+ /** Extra time for process/worktree cleanup after the post-gate clock aborts.
230
+ * Judge time is budgeted separately because one verdict may require two full
231
+ * attempts. The campaign must not abandon either attempt or cleanup. */
232
+ export const DISPATCH_CLEANUP_GRACE_MS = 5 * 60_000
233
+
234
+ /** The widened ceiling handed to the campaign: per-cell work budget PLUS the
235
+ * worst-case capacity-gate holds (gates run sequentially, each with its own
236
+ * ceiling). The campaign clock starts at dispatch entry — before the gates —
237
+ * so it must cover them; `waitForCapacity` itself fails the cell at each
238
+ * gate's own ceiling, so total cell time stays bounded. */
239
+ export function campaignDispatchCeilingMs(
240
+ config: Pick<OuterLoopConfig, 'dispatchTimeoutMs' | 'gateWaitCeilingMs' | 'judgeTimeoutMs'>,
241
+ gateCount = SUPERVISOR_GATE_COUNT,
242
+ ): number {
243
+ const judgeSettlementMs = config.judgeTimeoutMs ?? JUDGE_TIMEOUT_FLOOR_MS
244
+ return (
245
+ config.dispatchTimeoutMs +
246
+ gateCount * (config.gateWaitCeilingMs ?? DEFAULT_GATE_WAIT_CEILING_MS) +
247
+ 2 * judgeSettlementMs +
248
+ DISPATCH_CLEANUP_GRACE_MS
249
+ )
250
+ }
251
+
252
+ /** Run `work` under `timeoutMs`, with the clock started AFTER `awaitGates`
253
+ * resolves — a capacity hold is never billed to the cell's work budget.
254
+ * Gate failures (no capacity within a gate's own ceiling) still reject. */
255
+ export async function runWithPostGateClock<T>(opts: {
256
+ awaitGates: (signal?: AbortSignal) => Promise<void>
257
+ work: (signal: AbortSignal) => Promise<T>
258
+ timeoutMs: number
259
+ label?: string
260
+ /** Caller cancellation remains active during both capacity waiting and work. */
261
+ signal?: AbortSignal
262
+ }): Promise<T> {
263
+ opts.signal?.throwIfAborted()
264
+ await opts.awaitGates(opts.signal)
265
+ opts.signal?.throwIfAborted()
266
+ const abort = new AbortController()
267
+ const linked = linkAbortSignals([abort.signal, ...(opts.signal ? [opts.signal] : [])])
268
+ let timer: NodeJS.Timeout | undefined
269
+ let timedOut = false
270
+ const timeoutError = new Error(
271
+ `post-gate dispatch exceeded ${opts.timeoutMs}ms${opts.label ? ` (${opts.label})` : ''} — failed loud, gate wait unbilled`,
272
+ )
273
+ try {
274
+ if (opts.timeoutMs > 0) {
275
+ timer = setTimeout(() => {
276
+ timedOut = true
277
+ abort.abort(timeoutError)
278
+ }, opts.timeoutMs)
279
+ timer.unref?.()
280
+ }
281
+ const result = await opts.work(linked.signal)
282
+ if (timedOut) throw timeoutError
283
+ opts.signal?.throwIfAborted()
284
+ return result
285
+ } catch (err) {
286
+ if (timedOut && err !== timeoutError) {
287
+ const cleanupFailure = err instanceof Error ? err.message : String(err)
288
+ throw new Error(`${timeoutError.message}; cleanup failed: ${cleanupFailure}`, { cause: err })
289
+ }
290
+ if (timedOut) throw timeoutError
291
+ if (opts.signal?.aborted) {
292
+ if (err !== opts.signal.reason) {
293
+ const cleanupFailure = err instanceof Error ? err.message : String(err)
294
+ const interrupted = opts.signal.reason instanceof Error
295
+ ? opts.signal.reason.message
296
+ : String(opts.signal.reason ?? 'caller aborted')
297
+ throw new Error(`${interrupted}; cleanup failed: ${cleanupFailure}`, { cause: err })
298
+ }
299
+ throw opts.signal.reason
300
+ }
301
+ throw err
302
+ } finally {
303
+ if (timer) clearTimeout(timer)
304
+ linked.dispose()
305
+ }
306
+ }
307
+
308
+ function linkAbortSignals(signals: AbortSignal[]): { signal: AbortSignal; dispose: () => void } {
309
+ const controller = new AbortController()
310
+ const listeners = new Map<AbortSignal, () => void>()
311
+ for (const signal of signals) {
312
+ const onAbort = () => controller.abort(signal.reason)
313
+ listeners.set(signal, onAbort)
314
+ if (signal.aborted) {
315
+ onAbort()
316
+ break
317
+ }
318
+ signal.addEventListener('abort', onAbort, { once: true })
319
+ }
320
+ return {
321
+ signal: controller.signal,
322
+ dispose: () => {
323
+ for (const [signal, listener] of listeners) signal.removeEventListener('abort', listener)
324
+ },
325
+ }
326
+ }
327
+
328
+ function withParentCancellation<T extends CandidateGenerator>(generator: T, signal?: AbortSignal): T {
329
+ if (!signal) return generator
330
+ return {
331
+ ...generator,
332
+ async generate(args) {
333
+ signal.throwIfAborted()
334
+ const linked = linkAbortSignals([args.signal, signal])
335
+ try {
336
+ const result = await generator.generate({ ...args, signal: linked.signal })
337
+ signal.throwIfAborted()
338
+ return result
339
+ } finally {
340
+ linked.dispose()
341
+ }
342
+ },
343
+ } as T
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // Scoring primitives — replicate semantics, the pinned baseline, and the
348
+ // protocol_v2 verdict — live in cell-evidence.mts (pure over lib campaign
349
+ // cells). Re-exported here so existing consumers/tests keep one import home.
350
+ // ---------------------------------------------------------------------------
351
+
352
+ export {
353
+ baselineDriftWarnings,
354
+ cellsFromCampaign,
355
+ decideVerdict,
356
+ gateEvidenceFromCells,
357
+ instanceVerdictsFromCells,
358
+ loadCampaignCells,
359
+ loadCandidateCellGroups,
360
+ perInstanceFromCells,
361
+ replicateCoverageComplete,
362
+ replicateRunsFromCells,
363
+ resolvedInstanceCount,
364
+ sumWallSFromCells,
365
+ type EvidenceCell,
366
+ type R4Artifact,
367
+ type ReplicateRun,
368
+ type StaircasePerInstance,
369
+ type StaircaseVerdict,
370
+ } from './cell-evidence.mts'
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Launch guards. (a) The arms + judge + proposer all die confusingly hours in
374
+ // when the two API keys are absent (the launcher forgot dotenvx) — refuse at
375
+ // t=0 instead. (b) Two outer-loops sharing an outDir corrupt the campaign
376
+ // runDir and the arm-run caches — a pid-file lock with a staleness check makes
377
+ // the race impossible.
378
+ // ---------------------------------------------------------------------------
379
+
380
+ export const REQUIRED_LAUNCH_ENV = ['TANGLE_API_KEY', 'ZAI_API_KEY'] as const
381
+
382
+ export function assertLaunchEnv(env: Record<string, string | undefined> = process.env): void {
383
+ const missing = REQUIRED_LAUNCH_ENV.filter((k) => !env[k] || env[k]!.trim().length === 0)
384
+ if (missing.length > 0) {
385
+ throw new Error(
386
+ `outer-loop: ${missing.join(' + ')} absent from env — launch through dotenvx (dotenvx run -f agent-state.env -f tangle-router.env -- ...)`,
387
+ )
388
+ }
389
+ }
390
+
391
+ /** True when `pid` is a live process (EPERM = alive but not ours — still live). */
392
+ export function isPidAlive(pid: number): boolean {
393
+ try {
394
+ process.kill(pid, 0)
395
+ return true
396
+ } catch (err) {
397
+ return (err as NodeJS.ErrnoException).code === 'EPERM'
398
+ }
399
+ }
400
+
401
+ export const INSTANCE_LOCK_FILENAME = 'outer-loop.pid'
402
+
403
+ export interface InstanceLock {
404
+ path: string
405
+ release: () => Promise<void>
406
+ }
407
+
408
+ /** Single-instance pid-file lock in `outDir`. `wx` creation is the atomic
409
+ * claim; an existing file is honored only while its pid is alive (a crashed
410
+ * loop's stale lock — dead pid or garbage — is reclaimed). Pid reuse can in
411
+ * principle false-positive a stale lock as live; that fails SAFE (refuses to
412
+ * start) and clears on the next reboot cycle. */
413
+ export async function acquireInstanceLock(outDir: string, pid: number = process.pid): Promise<InstanceLock> {
414
+ await mkdir(outDir, { recursive: true })
415
+ const lockPath = join(outDir, INSTANCE_LOCK_FILENAME)
416
+ for (let attempt = 0; attempt < 2; attempt++) {
417
+ try {
418
+ await writeFile(lockPath, `${pid}\n`, { flag: 'wx' })
419
+ return {
420
+ path: lockPath,
421
+ release: async () => {
422
+ const raw = (await readFile(lockPath, 'utf8').catch(() => '')).trim()
423
+ if (raw === String(pid)) await unlink(lockPath).catch(() => {})
424
+ },
425
+ }
426
+ } catch (err) {
427
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
428
+ const raw = (await readFile(lockPath, 'utf8').catch(() => '')).trim()
429
+ const holder = Number.parseInt(raw, 10)
430
+ if (Number.isInteger(holder) && holder > 0 && holder !== pid && isPidAlive(holder)) {
431
+ throw new Error(
432
+ `outer-loop: another outer-loop (pid ${holder}) holds ${lockPath} — single-instance lock, refusing to race`,
433
+ )
434
+ }
435
+ await unlink(lockPath).catch(() => {}) // stale: dead pid or garbage content
436
+ }
437
+ }
438
+ throw new Error(`outer-loop: could not acquire ${lockPath} after clearing a stale lock`)
439
+ }
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // Staircase rows — accepted successors + rejected dots, one JSONL row each.
443
+ // ---------------------------------------------------------------------------
444
+
445
+ export const STAIRCASE_SCHEMA = 'swe-arena.staircase.v1'
446
+
447
+ export interface StaircaseRow {
448
+ schema: typeof STAIRCASE_SCHEMA
449
+ round: number
450
+ generation: number
451
+ runId: string
452
+ at: string
453
+ /** Candidate surface hash (agent-eval surface identity). */
454
+ candidate: string
455
+ candidateCommit: string | null
456
+ /** Incumbent surface hash the candidate mutated. */
457
+ parent: string
458
+ parentResolvedCount: number
459
+ label?: string
460
+ rationale?: string
461
+ changedFiles: string[]
462
+ changeSpaceViolations: string[]
463
+ perInstance: StaircasePerInstance[]
464
+ resolvedCount: number
465
+ coverageComplete: boolean
466
+ wallS: number
467
+ baselineWallS: number
468
+ costRatio: number | null
469
+ costGuardRatio: number
470
+ /** Whether runOptimization's internal keep-if-better advanced the incumbent
471
+ * to this candidate (composite-only rule; may diverge from `verdict` when
472
+ * the protocol cost guard rejects a gaining candidate — divergence is the
473
+ * signal, so both are recorded). */
474
+ internallyPromoted: boolean
475
+ verdict: StaircaseVerdict
476
+ /** Present only on `rejected-prefilter` dots: which pre-filter stage killed
477
+ * the candidate and why (e.g. `smoke: pallets__flask-5014 unresolved`). */
478
+ killReason?: string
479
+ holdout: 'operator-approval-required' | 'not-run'
480
+ armProvenance: { repo: string; commit: string } | null
481
+ diffPath: string | null
482
+ diffSha256: string | null
483
+ }
484
+
485
+ const STAIRCASE_VERDICTS: ReadonlySet<string> = new Set([
486
+ 'accepted',
487
+ 'rejected-no-gain',
488
+ 'rejected-cost',
489
+ 'rejected-out-of-space',
490
+ 'rejected-incomplete',
491
+ 'rejected-prefilter',
492
+ ])
493
+
494
+ /** Parse + validate one staircase JSONL row. Throws on schema drift. */
495
+ export function parseStaircaseRow(line: string): StaircaseRow {
496
+ const row = JSON.parse(line) as StaircaseRow
497
+ if (row.schema !== STAIRCASE_SCHEMA) throw new Error(`staircase row: unknown schema ${JSON.stringify(row.schema)}`)
498
+ for (const field of ['round', 'generation', 'resolvedCount', 'parentResolvedCount', 'wallS', 'baselineWallS', 'costGuardRatio'] as const) {
499
+ if (typeof row[field] !== 'number') throw new Error(`staircase row: ${field} must be a number`)
500
+ }
501
+ for (const field of ['runId', 'at', 'candidate', 'parent'] as const) {
502
+ if (typeof row[field] !== 'string' || row[field].length === 0) throw new Error(`staircase row: ${field} must be a non-empty string`)
503
+ }
504
+ if (!Array.isArray(row.perInstance)) throw new Error('staircase row: perInstance must be an array')
505
+ if (!Array.isArray(row.changedFiles) || !Array.isArray(row.changeSpaceViolations)) {
506
+ throw new Error('staircase row: changedFiles/changeSpaceViolations must be arrays')
507
+ }
508
+ if (!STAIRCASE_VERDICTS.has(row.verdict)) throw new Error(`staircase row: unknown verdict ${JSON.stringify(row.verdict)}`)
509
+ if (typeof row.coverageComplete !== 'boolean' || typeof row.internallyPromoted !== 'boolean') {
510
+ throw new Error('staircase row: coverageComplete/internallyPromoted must be booleans')
511
+ }
512
+ if (row.costRatio !== null && typeof row.costRatio !== 'number') throw new Error('staircase row: costRatio must be number|null')
513
+ return row
514
+ }
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // Config.
518
+ // ---------------------------------------------------------------------------
519
+
520
+ /** Round 1-3 artifact home (this session's scratchpad). Config-overridable —
521
+ * a future round supplies its own artifact roots. */
522
+ export const DEFAULT_HH_SCRATCHPAD =
523
+ '/tmp/claude-1000/-home-drew-code-supervisor-lab/f06fd156-042a-4ef9-bd88-f2ec7f52b90c/scratchpad/hh'
524
+
525
+ export interface SeedArtifactRun {
526
+ iid: string
527
+ arm: string
528
+ dir: string
529
+ patchPath?: string
530
+ /** Official-judge outcome for the seed run (round-3 values pinned in config). */
531
+ resolved: boolean | null
532
+ }
533
+
534
+ export interface FrozenArmParams {
535
+ workerModel: string
536
+ driverModel: string
537
+ budget: number
538
+ maxSandboxes: number
539
+ maxUsd: number
540
+ maxDepth: number
541
+ timeoutMs: number
542
+ envKnobs?: Record<string, string>
543
+ }
544
+
545
+ /** The round-3 (SUP4) arm — protocol_v2 immutables. */
546
+ export const FROZEN_ARM: FrozenArmParams = {
547
+ workerModel: 'zai-coding-plan/glm-5.2',
548
+ driverModel: 'glm-5.2',
549
+ budget: 40,
550
+ maxSandboxes: 4,
551
+ maxUsd: 8,
552
+ maxDepth: 3,
553
+ timeoutMs: 2_800_000,
554
+ }
555
+
556
+ export interface OuterLoopConfig {
557
+ round: number
558
+ /** Improvement set — the arena `improve()` trains on. */
559
+ instances: string[]
560
+ /** Pre-registered holdout. RECORDED here so the flag + operator instruction
561
+ * are self-contained; this driver NEVER runs them. */
562
+ holdoutInstances: string[]
563
+ loopsRepo: string
564
+ loopsBaseRef: string
565
+ armName: string
566
+ arm: FrozenArmParams
567
+ verifyDir: string
568
+ outDir: string
569
+ /** Staircase home, e.g. /home/drew/code/supervisor-lab/.evolve/rounds. */
570
+ roundsDir: string
571
+ secretsDir: string
572
+ envFiles: string[]
573
+ instanceImagesPath?: string
574
+ judgeTimeoutMs?: number
575
+ gateWaitCeilingMs?: number
576
+ capacityModel?: string
577
+ generations: number
578
+ populationSize: number
579
+ /** Replicate cells per (candidate × instance). Default 1. Instances count as
580
+ * resolved only when ALL replicates resolve (see resolvedInstanceCount) —
581
+ * single-rep scoring flips instance outcomes run-to-run. */
582
+ repsPerInstance?: number
583
+ /** Stored `PremeasuredOptimizationBaseline` JSON ({surfaceHash, campaign})
584
+ * from a prior run's baseline campaign — REQUIRED, the gate's only
585
+ * denominator. The LIB validates the artifact (surface hash, seed, reps,
586
+ * split digest, coverage) before skipping the baseline campaign, so a
587
+ * wrong artifact fails loud at t≈0. BOOTSTRAP: when the file does not
588
+ * exist yet, this run MEASURES the baseline (cache-resumable) and WRITES
589
+ * the artifact here for every later run to consume. */
590
+ premeasuredBaselinePath: string
591
+ /** DEPTH for the agentic generator — forwarded as
592
+ * budget.maxImprovementShots; the LIB owns the dial (capabilities.mts
593
+ * fails loud on a substrate that would drop it). */
594
+ maxShots: number
595
+ proposerHarness: 'claude' | 'codex' | 'opencode'
596
+ proposerTimeoutMs: number
597
+ /** GEN-3 proposer fan-out: N proposers author candidates CONCURRENTLY, each
598
+ * an AgentProfile-pinned harness invocation (see proposer-fanout.mts).
599
+ * When set, `populationSize` MUST equal `proposers.length` (one candidate
600
+ * slot per proposer — enforced at launch). Unset = the legacy
601
+ * single-author generator (`proposerHarness` + bare invocation). */
602
+ proposers?: ProposerSpec[]
603
+ /** GEN-3 cheap pre-filter: per candidate, change-space + tsc (the authoring
604
+ * verifier) plus ONE smoke arm cell before any full-evaluation spend.
605
+ * Killed candidates become `rejected-prefilter` staircase dots. */
606
+ prefilter?: PrefilterConfig
607
+ /** Replicates per holdout instance in the operator-approved certification
608
+ * run (holdout-certify.mts). Default 2 — the gen-2 winner failed 3/6 vs
609
+ * 4/6 on a 1-rep holdout with exactly one discordant cell, a known
610
+ * single-rep noise class. */
611
+ holdoutRepsPerInstance?: number
612
+ /** SAME-PROTOCOL parent measurement the certification bar compares against:
613
+ * an explicit {iid -> AND-verdict} map measured under the identical
614
+ * reps/fail-closed protocol, or 'measure' — the incumbent runs the same
615
+ * 2-rep holdout first in the certification run. */
616
+ holdoutBaseline?: Record<string, boolean> | 'measure'
617
+ /** Router model ids for the blind diagnosis ensemble (config, never a
618
+ * hardcoded unrouted model). */
619
+ analystModels: string[]
620
+ /** Previous round's failure artifacts, diagnosed before generation 0. */
621
+ seedArtifactRuns: SeedArtifactRun[]
622
+ costGuardRatio: number
623
+ dispatchTimeoutMs: number
624
+ }
625
+
626
+ export function assertFrozenArm(arm: FrozenArmParams): void {
627
+ const drift: string[] = []
628
+ for (const key of ['workerModel', 'driverModel', 'budget', 'maxSandboxes', 'maxUsd', 'maxDepth'] as const) {
629
+ if (arm[key] !== FROZEN_ARM[key]) drift.push(`${key}: ${JSON.stringify(arm[key])} != ${JSON.stringify(FROZEN_ARM[key])}`)
630
+ }
631
+ if (drift.length > 0) {
632
+ throw new Error(
633
+ `protocol_v2 violation: arm params are immutable (round-3 frozen values) — ${drift.join('; ')}`,
634
+ )
635
+ }
636
+ }
637
+
638
+ /** Committed per-instance verify scripts (fixtures/verify/<iid>.sh) — the
639
+ * durable home; the experiment's scratchpad copy did not survive a reboot. */
640
+ export const FIXTURES_VERIFY_DIR = fileURLToPath(new URL('./fixtures/verify', import.meta.url))
641
+
642
+ export function defaultRound4Config(
643
+ hh = DEFAULT_HH_SCRATCHPAD,
644
+ opts: { outDirName?: string } = {},
645
+ ): OuterLoopConfig {
646
+ const round3 = [
647
+ { iid: 'astropy__astropy-13033', resolved: false },
648
+ { iid: 'django__django-11532', resolved: false },
649
+ { iid: 'matplotlib__matplotlib-20826', resolved: true },
650
+ ]
651
+ return {
652
+ round: 4,
653
+ instances: round3.map((r) => r.iid),
654
+ holdoutInstances: [
655
+ 'astropy__astropy-14182',
656
+ 'django__django-12774',
657
+ 'django__django-14140',
658
+ 'scikit-learn__scikit-learn-14894',
659
+ 'sympy__sympy-20438',
660
+ 'pytest-dev__pytest-7236',
661
+ ],
662
+ loopsRepo: '/home/drew/code/loops',
663
+ loopsBaseRef: 'feat/supervisor-evidence-flow',
664
+ armName: 'R4',
665
+ arm: { ...FROZEN_ARM },
666
+ verifyDir: FIXTURES_VERIFY_DIR,
667
+ outDir: join(hh, opts.outDirName ?? 'r4'),
668
+ roundsDir: '/home/drew/code/supervisor-lab/.evolve/rounds',
669
+ secretsDir: '/home/drew/company/devops/secrets',
670
+ envFiles: ['agent-state.env', 'tangle-router.env'],
671
+ generations: 1,
672
+ populationSize: 2,
673
+ repsPerInstance: 2,
674
+ // The reps-confirmed baseline artifact (gen-1 measured: astropy F/F,
675
+ // django T/F → F fail-closed, matplotlib T/T = 1/3) lives here once the
676
+ // bootstrap run writes it; the lib validates it on every consumption.
677
+ premeasuredBaselinePath: join(hh, 'r4', 'premeasured-baseline.json'),
678
+ maxShots: 3,
679
+ proposerHarness: 'claude',
680
+ // Per author SHOT (agenticGenerator timeoutMs). 20 min timed out 3× under
681
+ // degraded capacity in gen-1 ("author shot timed out") — doubled to 40 min.
682
+ proposerTimeoutMs: 2_400_000,
683
+ analystModels: ['glm-5.2', 'glm-5.2', 'glm-5.2'],
684
+ seedArtifactRuns: round3.map((r) => ({
685
+ iid: r.iid,
686
+ arm: 'SUP4',
687
+ dir: join(hh, 'runs', r.iid, 'SUP4'),
688
+ patchPath: join(hh, 'patches', `${r.iid}.sup4.patch`),
689
+ resolved: r.resolved,
690
+ })),
691
+ costGuardRatio: 1.2,
692
+ dispatchTimeoutMs: 7_200_000,
693
+ }
694
+ }
695
+
696
+ // ---------------------------------------------------------------------------
697
+ // GEN-3 configuration — proposer fan-out + pre-filter + the widened
698
+ // improvement set + the 2-rep holdout protocol.
699
+ // ---------------------------------------------------------------------------
700
+
701
+ /** The gen-3 improvement set: the round-3 trio plus the three BOTH-FAIL
702
+ * instances from the original head-to-head (solo glm-5.2 ALSO failed them —
703
+ * any resolution beats solo, not just the parent). All six carry committed,
704
+ * dual-calibrated verify fixtures (repro base-fail/gold-pass + gold
705
+ * official-resolved). */
706
+ export const GEN3_IMPROVEMENT_SET = [
707
+ 'astropy__astropy-13033',
708
+ 'django__django-11532',
709
+ 'matplotlib__matplotlib-20826',
710
+ 'pydata__xarray-4687',
711
+ 'pytest-dev__pytest-6197',
712
+ 'sphinx-doc__sphinx-9658',
713
+ ] as const
714
+
715
+ /** Never-registered spare pool, pre-named in case a gen-3 instance has to be
716
+ * replaced (calibration regression, image loss). */
717
+ export const GEN3_SPARE_POOL = [
718
+ 'sympy__sympy-17318',
719
+ 'scikit-learn__scikit-learn-14087',
720
+ 'astropy__astropy-14508',
721
+ ] as const
722
+
723
+ /** Resolve the pre-filter smoke instance. 'cheapest-of-set' picks the
724
+ * improvement-set instance with the smallest summed baseline wall seconds
725
+ * (from the premeasured artifact's cells); with no baseline measurement yet
726
+ * it falls back to the first instance. An explicit iid passes through. */
727
+ export function resolveSmokeInstance(
728
+ smokeInstance: string,
729
+ instances: readonly string[],
730
+ baselineCells: import('./cell-evidence.mts').EvidenceCell[] | null,
731
+ ): string {
732
+ if (smokeInstance !== 'cheapest-of-set') return smokeInstance
733
+ if (instances.length === 0) throw new Error('resolveSmokeInstance: empty improvement set')
734
+ if (baselineCells === null || baselineCells.length === 0) return instances[0]!
735
+ const wall = new Map<string, number>()
736
+ for (const cell of baselineCells) {
737
+ if (cell.artifact === null || cell.artifact.kind !== 'swe-arm') continue
738
+ wall.set(cell.scenarioId, (wall.get(cell.scenarioId) ?? 0) + cell.artifact.wallS)
739
+ }
740
+ let best: string | null = null
741
+ let bestWall = Number.POSITIVE_INFINITY
742
+ for (const iid of instances) {
743
+ const w = wall.get(iid)
744
+ if (w !== undefined && w < bestWall) {
745
+ best = iid
746
+ bestWall = w
747
+ }
748
+ }
749
+ return best ?? instances[0]!
750
+ }
751
+
752
+ /**
753
+ * The gen-3 config: protocol round 4 continues (frozen arm, same holdout
754
+ * registry, same roundsDir staircase) with the gen-3 machinery on:
755
+ *
756
+ * - THREE parallel proposers (all claude, bare default-author profile) that
757
+ * differ by diagnosis slice/lens — fan-out diversity without unproven
758
+ * harness seats; `populationSize` = `proposers.length`.
759
+ * - Pre-filter enabled at the mechanism bar on the cheapest-of-set smoke
760
+ * instance ('pallets__flask-5014' becomes the designated smoke once its
761
+ * verify fixture is authored + calibrated; it has none committed yet).
762
+ * - The 6-instance improvement set. The premeasured-baseline artifact path
763
+ * is NEW (gen3/): the lib validates a premeasured campaign against the
764
+ * FULL scenario split digest, so the 3-instance round-4 artifact cannot
765
+ * seed a 6-instance split — the first gen-3 run is the bootstrap that
766
+ * measures all six (cache-resumable) and writes the artifact; the three
767
+ * new instances are thereby measured on the first round.
768
+ * - Holdout protocol pinned at 2 reps, parent measured under the SAME
769
+ * protocol ('measure'), operator valve unchanged (holdout: 'deferred').
770
+ */
771
+ export function defaultGen3Config(
772
+ hh = DEFAULT_HH_SCRATCHPAD,
773
+ opts: { outDirName?: string } = {},
774
+ ): OuterLoopConfig {
775
+ const base = defaultRound4Config(hh, opts)
776
+ const outDirName = opts.outDirName ?? 'gen3'
777
+ const proposers: ProposerSpec[] = [
778
+ { name: 'default-author', profile: 'default-author.profile.json', harness: 'claude' },
779
+ {
780
+ name: 'mechanics-author',
781
+ profile: 'default-author.profile.json',
782
+ harness: 'claude',
783
+ diagnosisSlice: 'mechanics',
784
+ lens: 'Focus on MECHANICS: worker lifecycle, sandbox/clone contracts, settlement and delivery paths. Prefer code-path fixes over prompt wording.',
785
+ },
786
+ {
787
+ name: 'prompts-author',
788
+ profile: 'default-author.profile.json',
789
+ harness: 'claude',
790
+ diagnosisSlice: 'prompts',
791
+ lens: 'Focus on PROMPTS: worker/brain instruction wording, placement guidance, self-check discipline. Prefer prompt/instruction changes over code-path rewrites.',
792
+ },
793
+ ]
794
+ return {
795
+ ...base,
796
+ instances: [...GEN3_IMPROVEMENT_SET],
797
+ outDir: join(hh, outDirName),
798
+ premeasuredBaselinePath: join(hh, outDirName, 'premeasured-baseline.json'),
799
+ populationSize: proposers.length,
800
+ proposers,
801
+ prefilter: { enabled: true, smokeInstance: 'cheapest-of-set', requireResolved: false },
802
+ holdoutRepsPerInstance: 2,
803
+ holdoutBaseline: 'measure',
804
+ }
805
+ }
806
+
807
+ // ---------------------------------------------------------------------------
808
+ // Round recorder — dispatch-time change-space fail-closed + diff writing ONLY.
809
+ // NOT a scoring source: scoring reads the lib's campaign cells
810
+ // (cell-evidence.mts). The prior recorder role — accumulating per-instance
811
+ // results keyed by dispatch order — mislabeled a resumed run's baseline
812
+ // (r4-mroh3rkt: cached cells replay without dispatching, so "first dispatched
813
+ // surface" was a CANDIDATE and the summary published its cells as
814
+ // "baseline 0/3" while the measured baseline was 1/3).
815
+ // ---------------------------------------------------------------------------
816
+
817
+ interface CandidateRecord {
818
+ surfaceKey: string
819
+ commit: string
820
+ baseCommit: string
821
+ tag: string
822
+ changedFiles: string[]
823
+ violations: string[]
824
+ diffPath: string | null
825
+ diffSha256: string | null
826
+ /** Dispatch-time forensics: which loops checkout ran the arm. Null for a
827
+ * candidate whose cells were all replayed from cache (never dispatched
828
+ * in this process). */
829
+ armProvenance: { repo: string; commit: string } | null
830
+ }
831
+
832
+ class RoundRecorder {
833
+ readonly byKey = new Map<string, CandidateRecord>()
834
+ constructor(
835
+ private readonly loopsRepo: string,
836
+ private readonly candidatesDir: string,
837
+ ) {}
838
+
839
+ byCommit(commit: string): CandidateRecord | undefined {
840
+ for (const rec of this.byKey.values()) if (rec.commit === commit) return rec
841
+ return undefined
842
+ }
843
+
844
+ /** Describe a candidate surface: changed files, change-space violations, and
845
+ * the written diff. Idempotent and callable POST-RUN too (candidate commits
846
+ * survive in the loops object store after worktree cleanup), so resumed
847
+ * candidates that never dispatched here still get full staircase rows. */
848
+ async ensure(surface: CodeSurface): Promise<CandidateRecord> {
849
+ const key = surfaceHash(surface)
850
+ const existing = this.byKey.get(key)
851
+ if (existing) return existing
852
+ const names = await runOk('git', [
853
+ '-C', this.loopsRepo,
854
+ 'diff', '--name-only', surface.baseCommit, surface.candidateCommit,
855
+ ])
856
+ const changedFiles = names.stdout.split('\n').map((s) => s.trim()).filter(Boolean)
857
+ const violations = changeSpaceViolations(changedFiles)
858
+ const tag = surface.candidateCommit.slice(0, 10)
859
+ let diffPath: string | null = null
860
+ if (surface.candidateCommit !== surface.baseCommit) {
861
+ const diff = await runOk('git', ['-C', this.loopsRepo, 'diff', surface.baseCommit, surface.candidateCommit])
862
+ await mkdir(this.candidatesDir, { recursive: true })
863
+ diffPath = join(this.candidatesDir, `${tag}.patch`)
864
+ await writeFile(diffPath, diff.stdout)
865
+ }
866
+ const rec: CandidateRecord = {
867
+ surfaceKey: key,
868
+ commit: surface.candidateCommit,
869
+ baseCommit: surface.baseCommit,
870
+ tag,
871
+ changedFiles,
872
+ violations,
873
+ diffPath,
874
+ diffSha256: surface.patch.sha256,
875
+ armProvenance: null,
876
+ }
877
+ this.byKey.set(key, rec)
878
+ return rec
879
+ }
880
+ }
881
+
882
+ // ---------------------------------------------------------------------------
883
+ // Eval worktrees — a candidate commit gets its own loops checkout so the
884
+ // candidate worktree managed by the improvement driver stays PRISTINE (its
885
+ // finalize-time verification rejects any extra file, node_modules included).
886
+ // ---------------------------------------------------------------------------
887
+
888
+ export async function addEvalWorktree(
889
+ loopsRepo: string,
890
+ commit: string,
891
+ dest: string,
892
+ signal?: AbortSignal,
893
+ ): Promise<void> {
894
+ signal?.throwIfAborted()
895
+ await run('git', ['-C', loopsRepo, 'worktree', 'remove', '--force', '--', dest], { timeoutMs: 60_000, signal })
896
+ signal?.throwIfAborted()
897
+ await rm(dest, { recursive: true, force: true })
898
+ signal?.throwIfAborted()
899
+ await run('git', ['-C', loopsRepo, 'worktree', 'prune'], { timeoutMs: 60_000, signal })
900
+ signal?.throwIfAborted()
901
+ await runOk('git', ['-C', loopsRepo, 'worktree', 'add', '--detach', dest, commit], { timeoutMs: 60_000, signal })
902
+ signal?.throwIfAborted()
903
+ // The loops driver needs deps; a worktree has none. Shared install is safe:
904
+ // arms never write into the loops checkout (state goes to ws/.loops + runDir).
905
+ await symlink(join(loopsRepo, 'node_modules'), join(dest, 'node_modules'), 'dir')
906
+ }
907
+
908
+ export async function removeEvalWorktree(loopsRepo: string, dest: string): Promise<void> {
909
+ await unlink(join(dest, 'node_modules')).catch(() => {})
910
+ const res = await run('git', ['-C', loopsRepo, 'worktree', 'remove', '--force', '--', dest], { timeoutMs: 60_000 })
911
+ if (res.code !== 0) {
912
+ await rm(dest, { recursive: true, force: true })
913
+ await run('git', ['-C', loopsRepo, 'worktree', 'prune'], { timeoutMs: 60_000 })
914
+ }
915
+ }
916
+
917
+ // ---------------------------------------------------------------------------
918
+ // The constrained proposer: agenticGenerator + change-space verifier + the
919
+ // round-4 task prompt. `improve(surface:'code')` requires the generator via
920
+ // `code.generator` so the runtime owns candidate-worktree cleanup.
921
+ // ---------------------------------------------------------------------------
922
+
923
+ /** Mirrors the agentic generator's raw-trace evidence contract — the exact
924
+ * artifact path its gate checks for. */
925
+ export const RAW_TRACE_DIAGNOSIS_PATH = '.improve/raw-trace-diagnosis.md'
926
+
927
+ export function changeSpaceInstruction(space: ChangeSpace = LOOPS_CHANGE_SPACE): string {
928
+ return [
929
+ 'DECLARED CHANGE-SPACE (hard constraint, enforced by an automated gate):',
930
+ `- You may ONLY edit files under: ${space.prefixes.map((p) => `${p}**`).join(', ')}`,
931
+ `- and these exact files: ${space.files.join(', ')}`,
932
+ `- plus the diagnosis artifact ${RAW_TRACE_DIAGNOSIS_PATH}.`,
933
+ '- Everything else is IMMUTABLE for this experiment: the official judge, the per-instance verify scripts,',
934
+ ' task prompts, model ids, and budgets live outside your reach and candidates whose diff touches any',
935
+ ' other path are REJECTED before they are ever evaluated.',
936
+ ].join('\n')
937
+ }
938
+
939
+ export function round4BuildPrompt(args: { report: unknown; findings: Array<Record<string, unknown>> }): string {
940
+ const lines: string[] = [
941
+ 'You are the optimizer of the "loops" pi SUPERVISOR — an agent that plans, spawns sandboxed coding',
942
+ 'workers, and settles a delivered patch for SWE-bench Verified instances (glm-5.2 in both seats, frozen).',
943
+ 'Round-3 state: the supervisor resolves 1/3 of its improvement set (matplotlib resolved; astropy + django',
944
+ 'deliver self-verify-passing patches the OFFICIAL maintainer test suite still rejects).',
945
+ '',
946
+ 'GOAL: raise the official resolved count on the improvement set WITHOUT raising cost/arm by more than 20%.',
947
+ 'Make the smallest coherent change to the supervisor implementation that addresses the diagnosis below,',
948
+ 'then stop. Do not commit — leave changes in the working tree.',
949
+ '',
950
+ changeSpaceInstruction(),
951
+ '',
952
+ 'Diagnosis findings (blind multi-analyst ensemble + raw-trace context):',
953
+ ]
954
+ for (const f of args.findings) {
955
+ const severity = typeof f.severity === 'string' ? f.severity : 'info'
956
+ const subject = typeof f.subject === 'string' ? ` [${f.subject}]` : ''
957
+ const claim = typeof f.claim === 'string' ? f.claim : JSON.stringify(f)
958
+ lines.push(`- (${severity})${subject} ${claim}`)
959
+ if (typeof f.recommended_action === 'string') lines.push(` → ${f.recommended_action}`)
960
+ }
961
+ const hasRawTrace = args.findings.some(
962
+ (f) => f.analyst_id === 'raw-trace-distiller' || f.area === 'raw-trace-context',
963
+ )
964
+ if (hasRawTrace) {
965
+ lines.push(
966
+ '',
967
+ 'Raw trace evidence requirement:',
968
+ '- Inspect at least one raw trace path named above before editing.',
969
+ `- Write ${RAW_TRACE_DIAGNOSIS_PATH} in this worktree.`,
970
+ '- Include the exact trace path(s) inspected, the failure mechanism, and the code change made.',
971
+ '- A candidate without this file, or with only this file changed, is discarded.',
972
+ )
973
+ }
974
+ return lines.join('\n')
975
+ }
976
+
977
+ /** Purge gitignored artifacts from a candidate worktree with `git clean -Xdff`.
978
+ *
979
+ * The proposer agent may run a dependency install inside its worktree to
980
+ * verify its own change (measured: round-4 gen-0 cand-1 left a real pnpm
981
+ * `node_modules/` — 38k paths — after editing loops.ts). Ignored paths are
982
+ * invisible to the change-space check (`git status` honors .gitignore), but
983
+ * the improvement driver's finalize-time surface verification rejects ANY
984
+ * extra path, ignored included (`ls-files --others --ignored`), killing the
985
+ * whole run. `-X` deletes only ignored paths, so tracked edits and untracked
986
+ * non-ignored deliverables (e.g. .improve/raw-trace-diagnosis.md) survive;
987
+ * the doubled `-f` clears nested git dirs some packages ship. */
988
+ export async function purgeIgnoredArtifacts(
989
+ worktreePath: string,
990
+ signal?: AbortSignal,
991
+ ): Promise<void> {
992
+ await runOk('git', ['-C', worktreePath, 'clean', '-Xdff'], {
993
+ ...(signal ? { signal } : {}),
994
+ })
995
+ }
996
+
997
+ /** Verifier run after each generator shot: ignored-dirt purge first (the
998
+ * finalize precondition), then change-space compliance (cheap,
999
+ * feedback-rich), then `tsc --noEmit` with the main repo's
1000
+ * node_modules linked in TEMPORARILY (the link must not survive — the
1001
+ * driver's finalize-time surface verification rejects any extra path). */
1002
+ export function loopsCandidateVerifier(loopsRepo: string): Verifier {
1003
+ return async (worktreePath: string, signal?: AbortSignal) => {
1004
+ signal?.throwIfAborted()
1005
+ await purgeIgnoredArtifacts(worktreePath, signal)
1006
+ signal?.throwIfAborted()
1007
+ const status = await runOk(
1008
+ 'git',
1009
+ ['-C', worktreePath, 'status', '--porcelain=v1', '--untracked-files=all'],
1010
+ { ...(signal ? { signal } : {}) },
1011
+ )
1012
+ signal?.throwIfAborted()
1013
+ const violations = changeSpaceViolations(porcelainChangedPaths(status.stdout))
1014
+ if (violations.length > 0) {
1015
+ return {
1016
+ ok: false,
1017
+ feedback:
1018
+ `CHANGE-SPACE VIOLATION — these paths are outside the declared change-space:\n` +
1019
+ violations.map((v) => ` - ${v}`).join('\n') +
1020
+ `\n${changeSpaceInstruction()}\nRevert or relocate those edits (git checkout -- <path> / rm for untracked).`,
1021
+ }
1022
+ }
1023
+ const nm = join(worktreePath, 'node_modules')
1024
+ let linked = false
1025
+ signal?.throwIfAborted()
1026
+ if (!existsSync(nm)) {
1027
+ await symlink(join(loopsRepo, 'node_modules'), nm, 'dir')
1028
+ linked = true
1029
+ }
1030
+ try {
1031
+ const tsc = join(loopsRepo, 'node_modules', '.bin', 'tsc')
1032
+ const res = await run(tsc, ['--noEmit'], {
1033
+ cwd: worktreePath,
1034
+ timeoutMs: 300_000,
1035
+ ...(signal ? { signal } : {}),
1036
+ })
1037
+ signal?.throwIfAborted()
1038
+ if (res.code !== 0) {
1039
+ return {
1040
+ ok: false,
1041
+ feedback: `tsc --noEmit failed (rc=${res.code}${res.timedOut ? ', timeout' : ''}):\n${(res.stdout + res.stderr).slice(0, 4000)}`,
1042
+ }
1043
+ }
1044
+ return { ok: true }
1045
+ } finally {
1046
+ if (linked) await unlink(nm).catch(() => {})
1047
+ }
1048
+ }
1049
+ }
1050
+
1051
+ /** Ambient auth vars that hijack the claude CLI away from its claude.ai login.
1052
+ * The run is launched under dotenvx, and agent-state.env injects an
1053
+ * ANTHROPIC_API_KEY meant for other tooling; the claude CLI prefers env-key
1054
+ * auth over the logged-in account and exits 1 immediately when that key's org
1055
+ * is over its usage cap (reproduced 2026-07-20: `claude -p` under the run env
1056
+ * → rc=1, "API Error: 400 You have reached your specified API usage limits";
1057
+ * same command with these vars unset → rc=0). The author shot must run on the
1058
+ * CLI's own login, so the leaked auth is stripped for the shot subprocess
1059
+ * only — the rest of the run keeps its env untouched. */
1060
+ const CLAUDE_AMBIENT_AUTH_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL'] as const
1061
+
1062
+ export function proposerShotEnv(harness: OuterLoopConfig['proposerHarness']): NodeJS.ProcessEnv {
1063
+ const env: NodeJS.ProcessEnv = { ...process.env }
1064
+ if (harness === 'claude') {
1065
+ for (const name of CLAUDE_AMBIENT_AUTH_VARS) delete env[name]
1066
+ }
1067
+ return env
1068
+ }
1069
+
1070
+ export function constrainedLoopsGenerator(config: OuterLoopConfig): CandidateGenerator {
1071
+ const shotDir = join(config.outDir, 'proposer-shots')
1072
+ // The run-wide CostLedger the current generate() call rides — captured so
1073
+ // onShotCompleted can settle each shot's spend into it. maxConcurrency is 1
1074
+ // and shots run inside generate(), so a single slot cannot interleave.
1075
+ let activeLedger: CostLedgerHandle | undefined
1076
+ let activePhase: string | undefined
1077
+ const inner = agenticGenerator({
1078
+ harness: config.proposerHarness,
1079
+ timeoutMs: config.proposerTimeoutMs,
1080
+ buildPrompt: (args) =>
1081
+ round4BuildPrompt(args as unknown as { report: unknown; findings: Array<Record<string, unknown>> }),
1082
+ verify: loopsCandidateVerifier(config.loopsRepo),
1083
+ runHarness: (options) => runLocalHarness({ ...options, env: proposerShotEnv(config.proposerHarness) }),
1084
+ // Three runs died as "author shot exited with code 1" with the shot's
1085
+ // stderr lost (nothing wires receipt persistence by default). Persist every
1086
+ // attempted shot — receipt plus bounded stream tails — so the NEXT failure
1087
+ // names its cause from disk. Shared implementation with the gen-3 fan-out
1088
+ // authors (proposer-fanout.mts): receipt persistence + spend settlement
1089
+ // into the run ledger for the claude/opencode paths whose shots would
1090
+ // otherwise read $0.
1091
+ onShotCompleted: proposerShotHooks({
1092
+ shotDir,
1093
+ harness: config.proposerHarness,
1094
+ ledger: () => activeLedger,
1095
+ phase: () => activePhase,
1096
+ }),
1097
+ })
1098
+ return {
1099
+ kind: `round4-constrained:${inner.kind}`,
1100
+ proposesWithoutFindings: true,
1101
+ generate: (args) => {
1102
+ activeLedger = args.costLedger
1103
+ activePhase = args.costPhase
1104
+ // args.maxShots is the LIB's dial (budget.maxImprovementShots → the
1105
+ // improvement driver); capabilities.mts guarantees it is threaded.
1106
+ return inner.generate(args)
1107
+ },
1108
+ }
1109
+ }
1110
+
1111
+ // ---------------------------------------------------------------------------
1112
+ // runRound. (The evaluated R4Artifact type lives in cell-evidence.mts with
1113
+ // the scoring that consumes it.)
1114
+ // ---------------------------------------------------------------------------
1115
+
1116
+ /** Ledger model id for the dockerized official judge's $0 receipts. */
1117
+ export const OFFICIAL_JUDGE_MODEL = 'swe-bench-official-judge'
1118
+
1119
+ function asCodeSurface(surface: MutableSurface): CodeSurface {
1120
+ if (typeof surface !== 'object' || surface === null || surface.kind !== 'code') {
1121
+ throw new Error('outer-loop: expected a CodeSurface (improve surface:"code" contract)')
1122
+ }
1123
+ return surface
1124
+ }
1125
+
1126
+ const log = (msg: string): void => console.log(`[${new Date().toISOString().slice(11, 19)}] ${msg}`)
1127
+
1128
+ export async function runRound(config: OuterLoopConfig, signal?: AbortSignal): Promise<void> {
1129
+ signal?.throwIfAborted()
1130
+ assertFrozenArm(config.arm)
1131
+ if (config.instances.length === 0) throw new Error('outer-loop: empty improvement set')
1132
+ const overlap = config.instances.filter((i) => config.holdoutInstances.includes(i))
1133
+ if (overlap.length > 0) {
1134
+ throw new Error(`outer-loop: improvement set leaks into the pre-registered holdout: ${overlap.join(', ')}`)
1135
+ }
1136
+ const reps = config.repsPerInstance ?? 1
1137
+ if (!Number.isInteger(reps) || reps < 1) {
1138
+ throw new Error(`outer-loop: repsPerInstance must be a positive integer, got ${JSON.stringify(config.repsPerInstance)}`)
1139
+ }
1140
+ if (config.proposers !== undefined) {
1141
+ if (config.proposers.length === 0) throw new Error('outer-loop: config.proposers must not be empty when set')
1142
+ if (config.proposers.length !== config.populationSize) {
1143
+ throw new Error(
1144
+ `outer-loop: populationSize ${config.populationSize} != proposers.length ${config.proposers.length} — ` +
1145
+ 'the fan-out assigns exactly one candidate slot per proposer',
1146
+ )
1147
+ }
1148
+ }
1149
+ // Stale-install guard: the resolved substrate must thread the passthroughs
1150
+ // this run depends on. Fails loud — a silent drop would re-spend the
1151
+ // premeasured baseline and pin the depth dial (see capabilities.mts).
1152
+ assertSubstratePassthroughs(log)
1153
+
1154
+ // The gate's only denominator: a stored prior baseline campaign the LIB
1155
+ // validates (surface hash, seed, reps, split digest, coverage) before
1156
+ // skipping the baseline campaign. A missing artifact = the BOOTSTRAP run —
1157
+ // the baseline is measured (cache-resumable) and the artifact written at
1158
+ // the end of this run.
1159
+ if (typeof config.premeasuredBaselinePath !== 'string' || config.premeasuredBaselinePath.length === 0) {
1160
+ throw new Error('outer-loop: config.premeasuredBaselinePath is required (the bootstrap run writes the artifact there)')
1161
+ }
1162
+ let premeasured: PremeasuredOptimizationBaseline<R4Artifact, Scenario> | undefined
1163
+ if (existsSync(config.premeasuredBaselinePath)) {
1164
+ premeasured = JSON.parse(
1165
+ await readFile(config.premeasuredBaselinePath, 'utf8'),
1166
+ ) as PremeasuredOptimizationBaseline<R4Artifact, Scenario>
1167
+ if (!premeasured || typeof premeasured.surfaceHash !== 'string' || !premeasured.campaign) {
1168
+ throw new Error(`premeasuredBaselinePath: ${config.premeasuredBaselinePath} is not a {surfaceHash, campaign} record`)
1169
+ }
1170
+ log(`premeasured baseline: ${config.premeasuredBaselinePath} (surface ${premeasured.surfaceHash})`)
1171
+ } else {
1172
+ log(
1173
+ `premeasured baseline artifact missing at ${config.premeasuredBaselinePath} — BOOTSTRAP run: ` +
1174
+ 'the baseline campaign will be measured (cache-resumable) and the artifact written there for later runs',
1175
+ )
1176
+ }
1177
+
1178
+ const secrets: SecretsEnv = { secretsDir: config.secretsDir, envFiles: config.envFiles }
1179
+ const excludes = await loadExcludes()
1180
+ const images = await loadInstanceImages(config.instanceImagesPath)
1181
+ const adapter = createSweBenchAdapter()
1182
+ // The pre-filter's smoke instance may sit outside the improvement set (e.g.
1183
+ // a designated cheap instance) — it needs the same problem/image/verify
1184
+ // validation and rides the same loaded-task map.
1185
+ const smokeIid =
1186
+ config.proposers !== undefined && config.prefilter?.enabled
1187
+ ? resolveSmokeInstance(
1188
+ config.prefilter.smokeInstance,
1189
+ config.instances,
1190
+ premeasured ? cellsFromCampaign(premeasured.campaign) : null,
1191
+ )
1192
+ : null
1193
+ const taskIds = [...new Set([...config.instances, ...(smokeIid !== null ? [smokeIid] : [])])]
1194
+ const tasks = await adapter.loadTasks({ ids: taskIds, split: 'test' })
1195
+ const problemById = new Map<string, string>()
1196
+ for (const iid of taskIds) {
1197
+ const task = tasks.find((t) => t.id === iid)
1198
+ if (!task) throw new Error(`outer-loop: ${iid} not found in SWE-bench_Verified`)
1199
+ const problem = String(task.metadata?.problem_statement ?? '')
1200
+ if (!problem) throw new Error(`outer-loop: ${iid} has an empty problem_statement`)
1201
+ if (!images[iid]) throw new Error(`outer-loop: ${iid} has no image mapping`)
1202
+ const verifyScript = join(config.verifyDir, `${iid}.sh`)
1203
+ if (!existsSync(verifyScript)) throw new Error(`outer-loop: missing verify script ${verifyScript}`)
1204
+ problemById.set(iid, problem)
1205
+ }
1206
+ if (smokeIid !== null) log(`prefilter smoke instance: ${smokeIid}`)
1207
+
1208
+ const judge: SerializedJudge = createSerializedJudge(
1209
+ config.judgeTimeoutMs !== undefined ? { timeoutMs: config.judgeTimeoutMs } : {},
1210
+ )
1211
+ const runId = `r${config.round}-${Date.now().toString(36)}`
1212
+ await mkdir(config.outDir, { recursive: true })
1213
+ await mkdir(config.roundsDir, { recursive: true })
1214
+ const recorder = new RoundRecorder(config.loopsRepo, join(config.outDir, 'candidates'))
1215
+ const analysts: AnalystSpec[] = config.analystModels.map((model, i) => ({ id: `${model}#${i + 1}`, model }))
1216
+
1217
+ const sweScenarios: Scenario[] = config.instances.map((iid) => ({ id: iid, kind: 'swe-instance' }))
1218
+
1219
+ // Capacity gates on BOTH paths the supervisor arm rides (worker + router).
1220
+ // Shared by every arm dispatch — the improvement cells AND the pre-filter
1221
+ // smoke cell. A cell's WORK clock (config.dispatchTimeoutMs) starts only
1222
+ // after these clear — a capacity hold is never billed to the work budget.
1223
+ const awaitGates = async (gateSignal: AbortSignal | undefined = signal): Promise<void> => {
1224
+ for (const gate of gatesForArmKind('supervisor', secrets, {
1225
+ ...(config.gateWaitCeilingMs !== undefined ? { waitCeilingMs: config.gateWaitCeilingMs } : {}),
1226
+ ...(config.capacityModel !== undefined ? { model: config.capacityModel } : {}),
1227
+ onStatus: log,
1228
+ })) {
1229
+ if (!(await waitForCapacity(gate, gateSignal))) throw new Error(`no capacity on ${gate.name} within ceiling`)
1230
+ }
1231
+ }
1232
+
1233
+ // ── the pre-filter smoke runner: ONE supervisor arm cell + official judge
1234
+ // on the smoke instance, run against the proposer's scratch worktree BEFORE
1235
+ // any full-evaluation spend. A crashed smoke KILLS the candidate (recorded
1236
+ // in the kill reason) rather than the round — the pre-filter is allowed to
1237
+ // be strict; a survivor still faces the full gate. ────────────────────
1238
+ const smokeRunner: SmokeRunner | undefined =
1239
+ smokeIid === null
1240
+ ? undefined
1241
+ : async ({ scratchPath, generation, proposer, costLedger }): Promise<SmokeVerdict> => {
1242
+ const iid = smokeIid
1243
+ const requireResolved = config.prefilter?.requireResolved === true
1244
+ const entry = images[iid]!
1245
+ const armOutDir = join(config.outDir, 'prefilter-smoke', `gen${generation}-${proposer.name}`)
1246
+ const nm = join(scratchPath, 'node_modules')
1247
+ let linked = false
1248
+ const t0 = Date.now()
1249
+ try {
1250
+ if (!existsSync(nm)) {
1251
+ await symlink(join(config.loopsRepo, 'node_modules'), nm, 'dir')
1252
+ linked = true
1253
+ }
1254
+ const work = async (signal: AbortSignal): Promise<{ armRes: SupervisorArmResult; resolved: boolean | null }> => {
1255
+ const spec: SupervisorArmSpec = {
1256
+ kind: 'supervisor',
1257
+ name: config.armName,
1258
+ workerModel: config.arm.workerModel,
1259
+ driverModel: config.arm.driverModel,
1260
+ budget: config.arm.budget,
1261
+ maxSandboxes: config.arm.maxSandboxes,
1262
+ maxUsd: config.arm.maxUsd,
1263
+ maxDepth: config.arm.maxDepth,
1264
+ ...(config.arm.envKnobs ? { envKnobs: config.arm.envKnobs } : {}),
1265
+ loopsRepo: scratchPath,
1266
+ extensionPath: join(scratchPath, 'extensions', 'pi', 'loops.ts'),
1267
+ timeoutMs: config.arm.timeoutMs,
1268
+ }
1269
+ log(`>>> prefilter smoke ${proposer.name} ${iid} gen=${generation}`)
1270
+ const armRes = await runSupervisorArm(spec, {
1271
+ instanceId: iid,
1272
+ image: entry.image,
1273
+ baseCommit: entry.base_commit,
1274
+ problemStatement: problemById.get(iid)!,
1275
+ verifyCmd: `bash ${join(config.verifyDir, `${iid}.sh`)}`,
1276
+ outDir: armOutDir,
1277
+ secrets,
1278
+ excludes,
1279
+ signal,
1280
+ })
1281
+ if (signal.aborted) throw signal.reason
1282
+ const verdict = await judge.judge(
1283
+ iid,
1284
+ armRes.patchPath,
1285
+ `prefilter-g${generation}-${proposer.name}`,
1286
+ signal,
1287
+ )
1288
+ return { armRes, resolved: verdict.resolved }
1289
+ }
1290
+ const runWork = (): Promise<{ armRes: SupervisorArmResult; resolved: boolean | null }> =>
1291
+ runWithPostGateClock({
1292
+ awaitGates,
1293
+ work,
1294
+ timeoutMs: config.dispatchTimeoutMs,
1295
+ label: `prefilter smoke ${proposer.name} ${iid}`,
1296
+ signal,
1297
+ })
1298
+ let outcome: { armRes: SupervisorArmResult; resolved: boolean | null }
1299
+ if (costLedger) {
1300
+ // The smoke's real arm spend reaches the run ledger like any cell.
1301
+ const paid = await costLedger.runPaidCall({
1302
+ channel: 'agent',
1303
+ phase: 'search.prefilter',
1304
+ actor: `prefilter-smoke:${iid}:g${generation}:${proposer.name}`,
1305
+ model: config.arm.workerModel,
1306
+ execute: runWork,
1307
+ receipt: ({ armRes }) => {
1308
+ const spend = armRes.recoveredSpend
1309
+ const usageKnown = (spend?.workerTokIn ?? null) !== null || (spend?.workerTokOut ?? null) !== null
1310
+ return {
1311
+ model: config.arm.workerModel,
1312
+ inputTokens: spend?.workerTokIn ?? 0,
1313
+ outputTokens: spend?.workerTokOut ?? 0,
1314
+ ...(usageKnown ? {} : { usageUnknown: true }),
1315
+ ...(armRes.spentUsd !== null ? { actualCostUsd: armRes.spentUsd } : {}),
1316
+ }
1317
+ },
1318
+ })
1319
+ if (!paid.succeeded) throw paid.error
1320
+ outcome = paid.value
1321
+ } else {
1322
+ outcome = await runWork()
1323
+ }
1324
+ const wallS = Math.round((Date.now() - t0) / 1000)
1325
+ const patchDelivered = outcome.armRes.patch_lines > 0
1326
+ const conclusive = outcome.resolved !== null
1327
+ const pass = requireResolved ? outcome.resolved === true : patchDelivered && conclusive
1328
+ const verdictLine =
1329
+ `smoke ${iid}: resolved=${outcome.resolved} patch_lines=${outcome.armRes.patch_lines} ` +
1330
+ `verify_pass=${outcome.armRes.verify_pass} wall_s=${outcome.armRes.wall_s}`
1331
+ const result: SmokeVerdict = {
1332
+ iid,
1333
+ pass,
1334
+ reason: pass
1335
+ ? verdictLine
1336
+ : `${verdictLine} — below the ${requireResolved ? 'resolved' : 'mechanism (patch + conclusive judge)'} bar`,
1337
+ resolved: outcome.resolved,
1338
+ patchLines: outcome.armRes.patch_lines,
1339
+ wallS,
1340
+ }
1341
+ await mkdir(armOutDir, { recursive: true })
1342
+ await writeFile(join(armOutDir, 'smoke.json'), JSON.stringify(result, null, 2))
1343
+ return result
1344
+ } catch (cause) {
1345
+ if (signal?.aborted) throw signal.reason
1346
+ const wallS = Math.round((Date.now() - t0) / 1000)
1347
+ const result: SmokeVerdict = {
1348
+ iid,
1349
+ pass: false,
1350
+ reason: `smoke errored: ${(cause as Error).message}`,
1351
+ resolved: null,
1352
+ patchLines: 0,
1353
+ wallS,
1354
+ }
1355
+ await mkdir(armOutDir, { recursive: true })
1356
+ await writeFile(join(armOutDir, 'smoke.json'), JSON.stringify(result, null, 2)).catch(() => {})
1357
+ return result
1358
+ } finally {
1359
+ if (linked) await unlink(nm).catch(() => {})
1360
+ }
1361
+ }
1362
+
1363
+ // ── dispatch: one (surface × scenario) cell ──────────────────────────
1364
+ const agent = async (surface: MutableSurface, scenario: Scenario, ctx: DispatchContext): Promise<R4Artifact> => {
1365
+ const cs = asCodeSurface(surface)
1366
+ const rec = await recorder.ensure(cs)
1367
+
1368
+ const iid = scenario.id
1369
+ // FAIL-CLOSED change-space enforcement: an out-of-space candidate must
1370
+ // never reach a model token or a docker container. The thrown cell is the
1371
+ // record (the lib stores it with `error` set — no side bookkeeping).
1372
+ if (rec.violations.length > 0) {
1373
+ throw new Error(`change-space violation (${rec.violations.length} path(s)): ${rec.violations.join(', ')}`)
1374
+ }
1375
+
1376
+ const runCell = async (signal: AbortSignal): Promise<R4Artifact> => {
1377
+ const entry = images[iid]!
1378
+ const evalWt = join(config.outDir, 'eval-wt', `${rec.tag}-${iid}-r${ctx.rep}`)
1379
+ const armOutDir = join(config.outDir, 'arm-runs', rec.tag, `rep-${ctx.rep}`)
1380
+ try {
1381
+ await addEvalWorktree(config.loopsRepo, cs.candidateCommit, evalWt, signal)
1382
+ const spec: SupervisorArmSpec = {
1383
+ kind: 'supervisor',
1384
+ name: config.armName,
1385
+ workerModel: config.arm.workerModel,
1386
+ driverModel: config.arm.driverModel,
1387
+ budget: config.arm.budget,
1388
+ maxSandboxes: config.arm.maxSandboxes,
1389
+ maxUsd: config.arm.maxUsd,
1390
+ maxDepth: config.arm.maxDepth,
1391
+ ...(config.arm.envKnobs ? { envKnobs: config.arm.envKnobs } : {}),
1392
+ loopsRepo: evalWt,
1393
+ extensionPath: join(evalWt, 'extensions', 'pi', 'loops.ts'),
1394
+ timeoutMs: config.arm.timeoutMs,
1395
+ }
1396
+ log(`>>> ${config.armName} ${rec.tag} ${iid} rep=${ctx.rep}`)
1397
+ const armRes: SupervisorArmResult = await runSupervisorArm(spec, {
1398
+ instanceId: iid,
1399
+ image: entry.image,
1400
+ baseCommit: entry.base_commit,
1401
+ problemStatement: problemById.get(iid)!,
1402
+ verifyCmd: `bash ${join(config.verifyDir, `${iid}.sh`)}`,
1403
+ outDir: armOutDir,
1404
+ secrets,
1405
+ excludes,
1406
+ signal,
1407
+ })
1408
+ if (signal.aborted) throw signal.reason
1409
+ const runDir = join(armOutDir, 'runs', iid, config.armName)
1410
+ const { ws: _ws, ...armSummary } = armRes
1411
+ await writeFile(join(runDir, 'result.json'), JSON.stringify(armSummary, null, 1))
1412
+
1413
+ // The official judge is a docker test-suite run — real wall time, zero
1414
+ // LLM spend. Its OWN paid call (channel 'judge', $0 actual) keeps the
1415
+ // run's spend tree attributing judge work per cell without inventing a
1416
+ // token cost; the wall lands on the artifact + judge.json.
1417
+ const judgeT0 = Date.now()
1418
+ const judgePaid = await ctx.cost.runPaidCall({
1419
+ channel: 'judge',
1420
+ actor: `official-judge:${iid}#r${ctx.rep}`,
1421
+ model: OFFICIAL_JUDGE_MODEL,
1422
+ execute: () => {
1423
+ if (signal.aborted) throw signal.reason
1424
+ return judge.judge(iid, armRes.patchPath, `${config.armName}-${rec.tag}`, signal)
1425
+ },
1426
+ receipt: () => ({ model: OFFICIAL_JUDGE_MODEL, inputTokens: 0, outputTokens: 0, actualCostUsd: 0 }),
1427
+ })
1428
+ if (!judgePaid.succeeded) throw judgePaid.error
1429
+ const verdict = judgePaid.value
1430
+ const judgeWallS = Math.round((Date.now() - judgeT0) / 1000)
1431
+ await writeFile(join(runDir, 'judge.json'), JSON.stringify({ ...verdict, wallS: judgeWallS }, null, 1))
1432
+ log(`${config.armName} ${rec.tag} ${iid} judged: resolved=${verdict.resolved} (attempts=${verdict.attempts})`)
1433
+
1434
+ const spend = armRes.recoveredSpend
1435
+ const recovered =
1436
+ armRes.spentTokens === null && (spend?.workerTokSqlite ?? null) === null
1437
+ ? null
1438
+ : (armRes.spentTokens ?? 0) + (spend?.workerTokSqlite ?? 0)
1439
+ rec.armProvenance = { repo: armRes.provenance.repo, commit: armRes.provenance.commit }
1440
+ await appendFile(
1441
+ join(config.outDir, 'progress.jsonl'),
1442
+ JSON.stringify({
1443
+ at: new Date().toISOString(),
1444
+ runId,
1445
+ candidate: rec.tag,
1446
+ iid,
1447
+ rep: ctx.rep,
1448
+ runDir,
1449
+ resolved: verdict.resolved,
1450
+ verify_pass: armRes.verify_pass,
1451
+ wall_s: armRes.wall_s,
1452
+ spentTokens: armRes.spentTokens,
1453
+ spentUsd: armRes.spentUsd,
1454
+ recoveredTokens: recovered,
1455
+ }) + '\n',
1456
+ )
1457
+ await ctx.artifacts.writeJson('arm-summary.json', { runDir, patchPath: armRes.patchPath, verdict })
1458
+
1459
+ if (verdict.resolved === null) {
1460
+ // Inconclusive judge (double flake / infra) — the cell must FAIL, not
1461
+ // score a fabricated boolean; the candidate becomes coverage-incomplete.
1462
+ throw new Error(`inconclusive judge verdict for ${iid} (${verdict.error ?? 'unknown'})`)
1463
+ }
1464
+ return {
1465
+ kind: 'swe-arm',
1466
+ iid,
1467
+ commit: cs.candidateCommit,
1468
+ resolved: verdict.resolved,
1469
+ verifyPass: armRes.verify_pass,
1470
+ patchLines: armRes.patch_lines,
1471
+ wallS: armRes.wall_s,
1472
+ spentTokens: armRes.spentTokens,
1473
+ spentUsd: armRes.spentUsd,
1474
+ recoveredTokens: recovered,
1475
+ workerTokIn: spend?.workerTokIn ?? null,
1476
+ workerTokOut: spend?.workerTokOut ?? null,
1477
+ judgeAttempts: verdict.attempts ?? null,
1478
+ judgeWallS,
1479
+ runDir,
1480
+ patchPath: armRes.patchPath,
1481
+ }
1482
+ } finally {
1483
+ await removeEvalWorktree(config.loopsRepo, evalWt)
1484
+ }
1485
+ }
1486
+
1487
+ // The arm's real spend reaches the LIB's CostLedger here: one agent-channel
1488
+ // paid call per cell whose receipt carries the recovered worker-session
1489
+ // token split (opencode sqlite join) and the runtime spend-tree dollars
1490
+ // (state.json spentUsd). run-campaign commits it into cell.costUsd /
1491
+ // cell.tokenUsage + durable cost-ledger.jsonl receipts — the stub/$0
1492
+ // rounds this replaces.
1493
+ const paid = await ctx.cost.runPaidCall<R4Artifact>({
1494
+ actor: `${config.armName}:${rec.tag}:${iid}#r${ctx.rep}`,
1495
+ model: config.arm.workerModel,
1496
+ execute: () =>
1497
+ runWithPostGateClock({
1498
+ awaitGates,
1499
+ work: runCell,
1500
+ timeoutMs: config.dispatchTimeoutMs,
1501
+ label: `${config.armName} ${rec.tag} ${iid} r${ctx.rep}`,
1502
+ signal,
1503
+ }),
1504
+ receipt: (artifact) => {
1505
+ if (artifact.kind !== 'swe-arm') throw new Error('swe cell produced a non-arm artifact')
1506
+ const usageKnown = artifact.workerTokIn !== null || artifact.workerTokOut !== null
1507
+ return {
1508
+ model: config.arm.workerModel,
1509
+ inputTokens: artifact.workerTokIn ?? 0,
1510
+ outputTokens: artifact.workerTokOut ?? 0,
1511
+ ...(usageKnown ? {} : { usageUnknown: true }),
1512
+ // The runtime spend-tree usd is the measured bill; without it the
1513
+ // receipt stays honestly unpriced (costUnknown) rather than $0.
1514
+ ...(artifact.spentUsd !== null ? { actualCostUsd: artifact.spentUsd } : {}),
1515
+ }
1516
+ },
1517
+ })
1518
+ if (!paid.succeeded) throw paid.error
1519
+ return paid.value
1520
+ }
1521
+
1522
+ // ── judge config: a deterministic READ of the official verdict the dispatch
1523
+ // already obtained under the serialized-judge lock. ───────────────────
1524
+ const judgeConfig: JudgeConfig<R4Artifact, Scenario> = {
1525
+ name: 'swe-arena-official-judge',
1526
+ dimensions: [{ key: 'resolved', description: 'official SWE-bench judge verdict' }],
1527
+ score: ({ artifact }) => {
1528
+ const v = artifact.resolved ? 1 : 0
1529
+ return {
1530
+ composite: v,
1531
+ dimensions: { resolved: v },
1532
+ notes: `official judge: ${artifact.iid} resolved=${artifact.resolved} (verify_pass=${artifact.verifyPass}, patch_lines=${artifact.patchLines}, wall_s=${artifact.wallS})`,
1533
+ }
1534
+ },
1535
+ }
1536
+
1537
+ // ── diagnosis at the analyzeGeneration seam ──────────────────────────
1538
+ const rawTrace = rawTraceDistiller<Scenario, R4Artifact>({ fallbackFindings: [] })
1539
+ const steeringFinding = makeFinding({
1540
+ analyst_id: 'round4-protocol',
1541
+ severity: 'high',
1542
+ area: 'constraint',
1543
+ confidence: 1,
1544
+ claim:
1545
+ 'Declared change-space: ONLY extensions/pi/** and src/{worker-evidence,best-effort,worker-clone}.ts may change ' +
1546
+ '(plus the .improve/ diagnosis artifact). Judge, verify scripts, task prompts, model ids and budgets are immutable.',
1547
+ recommended_action: 'Keep every edit inside the change-space; out-of-space candidate diffs are rejected before evaluation.',
1548
+ evidence_refs: [],
1549
+ })
1550
+ const analyzeGeneration = async (input: {
1551
+ generation: number
1552
+ runDir: string
1553
+ candidates: Array<{ surfaceHash: string; composite: number; campaign: unknown }>
1554
+ history: unknown[]
1555
+ }): Promise<unknown[]> => {
1556
+ signal?.throwIfAborted()
1557
+ const runs: SupRunArtifacts[] = []
1558
+ if (input.generation === -1) {
1559
+ for (const seed of config.seedArtifactRuns) {
1560
+ if (!existsSync(seed.dir)) {
1561
+ // A wiped scratchpad (host reboot) must not feed EMPTY bundles to the
1562
+ // analysts as if they were real artifacts — skip loudly.
1563
+ log(`seed artifact dir missing — skipped from diagnosis: ${seed.dir}`)
1564
+ continue
1565
+ }
1566
+ runs.push({
1567
+ iid: seed.iid,
1568
+ arm: seed.arm,
1569
+ dir: seed.dir,
1570
+ ...(seed.patchPath ? { patchPath: seed.patchPath } : {}),
1571
+ judge: { resolved: seed.resolved, note: 'previous round (seeded)' },
1572
+ })
1573
+ }
1574
+ }
1575
+ // Candidate failure artifacts come from the LIB's campaign cells (the
1576
+ // artifacts name their own runDir/patch) — resume-replayed cells included,
1577
+ // which the old recorder-based lookup silently dropped.
1578
+ const worstFirst = [...input.candidates].sort((a, b) => a.composite - b.composite).slice(0, 4)
1579
+ for (const cand of worstFirst) {
1580
+ const cells = cellsFromCampaign(cand.campaign as CampaignResult<R4Artifact, Scenario>)
1581
+ for (const cell of cells) {
1582
+ const a = cell.artifact
1583
+ if (a === null || a.kind !== 'swe-arm' || !a.runDir) continue
1584
+ runs.push({
1585
+ iid: a.iid,
1586
+ arm: config.armName,
1587
+ dir: a.runDir,
1588
+ ...(a.patchPath ? { patchPath: a.patchPath } : {}),
1589
+ judge: { resolved: cell.error ? null : a.resolved },
1590
+ })
1591
+ }
1592
+ }
1593
+ let ensembleFindings: unknown[] = []
1594
+ if (runs.length > 0) {
1595
+ try {
1596
+ const scratch = join(config.outDir, 'diagnosis', `gen-${input.generation}`)
1597
+ const ensemble = await runDiagnosisEnsemble({
1598
+ analysts,
1599
+ runs,
1600
+ secrets,
1601
+ scratchDir: scratch,
1602
+ onStatus: log,
1603
+ signal,
1604
+ })
1605
+ signal?.throwIfAborted()
1606
+ await writeFile(
1607
+ join(config.outDir, 'diagnosis', `gen-${input.generation}.json`),
1608
+ JSON.stringify({ reports: ensemble.reports, fused: ensemble.fused }, null, 2),
1609
+ )
1610
+ ensembleFindings = fusedToAnalystFindings(ensemble.fused, {
1611
+ dirs: [...new Set(runs.map((r) => r.dir))],
1612
+ totalAnalysts: analysts.length,
1613
+ })
1614
+ } catch (cause) {
1615
+ if (signal?.aborted) throw signal.reason
1616
+ // A dead router must not kill the round: the raw-trace context below
1617
+ // still grounds the proposer; the failure is logged, never silent.
1618
+ log(`diagnosis ensemble FAILED for gen ${input.generation}: ${(cause as Error).message}`)
1619
+ }
1620
+ }
1621
+ signal?.throwIfAborted()
1622
+ const rawFindings = (await rawTrace(input as Parameters<typeof rawTrace>[0])) as unknown[]
1623
+ signal?.throwIfAborted()
1624
+ return [steeringFinding, ...ensembleFindings, ...rawFindings]
1625
+ }
1626
+
1627
+ // ── protocol_v2: NEVER ships from inside the loop. `budget.holdout:
1628
+ // 'deferred'` makes the LIB dispatch zero holdout cells, force `hold`, omit
1629
+ // `lift`, and record `holdout: 'deferred'` in the provenance record; the
1630
+ // pre-registered holdout run happens later, with operator approval. The
1631
+ // would-be-KEEP operator brief is computed post-run from campaign cells
1632
+ // (see the summary below). ───────────────────────────────────────────
1633
+ const holdoutReps = config.holdoutRepsPerInstance ?? 2
1634
+ const holdoutInstruction =
1635
+ `holdout (${config.holdoutInstances.length} pre-registered instances: ${config.holdoutInstances.join(', ')}) ` +
1636
+ `was NOT run — operator approval required. To certify a would-be KEEP under the ${holdoutReps}-rep ` +
1637
+ 'fail-closed protocol (same-protocol parent comparison): ' +
1638
+ 'tsx src/swe-arena/holdout-certify.mts <config.json> --candidate <winner-loops-commit>'
1639
+ const improveRunDir = join(config.outDir, 'improve-run')
1640
+
1641
+ // ── generator: the gen-3 proposer fan-out (parallel AgentProfile-pinned
1642
+ // authors + pre-filter) when `proposers` is configured; the legacy
1643
+ // single-author generator otherwise. ─────────────────────────────────
1644
+ const fanout =
1645
+ config.proposers !== undefined
1646
+ ? fanOutLoopsGenerator(config, { ...(smokeRunner ? { smokeRunner } : {}), log })
1647
+ : null
1648
+ const generator = withParentCancellation(fanout ?? constrainedLoopsGenerator(config), signal)
1649
+
1650
+ // ── the improve() call: the optimizer seat ───────────────────────────
1651
+ // Typed from improve()'s own parameter: the monorepo hoists two
1652
+ // agent-interface majors, so a nominal import can resolve to the wrong one.
1653
+ const profile = { name: 'loops-pi-supervisor' } as Parameters<typeof improve>[0]
1654
+ signal?.throwIfAborted()
1655
+ log(`round ${config.round} runId=${runId}: improve(surface:'code') over ${config.loopsRepo}@${config.loopsBaseRef}`)
1656
+ const result = await improve<Scenario, R4Artifact>(profile, [], {
1657
+ surface: 'code',
1658
+ // analyzeGeneration wins over this flag; the composite above embeds
1659
+ // rawTraceDistiller directly so the raw-trace mechanism stays active.
1660
+ rawTraceContext: true,
1661
+ analyzeGeneration,
1662
+ code: {
1663
+ repoRoot: config.loopsRepo,
1664
+ baseRef: config.loopsBaseRef,
1665
+ worktreeDir: join(config.outDir, 'loops-worktrees'),
1666
+ generator,
1667
+ },
1668
+ scenarios: sweScenarios,
1669
+ judge: judgeConfig,
1670
+ agent,
1671
+ budget: {
1672
+ generations: config.generations,
1673
+ populationSize: config.populationSize,
1674
+ maxConcurrency: 1,
1675
+ reps,
1676
+ maxImprovementShots: config.maxShots,
1677
+ // Deferred with no reserved set: ALL improvement-set scenarios train;
1678
+ // the held-out comparison lives in the separate operator-approved run.
1679
+ holdout: 'deferred',
1680
+ },
1681
+ // TRAINING RECORDER: every scored (artifact, judge score) lands in the
1682
+ // lib's labeled-scenario store as a JSONL corpus under outDir (growth is
1683
+ // outDir-scoped; a handful of cells per round). Records carry the default
1684
+ // 'unverified' trust — corpus-grade, NOT gold-eligible, which is right
1685
+ // until an operator-confirmed holdout verdict upgrades them.
1686
+ labeledStore: new FsLabeledScenarioStore({ root: join(config.outDir, 'labeled-store') }),
1687
+ captureSource: 'eval-run',
1688
+ // Arm/judge/proposer spend reaches the campaign meter through real paid
1689
+ // calls (worker receipt per swe cell, $0 judge receipts, imported
1690
+ // proposer-shot receipts). 'warn' not 'assert': the official judge's $0
1691
+ // receipts are correct-by-design and must not kill the round as "stubs".
1692
+ expectUsage: 'warn',
1693
+ // Widened: covers worst-case capacity-gate holds; the REAL per-cell work
1694
+ // clock (config.dispatchTimeoutMs) starts post-gate inside the dispatch.
1695
+ dispatchTimeoutMs: campaignDispatchCeilingMs(config),
1696
+ runDir: improveRunDir,
1697
+ ...(premeasured ? { premeasuredBaseline: premeasured } : {}),
1698
+ })
1699
+ signal?.throwIfAborted()
1700
+
1701
+ // ── staircase rows + round summary — scored from the LIB's campaign cells
1702
+ // (baselineCampaign + per-generation candidate campaigns), which replay
1703
+ // correctly attributed on resume. The recorder only contributes the
1704
+ // dispatch-time diff/change-space description (recomputed post-run via
1705
+ // ensure() for candidates that were replayed, never dispatched here). ────
1706
+ try {
1707
+ const loop = result.raw.raw
1708
+ const baselineCells = cellsFromCampaign(loop.baselineCampaign)
1709
+ const baselineWallS = sumWallSFromCells(baselineCells)
1710
+ const measuredBaselineCount = resolvedInstanceCount(
1711
+ replicateRunsFromCells(baselineCells),
1712
+ config.instances,
1713
+ reps,
1714
+ )
1715
+ const campaignBySurface = new Map<string, CampaignResult<R4Artifact, Scenario>>()
1716
+ for (const gen of loop.generations) {
1717
+ for (const s of gen.surfaces) campaignBySurface.set(s.surfaceHash, s.campaign)
1718
+ }
1719
+ const resolvedCountOf = (campaign: CampaignResult<R4Artifact, Scenario>): number =>
1720
+ resolvedInstanceCount(replicateRunsFromCells(cellsFromCampaign(campaign)), config.instances, reps)
1721
+
1722
+ // BASELINE-DRIFT: a resumed runDir can still hold baseline cells cached by
1723
+ // an OLDER (pre-artifact) run. When they contradict the lib-validated
1724
+ // premeasured artifact, log loud — the artifact rules, never silently.
1725
+ if (premeasured) {
1726
+ const cachedBaseline = await loadCampaignCells(join(improveRunDir, 'baseline'))
1727
+ if (cachedBaseline.length > 0) {
1728
+ const expected = instanceVerdictsFromCells(baselineCells, config.instances, reps)
1729
+ for (const w of baselineDriftWarnings(
1730
+ expected,
1731
+ replicateRunsFromCells(cachedBaseline),
1732
+ config.instances,
1733
+ reps,
1734
+ )) {
1735
+ log(`BASELINE-DRIFT: ${w}`)
1736
+ }
1737
+ }
1738
+ }
1739
+
1740
+ for (let g = 0; g < loop.generations.length; g++) {
1741
+ const gen = loop.generations[g]!
1742
+ const rows: StaircaseRow[] = []
1743
+ for (const cand of gen.record.candidates) {
1744
+ const surface = gen.surfaces.find((s) => s.surfaceHash === cand.surfaceHash)?.surface
1745
+ const cs = surface && typeof surface === 'object' && surface.kind === 'code' ? surface : null
1746
+ const desc = cs ? await recorder.ensure(cs) : undefined
1747
+ const campaign = campaignBySurface.get(cand.surfaceHash)
1748
+ const cells = campaign ? cellsFromCampaign(campaign) : []
1749
+ const runs = replicateRunsFromCells(cells)
1750
+ const perInstance = perInstanceFromCells(cells)
1751
+ const candResolved = resolvedInstanceCount(runs, config.instances, reps)
1752
+ const wallS = sumWallSFromCells(cells)
1753
+ const coverageComplete =
1754
+ cand.eligibleForPromotion === true && replicateCoverageComplete(runs, config.instances, reps)
1755
+ const costRatio = baselineWallS > 0 ? wallS / baselineWallS : null
1756
+ // Parent's AND-resolved count. A parent hash with no candidate
1757
+ // campaign IS the baseline incumbent — its count comes from the
1758
+ // baseline campaign (the lib-validated premeasured artifact, or the
1759
+ // bootstrap run's measurement; both survive resume, no dispatch-order
1760
+ // guess).
1761
+ const parentCampaign = cand.parentSurfaceHash
1762
+ ? campaignBySurface.get(cand.parentSurfaceHash)
1763
+ : undefined
1764
+ const parentResolvedCount =
1765
+ parentCampaign !== undefined ? resolvedCountOf(parentCampaign) : measuredBaselineCount
1766
+ const violations = desc?.violations ?? []
1767
+ rows.push({
1768
+ schema: STAIRCASE_SCHEMA,
1769
+ round: config.round,
1770
+ generation: g,
1771
+ runId,
1772
+ at: new Date().toISOString(),
1773
+ candidate: cand.surfaceHash,
1774
+ candidateCommit: cs?.candidateCommit ?? null,
1775
+ parent: cand.parentSurfaceHash ?? 'baseline',
1776
+ parentResolvedCount,
1777
+ ...(cand.label ? { label: cand.label } : {}),
1778
+ ...(cand.rationale ? { rationale: cand.rationale } : {}),
1779
+ changedFiles: desc?.changedFiles ?? [],
1780
+ changeSpaceViolations: violations,
1781
+ perInstance,
1782
+ resolvedCount: candResolved,
1783
+ coverageComplete,
1784
+ wallS,
1785
+ baselineWallS,
1786
+ costRatio,
1787
+ costGuardRatio: config.costGuardRatio,
1788
+ internallyPromoted: gen.record.promoted.includes(cand.surfaceHash),
1789
+ verdict: decideVerdict({
1790
+ violations,
1791
+ coverageComplete,
1792
+ resolvedCount: candResolved,
1793
+ parentResolvedCount,
1794
+ costRatio,
1795
+ costGuardRatio: config.costGuardRatio,
1796
+ }),
1797
+ holdout: 'operator-approval-required',
1798
+ armProvenance: desc?.armProvenance ?? null,
1799
+ diffPath: desc?.diffPath ?? null,
1800
+ diffSha256: desc?.diffSha256 ?? null,
1801
+ })
1802
+ }
1803
+ const genFile = join(config.roundsDir, `gen-${g}.jsonl`)
1804
+ for (const row of rows) await appendFile(genFile, JSON.stringify(row) + '\n')
1805
+ log(`staircase: ${rows.length} row(s) → ${genFile} (${rows.map((r) => r.verdict).join(', ')})`)
1806
+ }
1807
+
1808
+ // Pre-filter kills: candidates the fan-out killed BEFORE evaluation never
1809
+ // became surfaces (zero arm cells), so the loop has no row for them —
1810
+ // each becomes an explicit `rejected-prefilter` staircase dot with its
1811
+ // kill reason and forensics patch.
1812
+ if (fanout) {
1813
+ const kills = fanout.drainPrefilterKills()
1814
+ for (const kill of kills) {
1815
+ const row: StaircaseRow = {
1816
+ schema: STAIRCASE_SCHEMA,
1817
+ round: config.round,
1818
+ generation: kill.generation,
1819
+ runId,
1820
+ at: new Date().toISOString(),
1821
+ candidate: `prefilter-kill:${kill.diffSha256?.slice('sha256:'.length, 'sha256:'.length + 12) ?? kill.proposer}`,
1822
+ candidateCommit: null,
1823
+ parent: premeasured?.surfaceHash ?? 'baseline',
1824
+ parentResolvedCount: measuredBaselineCount,
1825
+ label: kill.proposer,
1826
+ rationale: `prefilter kill at stage '${kill.stage}' (${kill.harness})`,
1827
+ changedFiles: [],
1828
+ changeSpaceViolations: kill.stage === 'change-space' ? [kill.reason] : [],
1829
+ perInstance: [],
1830
+ resolvedCount: 0,
1831
+ coverageComplete: false,
1832
+ wallS: kill.smoke?.wallS ?? 0,
1833
+ baselineWallS,
1834
+ costRatio: null,
1835
+ costGuardRatio: config.costGuardRatio,
1836
+ internallyPromoted: false,
1837
+ verdict: 'rejected-prefilter',
1838
+ killReason: `${kill.stage}: ${kill.reason}`,
1839
+ holdout: 'operator-approval-required',
1840
+ armProvenance: null,
1841
+ diffPath: kill.patchPath,
1842
+ diffSha256: kill.diffSha256,
1843
+ }
1844
+ const genFile = join(config.roundsDir, `gen-${kill.generation}.jsonl`)
1845
+ await appendFile(genFile, JSON.stringify(row) + '\n')
1846
+ log(`staircase: prefilter kill dot (${kill.proposer}, ${kill.stage}) → ${genFile}`)
1847
+ }
1848
+ }
1849
+
1850
+ const winnerSurface = result.raw.winner.surface
1851
+ const winnerCs =
1852
+ typeof winnerSurface === 'object' && winnerSurface !== null && winnerSurface.kind === 'code'
1853
+ ? winnerSurface
1854
+ : null
1855
+ const winnerRec = winnerCs ? await recorder.ensure(winnerCs) : undefined
1856
+ let winnerPatch: string | null = null
1857
+ if (winnerRec?.diffPath) {
1858
+ winnerPatch = join(config.outDir, 'winner.patch')
1859
+ await writeFile(winnerPatch, await readFile(winnerRec.diffPath, 'utf8'))
1860
+ }
1861
+
1862
+ // BOOTSTRAP: persist this run's measured baseline campaign as the
1863
+ // premeasured artifact every later run consumes (and the lib re-validates
1864
+ // by surface hash / seed / reps / split digest). The baseline surface
1865
+ // hash comes from the Pareto frontier's generation −1 entry — the lib's
1866
+ // own record of the baseline measurement.
1867
+ if (!premeasured) {
1868
+ const baselineHash = loop.paretoFrontier.find((p) => p.generation === -1)?.surfaceHash
1869
+ if (baselineHash === undefined) {
1870
+ log('bootstrap: no generation −1 Pareto entry — premeasured baseline artifact NOT written')
1871
+ } else {
1872
+ const artifact: PremeasuredOptimizationBaseline<R4Artifact, Scenario> = {
1873
+ surfaceHash: baselineHash,
1874
+ campaign: loop.baselineCampaign,
1875
+ }
1876
+ await writeFile(config.premeasuredBaselinePath, JSON.stringify(artifact, null, 1))
1877
+ log(`bootstrap: premeasured baseline artifact → ${config.premeasuredBaselinePath} (surface ${baselineHash})`)
1878
+ }
1879
+ }
1880
+
1881
+ // The would-be-KEEP operator brief: winner vs baseline on the improvement
1882
+ // set, from campaign cells. The lib's deferred-holdout gate always holds;
1883
+ // this evidence tells the operator whether the pre-registered holdout run
1884
+ // is worth approving.
1885
+ const winnerHash = winnerCs ? surfaceHash(winnerCs) : null
1886
+ const winnerCampaign = winnerHash !== null ? campaignBySurface.get(winnerHash) : undefined
1887
+ const improvementSet =
1888
+ winnerCampaign !== undefined && winnerRec !== undefined
1889
+ ? gateEvidenceFromCells({
1890
+ winnerCells: cellsFromCampaign(winnerCampaign),
1891
+ baselineCells,
1892
+ violations: winnerRec.violations,
1893
+ iids: config.instances,
1894
+ reps,
1895
+ costGuardRatio: config.costGuardRatio,
1896
+ })
1897
+ : null
1898
+ const wouldKeep = improvementSet !== null && improvementSet.verdict === 'accepted'
1899
+ if (improvementSet) {
1900
+ log(
1901
+ `improvement set: winner ${improvementSet.candResolved}/${config.instances.length} vs baseline ` +
1902
+ `${improvementSet.baseResolved}/${config.instances.length}; wall ${improvementSet.candWallS}s vs ` +
1903
+ `${improvementSet.baseWallS}s (ratio ${improvementSet.costRatio === null ? 'n/a' : improvementSet.costRatio.toFixed(2)}, ` +
1904
+ `guard ${config.costGuardRatio}); protocol verdict: ${improvementSet.verdict}${wouldKeep ? ' (WOULD-BE KEEP)' : ''}`,
1905
+ )
1906
+ } else {
1907
+ log('improvement set: winner == baseline (no candidate campaign) — nothing to promote')
1908
+ }
1909
+
1910
+ const summary = {
1911
+ schema: 'swe-arena.round-summary.v2',
1912
+ round: config.round,
1913
+ runId,
1914
+ at: new Date().toISOString(),
1915
+ loops: { repo: config.loopsRepo, baseRef: config.loopsBaseRef },
1916
+ // The gate's denominator: the lib-validated premeasured artifact, or
1917
+ // this bootstrap run's freshly measured (and persisted) campaign.
1918
+ baseline: {
1919
+ resolvedCount: measuredBaselineCount,
1920
+ wallS: baselineWallS,
1921
+ perInstance: perInstanceFromCells(baselineCells),
1922
+ premeasured: premeasured !== undefined,
1923
+ artifactPath: config.premeasuredBaselinePath,
1924
+ ...(premeasured ? { surfaceHash: premeasured.surfaceHash } : {}),
1925
+ },
1926
+ winner: winnerCs
1927
+ ? {
1928
+ surfaceHash: winnerHash,
1929
+ commit: winnerCs.candidateCommit,
1930
+ label: result.raw.winner.label ?? null,
1931
+ rationale: result.raw.winner.rationale ?? null,
1932
+ patch: winnerPatch,
1933
+ }
1934
+ : null,
1935
+ // The lib's verdict + reasons: deferred holdout forces `hold` with zero
1936
+ // holdout cells dispatched and no fabricated lift.
1937
+ gateDecision: result.decision,
1938
+ gateReasons: loop.gateResult.reasons,
1939
+ // Improvement-set (search-split) evidence — NOT a held-out measurement.
1940
+ improvementSet: improvementSet === null ? null : { ...improvementSet, wouldKeep },
1941
+ // Honest run-wide spend from the lib's CostLedger: per-channel rollups
1942
+ // (agent = arm cells, judge = official-judge calls, driver = proposer
1943
+ // shots), token totals, and accounting-completeness flags.
1944
+ cost: {
1945
+ totalCostUsd: result.raw.totalCostUsd,
1946
+ inputTokens: result.raw.cost.inputTokens,
1947
+ outputTokens: result.raw.cost.outputTokens,
1948
+ byChannel: result.raw.cost.byChannel,
1949
+ fullyPriced: result.raw.cost.fullyPriced,
1950
+ usageComplete: result.raw.cost.usageComplete,
1951
+ accountingComplete: result.raw.cost.accountingComplete,
1952
+ incompleteReasons: result.raw.cost.incompleteReasons,
1953
+ receipts: result.raw.receipts.length,
1954
+ },
1955
+ holdout: {
1956
+ instances: config.holdoutInstances,
1957
+ mode: 'deferred',
1958
+ status: 'operator-approval-required',
1959
+ // The certification protocol the operator run must use — 2-rep
1960
+ // fail-closed with a same-protocol parent (gen-2 postmortem).
1961
+ protocol: {
1962
+ repsPerInstance: holdoutReps,
1963
+ resolvedRule: 'all-reps',
1964
+ parentBaseline: config.holdoutBaseline ?? 'measure',
1965
+ },
1966
+ instruction: holdoutInstruction,
1967
+ },
1968
+ }
1969
+ const summaryPath = join(config.roundsDir, `round${config.round}-summary-${runId}.json`)
1970
+ await writeFile(summaryPath, JSON.stringify(summary, null, 2))
1971
+ log(`round summary → ${summaryPath}`)
1972
+ log(`gate: ${result.decision} — ${loop.gateResult.reasons[0] ?? ''}`)
1973
+ } finally {
1974
+ await result.dispose()
1975
+ }
1976
+ signal?.throwIfAborted()
1977
+ }
1978
+
1979
+ // ---------------------------------------------------------------------------
1980
+ // Calibration smoke — the ensemble over the REAL round-2 django SUP2 run
1981
+ // (known truth: the worker authored a LOCAL idna helper inside the mail module
1982
+ // while the gold fix adds punycode() in django/utils/encoding.py — a fix
1983
+ // PLACEMENT failure). Cheap (a few k tokens/analyst); grades whether each
1984
+ // blind analyst independently surfaces placement.
1985
+ // ---------------------------------------------------------------------------
1986
+
1987
+ export interface SmokeArgs {
1988
+ supRunDir?: string
1989
+ patchPath?: string
1990
+ analysts?: number
1991
+ model?: string
1992
+ /** 'router' (default) or 'zai' — the z.ai coding endpoint is the proven
1993
+ * fallback when router.tangle.tools 524-storms (a measured infra class). */
1994
+ endpoint?: 'router' | 'zai'
1995
+ /** Transport retries per analyst. Default 4 in the smoke (storms pass). */
1996
+ retries?: number
1997
+ secrets?: SecretsEnv
1998
+ scratchDir?: string
1999
+ }
2000
+
2001
+ export async function calibrationSmoke(args: SmokeArgs = {}): Promise<{
2002
+ perAnalyst: Array<{ analystId: string; ok: boolean; surfacesPlacement: boolean; findings: number; error?: string }>
2003
+ fusedTop: string[]
2004
+ }> {
2005
+ const supRunDir = args.supRunDir ?? join(DEFAULT_HH_SCRATCHPAD, 'runs', 'django__django-11532', 'SUP2')
2006
+ const patchPath = args.patchPath ?? join(DEFAULT_HH_SCRATCHPAD, 'patches', 'django__django-11532.sup2.patch')
2007
+ const secrets: SecretsEnv = args.secrets ?? {
2008
+ secretsDir: '/home/drew/company/devops/secrets',
2009
+ envFiles: ['agent-state.env', 'tangle-router.env'],
2010
+ }
2011
+ const scratchDir = args.scratchDir ?? join(supRunDir, '..', '..', '..', 'r4', 'calibration-smoke')
2012
+ const analysts: AnalystSpec[] = defaultAnalysts(args.analysts ?? 3, args.model ?? 'glm-5.2').map((s) =>
2013
+ args.endpoint === 'zai' ? { ...s, url: ZAI_CODING_ENDPOINT, apiKeyEnv: 'ZAI_API_KEY' } : s,
2014
+ )
2015
+ const runs: SupRunArtifacts[] = [
2016
+ {
2017
+ iid: 'django__django-11532',
2018
+ arm: 'SUP2',
2019
+ dir: supRunDir,
2020
+ ...(existsSync(patchPath) ? { patchPath } : {}),
2021
+ judge: { resolved: false, note: 'round-2 official judge: unresolved while the self-verify passed' },
2022
+ },
2023
+ ]
2024
+ const ensemble = await runDiagnosisEnsemble({
2025
+ analysts,
2026
+ runs,
2027
+ secrets,
2028
+ scratchDir,
2029
+ retriesPerAnalyst: args.retries ?? 4,
2030
+ retryDelayMs: 15_000,
2031
+ onStatus: log,
2032
+ })
2033
+ const placement = surfacesPlacementRegex()
2034
+ const perAnalyst = ensemble.reports.map((r) => ({
2035
+ analystId: r.analystId,
2036
+ ok: r.ok,
2037
+ surfacesPlacement: r.findings.some((f) =>
2038
+ placement.test(`${f.failure_class} ${f.evidence_quote} ${f.proposed_direction}`),
2039
+ ),
2040
+ findings: r.findings.length,
2041
+ ...(r.error ? { error: r.error } : {}),
2042
+ }))
2043
+ console.log('\n=== CALIBRATION SMOKE (django__django-11532 SUP2, truth = fix placement) ===')
2044
+ console.log(`bundle: ${ensemble.bundleChars} chars; analysts: ${analysts.map((a) => a.model).join(', ')}`)
2045
+ for (const r of ensemble.reports) {
2046
+ const grade = perAnalyst.find((p) => p.analystId === r.analystId)!
2047
+ console.log(`\n--- ${r.analystId} ok=${r.ok} placement-surfaced=${grade.surfacesPlacement}${r.error ? ` error=${r.error}` : ''}` +
2048
+ (r.tokens ? ` tokens(in=${r.tokens.input},out=${r.tokens.output})` : ''))
2049
+ for (const f of r.findings) {
2050
+ console.log(` [${f.confidence.toFixed(2)}] ${f.failure_class} → ${f.proposed_direction.slice(0, 160)}`)
2051
+ if (f.evidence_quote) console.log(` evidence: ${f.evidence_quote.slice(0, 160)}`)
2052
+ }
2053
+ }
2054
+ console.log('\n--- fused (agreement-ranked) ---')
2055
+ for (const f of ensemble.fused) {
2056
+ console.log(
2057
+ ` agreement=${f.agreement}${f.competingHypothesis ? ' [competing hypothesis]' : ''} conf=${f.meanConfidence.toFixed(2)} — ${f.failure_class}`,
2058
+ )
2059
+ }
2060
+ const outPath = join(scratchDir, 'calibration-smoke.json')
2061
+ await mkdir(scratchDir, { recursive: true })
2062
+ await writeFile(outPath, JSON.stringify({ perAnalyst, ensemble }, null, 2))
2063
+ console.log(`\nfull output → ${outPath}`)
2064
+ return { perAnalyst, fusedTop: ensemble.fused.map((f) => f.failure_class) }
2065
+ }
2066
+
2067
+ // ---------------------------------------------------------------------------
2068
+ // CLI.
2069
+ // ---------------------------------------------------------------------------
2070
+
2071
+ const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href
2072
+
2073
+ if (isMain) {
2074
+ const argv = process.argv.slice(2)
2075
+ // Config execution owns long-lived workers and therefore needs cooperative
2076
+ // cleanup. Utility modes keep the terminal's default signal behavior because
2077
+ // their analyst APIs do not yet accept AbortSignal; installing a handler there
2078
+ // would swallow Ctrl-C while the model call continued.
2079
+ const interrupt = argv[0] && !argv[0].startsWith('--')
2080
+ ? installProcessSignalAbort('outer-loop')
2081
+ : undefined
2082
+ try {
2083
+ const flag = (name: string): string | undefined => {
2084
+ const i = argv.indexOf(name)
2085
+ return i !== -1 ? argv[i + 1] : undefined
2086
+ }
2087
+ if (argv[0] === '--write-config') {
2088
+ const path = argv[1]
2089
+ if (!path || path.startsWith('--')) {
2090
+ console.error('usage: outer-loop.mts --write-config <path> [--out-name <dirname>] [--gen3]')
2091
+ process.exit(2)
2092
+ }
2093
+ const outDirName = flag('--out-name')
2094
+ const gen3 = argv.includes('--gen3')
2095
+ const make = gen3 ? defaultGen3Config : defaultRound4Config
2096
+ await writeFile(
2097
+ path,
2098
+ JSON.stringify(make(undefined, outDirName ? { outDirName } : {}), null, 2) + '\n',
2099
+ )
2100
+ console.log(`default ${gen3 ? 'gen-3' : 'round-4'} config → ${path}`)
2101
+ } else if (argv[0] === '--calibration-smoke') {
2102
+ const dir = argv[1] && !argv[1].startsWith('--') ? argv[1] : undefined
2103
+ const n = flag('--analysts')
2104
+ const model = flag('--model')
2105
+ const endpoint = flag('--endpoint')
2106
+ const retries = flag('--retries')
2107
+ if (endpoint !== undefined && endpoint !== 'router' && endpoint !== 'zai') {
2108
+ console.error(`--endpoint must be 'router' or 'zai', got ${JSON.stringify(endpoint)}`)
2109
+ process.exit(2)
2110
+ }
2111
+ await calibrationSmoke({
2112
+ ...(dir ? { supRunDir: dir } : {}),
2113
+ ...(n ? { analysts: Number(n) } : {}),
2114
+ ...(model ? { model } : {}),
2115
+ ...(endpoint ? { endpoint } : {}),
2116
+ ...(retries ? { retries: Number(retries) } : {}),
2117
+ })
2118
+ } else if (argv[0] && !argv[0].startsWith('--')) {
2119
+ const config = JSON.parse(await readFile(argv[0], 'utf8')) as OuterLoopConfig
2120
+ // Launch guards BEFORE any spend: keys present (dotenvx forgotten = hours
2121
+ // of confusing downstream failures) and exactly one loop per outDir.
2122
+ assertLaunchEnv()
2123
+ interrupt!.signal.throwIfAborted()
2124
+ const lock = await acquireInstanceLock(config.outDir)
2125
+ try {
2126
+ await runRound(config, interrupt!.signal)
2127
+ } finally {
2128
+ await lock.release()
2129
+ }
2130
+ } else {
2131
+ console.error(
2132
+ 'usage: tsx src/swe-arena/outer-loop.mts <config.json> # SPENDS: arms + judges + proposer\n' +
2133
+ ' tsx src/swe-arena/outer-loop.mts --write-config <path> [--out-name <dirname>] [--gen3]\n' +
2134
+ ' tsx src/swe-arena/outer-loop.mts --calibration-smoke [supRunDir] [--analysts N] [--model M] [--endpoint router|zai] [--retries N]',
2135
+ )
2136
+ process.exit(2)
2137
+ }
2138
+ } catch (cause) {
2139
+ if (!interrupt?.signal.aborted) throw cause
2140
+ const detail = cause instanceof Error ? cause.message : String(cause)
2141
+ console.error(`outer-loop stopped after cleanup: ${detail}`)
2142
+ } finally {
2143
+ interrupt?.dispose()
2144
+ }
2145
+ }