@tangle-network/agent-bench 0.3.6 → 0.3.8

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 (265) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/HARNESS.md +43 -0
  3. package/dist/adapters.js +24 -24
  4. package/dist/benchmarks/_harness.d.ts +1 -1
  5. package/dist/benchmarks/_harness.js +1 -1
  6. package/dist/benchmarks/aec-bench.js +2 -2
  7. package/dist/benchmarks/agentbench.js +2 -2
  8. package/dist/benchmarks/appworld.js +2 -2
  9. package/dist/benchmarks/bfcl.js +2 -2
  10. package/dist/benchmarks/commit0.js +2 -2
  11. package/dist/benchmarks/crag.js +2 -2
  12. package/dist/benchmarks/dabstep.js +2 -2
  13. package/dist/benchmarks/enterpriseops-gym.js +2 -2
  14. package/dist/benchmarks/finresearchbench.js +2 -2
  15. package/dist/benchmarks/humaneval.d.ts +10 -1
  16. package/dist/benchmarks/humaneval.js +5 -3
  17. package/dist/benchmarks/nomiracl.js +2 -2
  18. package/dist/benchmarks/open-rag-bench.js +2 -2
  19. package/dist/benchmarks/programbench.js +2 -2
  20. package/dist/benchmarks/ragbench.js +2 -2
  21. package/dist/benchmarks/swe-bench.js +2 -2
  22. package/dist/benchmarks/t2-ragbench.js +2 -2
  23. package/dist/benchmarks/tau-bench-shared.js +2 -2
  24. package/dist/benchmarks/tau2-bench.js +3 -3
  25. package/dist/benchmarks/tau3-banking.js +3 -3
  26. package/dist/benchmarks/terminal-bench.js +2 -2
  27. package/dist/benchmarks/toollm.js +2 -2
  28. package/dist/benchmarks/webarena-verified.js +2 -2
  29. package/dist/{chunk-CKUVRZ2T.js → chunk-3U5TXJZS.js} +2 -2
  30. package/dist/{chunk-PPYSEKFM.js → chunk-5H5XV76F.js} +73 -15
  31. package/dist/chunk-5H5XV76F.js.map +1 -0
  32. package/dist/{chunk-YCGY7UIZ.js → chunk-7GRVHU22.js} +2 -2
  33. package/dist/{chunk-Z7ML6L77.js → chunk-HWST3SED.js} +2 -2
  34. package/dist/{chunk-SYDW647C.js → chunk-IA2FBTWC.js} +2 -2
  35. package/dist/{chunk-R67DFVLO.js → chunk-IFVINJ4B.js} +2 -2
  36. package/dist/{chunk-R36V2VP7.js → chunk-IZ5M6OAC.js} +2 -2
  37. package/dist/{chunk-ODT47UAY.js → chunk-K3BQGZCT.js} +2 -2
  38. package/dist/{chunk-IFAV6KEM.js → chunk-KP5KD6EN.js} +2 -2
  39. package/dist/{chunk-ZEWMTR5M.js → chunk-MQMRLGOG.js} +2 -2
  40. package/dist/{chunk-TSWPNOYM.js → chunk-NQG5XDSB.js} +2 -2
  41. package/dist/{chunk-7WSD27QQ.js → chunk-PB64GYIG.js} +2 -2
  42. package/dist/{chunk-HBSWHQNJ.js → chunk-RCYQEFNX.js} +3 -3
  43. package/dist/{chunk-J3KDJNX2.js → chunk-RH5F53JT.js} +2 -2
  44. package/dist/{chunk-UAIOHCUK.js → chunk-SFLA7OH3.js} +3 -3
  45. package/dist/{chunk-Y6O2OCUO.js → chunk-SHM6MRRF.js} +2 -2
  46. package/dist/{chunk-KDIKRJGB.js → chunk-SHYIRB7I.js} +2 -2
  47. package/dist/{chunk-HHXFIHXC.js → chunk-SVR2LKYI.js} +2 -2
  48. package/dist/{chunk-5SBJCB6W.js → chunk-V7AEBY6U.js} +22 -22
  49. package/dist/{chunk-LRRD7NAG.js → chunk-WSKWVEQB.js} +18 -2
  50. package/dist/chunk-WSKWVEQB.js.map +1 -0
  51. package/dist/{chunk-2PVVP7GN.js → chunk-XKEFIFIC.js} +2 -2
  52. package/dist/{chunk-JRWWGMK7.js → chunk-XYA4XSNU.js} +2 -2
  53. package/dist/{chunk-X5YKXC6V.js → chunk-YSMEKBTD.js} +2 -2
  54. package/dist/{chunk-2XU6OGEN.js → chunk-Z4TZ76N7.js} +2 -2
  55. package/dist/index.js +24 -24
  56. package/package.json +6 -5
  57. package/scripts/run-package-tests.mjs +30 -8
  58. package/scripts/verify-packed-consumer.mjs +1 -1
  59. package/scripts/verify-pier-agent.mts +1 -0
  60. package/src/benchmarks/_harness.ts +20 -2
  61. package/src/benchmarks/humaneval.test.mts +122 -0
  62. package/src/benchmarks/humaneval.ts +100 -27
  63. package/src/david-attribution.mts +78 -0
  64. package/src/david-goliath.mts +149 -0
  65. package/src/hev-improve.mts +25 -6
  66. package/src/humaneval-object-ablation.mts +201 -0
  67. package/src/live-improve-campaign-mbpp.mts +641 -0
  68. package/src/live-improve-campaign.mts +500 -0
  69. package/src/mbpp-structural.mts +12 -7
  70. package/src/quant-arena/README.md +144 -0
  71. package/src/quant-arena/backtest.test.mts +135 -0
  72. package/src/quant-arena/backtest.ts +218 -0
  73. package/src/quant-arena/data.test.mts +44 -0
  74. package/src/quant-arena/data.ts +141 -0
  75. package/src/quant-arena/driver.test.mts +253 -0
  76. package/src/quant-arena/driver.ts +219 -0
  77. package/src/quant-arena/fixtures/data/PROVENANCE.md +26 -0
  78. package/src/quant-arena/fixtures/data/holdout/IDX.csv +523 -0
  79. package/src/quant-arena/fixtures/data/holdout/S01.csv +523 -0
  80. package/src/quant-arena/fixtures/data/holdout/S02.csv +523 -0
  81. package/src/quant-arena/fixtures/data/holdout/S03.csv +523 -0
  82. package/src/quant-arena/fixtures/data/holdout/S04.csv +523 -0
  83. package/src/quant-arena/fixtures/data/holdout/S05.csv +523 -0
  84. package/src/quant-arena/fixtures/data/holdout/S06.csv +523 -0
  85. package/src/quant-arena/fixtures/data/holdout/S07.csv +523 -0
  86. package/src/quant-arena/fixtures/data/holdout/S08.csv +523 -0
  87. package/src/quant-arena/fixtures/data/holdout/S09.csv +523 -0
  88. package/src/quant-arena/fixtures/data/holdout/S10.csv +523 -0
  89. package/src/quant-arena/fixtures/data/insample/IDX.csv +2087 -0
  90. package/src/quant-arena/fixtures/data/insample/S01.csv +2087 -0
  91. package/src/quant-arena/fixtures/data/insample/S02.csv +2087 -0
  92. package/src/quant-arena/fixtures/data/insample/S03.csv +2087 -0
  93. package/src/quant-arena/fixtures/data/insample/S04.csv +2087 -0
  94. package/src/quant-arena/fixtures/data/insample/S05.csv +2087 -0
  95. package/src/quant-arena/fixtures/data/insample/S06.csv +2087 -0
  96. package/src/quant-arena/fixtures/data/insample/S07.csv +2087 -0
  97. package/src/quant-arena/fixtures/data/insample/S08.csv +2087 -0
  98. package/src/quant-arena/fixtures/data/insample/S09.csv +2087 -0
  99. package/src/quant-arena/fixtures/data/insample/S10.csv +2087 -0
  100. package/src/quant-arena/fixtures/demo-campaign/cost-ledger.jsonl +16 -0
  101. package/src/quant-arena/fixtures/demo-campaign/notebook.jsonl +5 -0
  102. package/src/quant-arena/fixtures/demo-campaign/rollout-manifest.json +171 -0
  103. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-001-default-author/strategy.ts +119 -0
  104. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-002-default-author/strategy.ts +119 -0
  105. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-003-quant-researcher/strategy.ts +105 -0
  106. package/src/quant-arena/fixtures/demo-campaign/strategies/cand-004-quant-researcher/strategy.ts +102 -0
  107. package/src/quant-arena/fixtures/demo-campaign-v2/cost-ledger.jsonl +4 -0
  108. package/src/quant-arena/fixtures/demo-campaign-v2/notebook.jsonl +2 -0
  109. package/src/quant-arena/fixtures/demo-campaign-v2/rollout-manifest.json +84 -0
  110. package/src/quant-arena/fixtures/demo-campaign-v2/strategies/cand-001-quant-researcher/strategy.ts +117 -0
  111. package/src/quant-arena/holdout-certify.mts +206 -0
  112. package/src/quant-arena/holdout-certify.test.mts +82 -0
  113. package/src/quant-arena/leak-audit.test.mts +79 -0
  114. package/src/quant-arena/leak-audit.ts +95 -0
  115. package/src/quant-arena/make-fixtures.mts +161 -0
  116. package/src/quant-arena/multiplicity.test.mts +68 -0
  117. package/src/quant-arena/multiplicity.ts +87 -0
  118. package/src/quant-arena/nautilus-certify.ts +31 -0
  119. package/src/quant-arena/oms.ts +90 -0
  120. package/src/quant-arena/profiles/quant-researcher.profile.json +7 -0
  121. package/src/quant-arena/python/pyproject.toml +8 -0
  122. package/src/quant-arena/python/uv.lock +1297 -0
  123. package/src/quant-arena/python/vbt-worker.py +192 -0
  124. package/src/quant-arena/quant-loop.mts +813 -0
  125. package/src/quant-arena/quant-loop.test.mts +75 -0
  126. package/src/quant-arena/strategies/buy-hold-index/strategy.ts +11 -0
  127. package/src/quant-arena/strategies/equal-weight/strategy.ts +20 -0
  128. package/src/quant-arena/strategies/sma-crossover/strategy.ts +42 -0
  129. package/src/quant-arena/types.ts +133 -0
  130. package/src/quant-arena/vbt-client.ts +321 -0
  131. package/src/quant-arena/vbt-parity.test.mts +183 -0
  132. package/src/quant-arena/windows.test.mts +45 -0
  133. package/src/quant-arena/windows.ts +54 -0
  134. package/src/rollout-ledger/backfill-swe-arena.mts +606 -0
  135. package/src/rollout-ledger/backfill-swe-arena.test.mts +338 -0
  136. package/src/rollout-ledger/settle-capture.mts +442 -0
  137. package/src/rollout-ledger/settle-capture.test.mts +270 -0
  138. package/src/stream-observe.py +45 -0
  139. package/src/stream-observe.tpl.html +247 -0
  140. package/src/supervisor-arena.mts +816 -0
  141. package/src/swe-arena/activation.mts +228 -0
  142. package/src/swe-arena/activation.test.mts +303 -0
  143. package/src/swe-arena/analyze.ts +211 -0
  144. package/src/swe-arena/arms.ts +804 -0
  145. package/src/swe-arena/bootstrap-meta.mts +188 -0
  146. package/src/swe-arena/bootstrap-meta.test.mts +51 -0
  147. package/src/swe-arena/briefing.mts +217 -0
  148. package/src/swe-arena/briefing.test.mts +178 -0
  149. package/src/swe-arena/calibrate.ts +217 -0
  150. package/src/swe-arena/capabilities.mts +76 -0
  151. package/src/swe-arena/capabilities.test.mts +57 -0
  152. package/src/swe-arena/capacity.ts +194 -0
  153. package/src/swe-arena/cell-evidence.mts +437 -0
  154. package/src/swe-arena/cell-evidence.test.mts +248 -0
  155. package/src/swe-arena/diagnosis-ensemble.test.mts +210 -0
  156. package/src/swe-arena/diagnosis-ensemble.ts +520 -0
  157. package/src/swe-arena/execution.test.mts +1170 -0
  158. package/src/swe-arena/factory-command-container.ts +284 -0
  159. package/src/swe-arena/factory-judge-child.mts +228 -0
  160. package/src/swe-arena/factory.test.mts +643 -0
  161. package/src/swe-arena/fixtures/analyze.py +80 -0
  162. package/src/swe-arena/fixtures/excludes.txt +8 -0
  163. package/src/swe-arena/fixtures/factory/agent-eval-309/calibration.md +51 -0
  164. package/src/swe-arena/fixtures/factory/agent-eval-309/manifest.json +29 -0
  165. package/src/swe-arena/fixtures/factory/agent-eval-309/spec.md +64 -0
  166. package/src/swe-arena/fixtures/factory/agent-runtime-232/calibration.md +48 -0
  167. package/src/swe-arena/fixtures/factory/agent-runtime-232/manifest.json +29 -0
  168. package/src/swe-arena/fixtures/factory/agent-runtime-232/spec.md +48 -0
  169. package/src/swe-arena/fixtures/factory/loops-28/calibration.md +47 -0
  170. package/src/swe-arena/fixtures/factory/loops-28/manifest.json +30 -0
  171. package/src/swe-arena/fixtures/factory/loops-28/spec.md +50 -0
  172. package/src/swe-arena/fixtures/gen1-salvage/README.md +45 -0
  173. package/src/swe-arena/fixtures/gen1-salvage/cand0-e6d7361.diff +116 -0
  174. package/src/swe-arena/fixtures/gen1-salvage/cand1-76a8590.diff +293 -0
  175. package/src/swe-arena/fixtures/holdout-preregister.log +12 -0
  176. package/src/swe-arena/fixtures/holdout.json +44 -0
  177. package/src/swe-arena/fixtures/instances.json +146 -0
  178. package/src/swe-arena/fixtures/ledger.jsonl +12 -0
  179. package/src/swe-arena/fixtures/patches/pallets__flask-5014.solo.patch +36 -0
  180. package/src/swe-arena/fixtures/patches/pydata__xarray-4687.sup.patch +33 -0
  181. package/src/swe-arena/fixtures/rejudge.jsonl +15 -0
  182. package/src/swe-arena/fixtures/rematch.jsonl +3 -0
  183. package/src/swe-arena/fixtures/rematch2.jsonl +3 -0
  184. package/src/swe-arena/fixtures/rematch3.jsonl +3 -0
  185. package/src/swe-arena/fixtures/run-report/README.md +43 -0
  186. package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.json +173 -0
  187. package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.md +100 -0
  188. package/src/swe-arena/fixtures/run-report/gen3-rollup.json +551 -0
  189. package/src/swe-arena/fixtures/run-report/gen3-rollup.md +64 -0
  190. package/src/swe-arena/fixtures/sup-journal-true.json +19 -0
  191. package/src/swe-arena/fixtures/verify/astropy__astropy-13033.sh +48 -0
  192. package/src/swe-arena/fixtures/verify/django__django-11532.sh +50 -0
  193. package/src/swe-arena/fixtures/verify/matplotlib__matplotlib-20826.sh +76 -0
  194. package/src/swe-arena/fixtures/verify/pydata__xarray-4687.sh +44 -0
  195. package/src/swe-arena/fixtures/verify/pytest-dev__pytest-6197.sh +32 -0
  196. package/src/swe-arena/fixtures/verify/sphinx-doc__sphinx-9658.sh +51 -0
  197. package/src/swe-arena/fixtures/worker-tokens.json +42 -0
  198. package/src/swe-arena/fixtures.ts +237 -0
  199. package/src/swe-arena/gepa-seat.mts +583 -0
  200. package/src/swe-arena/gepa-seat.test.mts +635 -0
  201. package/src/swe-arena/holdout-certify.mts +408 -0
  202. package/src/swe-arena/holdout-certify.test.mts +160 -0
  203. package/src/swe-arena/judge-child.mts +37 -0
  204. package/src/swe-arena/ledger-orphans.mts +77 -0
  205. package/src/swe-arena/ledger-orphans.test.mts +147 -0
  206. package/src/swe-arena/lineage-record.mts +164 -0
  207. package/src/swe-arena/lineage-record.test.mts +115 -0
  208. package/src/swe-arena/manifest.mts +293 -0
  209. package/src/swe-arena/manifest.test.mts +169 -0
  210. package/src/swe-arena/materialize.ts +142 -0
  211. package/src/swe-arena/outer-loop.mts +2795 -0
  212. package/src/swe-arena/outer-loop.test.mts +696 -0
  213. package/src/swe-arena/parity.test.mts +87 -0
  214. package/src/swe-arena/premeasured-from-cells.mts +281 -0
  215. package/src/swe-arena/premeasured-from-cells.test.mts +180 -0
  216. package/src/swe-arena/proc.test.mts +174 -0
  217. package/src/swe-arena/proc.ts +260 -0
  218. package/src/swe-arena/profiles/default-author.profile.json +4 -0
  219. package/src/swe-arena/proposer-fanout.mts +770 -0
  220. package/src/swe-arena/proposer-fanout.test.mts +619 -0
  221. package/src/swe-arena/proposer-provenance.mts +177 -0
  222. package/src/swe-arena/proposer-provenance.test.mts +106 -0
  223. package/src/swe-arena/reconcile.ts +0 -0
  224. package/src/swe-arena/replay.mts +183 -0
  225. package/src/swe-arena/replay.test.mts +300 -0
  226. package/src/swe-arena/run-experiment.mts +727 -0
  227. package/src/swe-arena/run-report.mts +75 -0
  228. package/src/swe-arena/run-supervisor.mjs +297 -0
  229. package/src/swe-arena/run-supervisor.test.mts +500 -0
  230. package/src/swe-arena/score-split.mts +140 -0
  231. package/src/swe-arena/score-split.test.mts +123 -0
  232. package/src/swe-arena/serialized-judge.ts +414 -0
  233. package/src/swe-arena/types.ts +218 -0
  234. package/src/swe-code-improve.mts +328 -0
  235. package/src/swe-emit-patch.mts +104 -0
  236. package/src/swe-improve.mts +232 -0
  237. package/src/swe-jail.ts +2 -2
  238. package/src/swe-local-proof.mts +169 -0
  239. package/src/swe-repro-calibrate.mts +446 -0
  240. package/src/swe-stream.mts +1497 -0
  241. package/src/swe-structural.mts +245 -837
  242. package/dist/chunk-LRRD7NAG.js.map +0 -1
  243. package/dist/chunk-PPYSEKFM.js.map +0 -1
  244. /package/dist/{chunk-CKUVRZ2T.js.map → chunk-3U5TXJZS.js.map} +0 -0
  245. /package/dist/{chunk-YCGY7UIZ.js.map → chunk-7GRVHU22.js.map} +0 -0
  246. /package/dist/{chunk-Z7ML6L77.js.map → chunk-HWST3SED.js.map} +0 -0
  247. /package/dist/{chunk-SYDW647C.js.map → chunk-IA2FBTWC.js.map} +0 -0
  248. /package/dist/{chunk-R67DFVLO.js.map → chunk-IFVINJ4B.js.map} +0 -0
  249. /package/dist/{chunk-R36V2VP7.js.map → chunk-IZ5M6OAC.js.map} +0 -0
  250. /package/dist/{chunk-ODT47UAY.js.map → chunk-K3BQGZCT.js.map} +0 -0
  251. /package/dist/{chunk-IFAV6KEM.js.map → chunk-KP5KD6EN.js.map} +0 -0
  252. /package/dist/{chunk-ZEWMTR5M.js.map → chunk-MQMRLGOG.js.map} +0 -0
  253. /package/dist/{chunk-TSWPNOYM.js.map → chunk-NQG5XDSB.js.map} +0 -0
  254. /package/dist/{chunk-7WSD27QQ.js.map → chunk-PB64GYIG.js.map} +0 -0
  255. /package/dist/{chunk-HBSWHQNJ.js.map → chunk-RCYQEFNX.js.map} +0 -0
  256. /package/dist/{chunk-J3KDJNX2.js.map → chunk-RH5F53JT.js.map} +0 -0
  257. /package/dist/{chunk-UAIOHCUK.js.map → chunk-SFLA7OH3.js.map} +0 -0
  258. /package/dist/{chunk-Y6O2OCUO.js.map → chunk-SHM6MRRF.js.map} +0 -0
  259. /package/dist/{chunk-KDIKRJGB.js.map → chunk-SHYIRB7I.js.map} +0 -0
  260. /package/dist/{chunk-HHXFIHXC.js.map → chunk-SVR2LKYI.js.map} +0 -0
  261. /package/dist/{chunk-5SBJCB6W.js.map → chunk-V7AEBY6U.js.map} +0 -0
  262. /package/dist/{chunk-2PVVP7GN.js.map → chunk-XKEFIFIC.js.map} +0 -0
  263. /package/dist/{chunk-JRWWGMK7.js.map → chunk-XYA4XSNU.js.map} +0 -0
  264. /package/dist/{chunk-X5YKXC6V.js.map → chunk-YSMEKBTD.js.map} +0 -0
  265. /package/dist/{chunk-2XU6OGEN.js.map → chunk-Z4TZ76N7.js.map} +0 -0
@@ -0,0 +1,804 @@
1
+ /**
2
+ * ArmSpec execution — the typed port of the experiment's `solo.sh` and
3
+ * `sup4.sh`, plus the cost-recovery pipeline (`true_sup_spend.py` + the
4
+ * opencode sqlite join that produced worker-tokens.json).
5
+ *
6
+ * Secrets discipline: every model-touching child runs under
7
+ * `dotenvx run -f <env files> --` with cwd = the secrets dir, resolved at CALL
8
+ * time. This module handles env NAMES and file names only — never key values;
9
+ * keys are expanded inside the dotenvx child, not in this process.
10
+ *
11
+ * The supervisor arm WRAPS the real loops driver: the tracked
12
+ * `run-supervisor.mjs` shim registers the loops pi extension's actual tools
13
+ * and invokes `spawn_supervisor` — no supervisor logic is reimplemented here.
14
+ */
15
+
16
+ import { chmod, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
17
+ import { join, resolve } from 'node:path'
18
+ import { homedir } from 'node:os'
19
+ import { fileURLToPath } from 'node:url'
20
+ import { run, runOk, type RunResult, shq } from './proc'
21
+ import { materializeWorkspace } from './materialize'
22
+ import type { ArmSpec, SoloUsage } from './types'
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Shared pieces: excludes, prompt suffix, patch extraction, dotenvx spawning.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const fixturesDir = fileURLToPath(new URL('./fixtures', import.meta.url))
29
+ /** The tracked driver shim (was untracked in the experiment scratchpad). */
30
+ export const runSupervisorShim = fileURLToPath(new URL('./run-supervisor.mjs', import.meta.url))
31
+
32
+ /**
33
+ * The exact worker prompt suffix from solo.sh — the SOLO arm gets the same
34
+ * closing instruction the supervisor's workers get, so prompt content is
35
+ * never a confound between arms.
36
+ */
37
+ export const WORKER_PROMPT_SUFFIX =
38
+ '\n\nWork only in your current working directory. Implement the change completely and correctly — do not stub, weaken, or special-case anything, and keep the repository\'s existing tests passing. An automated check verifies your work after you finish.'
39
+
40
+ /** Test-file exclude pathspecs (fixtures/excludes.txt, vendored from hh/excludes.txt). */
41
+ export async function loadExcludes(): Promise<string[]> {
42
+ const raw = await readFile(join(fixturesDir, 'excludes.txt'), 'utf8')
43
+ const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean)
44
+ if (lines.length === 0) throw new Error('fixtures/excludes.txt is empty')
45
+ return lines
46
+ }
47
+
48
+ const GIT_COMMAND_TIMEOUT_MS = 2 * 60_000
49
+
50
+ async function runArenaGitOk(argv: string[], signal?: AbortSignal): Promise<Awaited<ReturnType<typeof runOk>>> {
51
+ signal?.throwIfAborted()
52
+ const result = await runOk('git', argv, { signal, timeoutMs: GIT_COMMAND_TIMEOUT_MS })
53
+ signal?.throwIfAborted()
54
+ return result
55
+ }
56
+
57
+ /**
58
+ * Patch extraction — identical git invocation to solo.sh/sup4.sh: stage
59
+ * everything, diff the index against base_commit, excluding test files and the
60
+ * supervisor's own .loops state.
61
+ */
62
+ export async function extractPatch(
63
+ ws: string,
64
+ baseCommit: string,
65
+ excludes: string[],
66
+ signal?: AbortSignal,
67
+ ): Promise<string> {
68
+ await runArenaGitOk(['-C', ws, 'add', '-A'], signal)
69
+ const res = await runArenaGitOk([
70
+ '-C', ws,
71
+ 'diff', '--cached', baseCommit,
72
+ '--', '.',
73
+ ...excludes,
74
+ ':(exclude,glob).loops/**',
75
+ ':(exclude,glob)**/.loops/**',
76
+ ], signal)
77
+ return res.stdout
78
+ }
79
+
80
+ export interface SecretsEnv {
81
+ /** Directory dotenvx runs from (e.g. /home/drew/company/devops/secrets). */
82
+ secretsDir: string
83
+ /** Env file names passed as `-f` flags, resolved relative to secretsDir. */
84
+ envFiles: string[]
85
+ }
86
+
87
+ /** `cd <secretsDir> && dotenvx run -f … -- bash -c <script>` — the arms' env wrapper. */
88
+ export function dotenvxBash(
89
+ secrets: SecretsEnv,
90
+ script: string,
91
+ opts: { timeoutMs?: number; killGraceMs?: number; env?: NodeJS.ProcessEnv; signal?: AbortSignal } = {},
92
+ ): ReturnType<typeof run> {
93
+ const authBootstrap = [
94
+ 'if [ -n "${SWE_ARENA_OPENCODE_AUTH_FILE:-}" ] && [ -r "$SWE_ARENA_OPENCODE_AUTH_FILE" ]; then',
95
+ ' export OPENCODE_AUTH_CONTENT="$(cat -- "$SWE_ARENA_OPENCODE_AUTH_FILE")"',
96
+ 'fi',
97
+ ].join('\n')
98
+ const argv = [
99
+ 'run',
100
+ ...secrets.envFiles.flatMap((f) => ['-f', f]),
101
+ '--',
102
+ 'bash',
103
+ '-c',
104
+ `${authBootstrap}\n${script}`,
105
+ ]
106
+ return run('dotenvx', argv, {
107
+ cwd: secrets.secretsDir,
108
+ timeoutMs: opts.timeoutMs,
109
+ killGraceMs: opts.killGraceMs,
110
+ env: opts.env ?? process.env,
111
+ signal: opts.signal,
112
+ })
113
+ }
114
+
115
+ export interface IsolatedCellEnvironment {
116
+ env: NodeJS.ProcessEnv
117
+ opencodeDb: string
118
+ }
119
+
120
+ /** Give one cell private CLI state, caches, temp files, and Python installs. */
121
+ export async function prepareIsolatedCellEnvironment(
122
+ runDir: string,
123
+ baseEnv: NodeJS.ProcessEnv = process.env,
124
+ ): Promise<IsolatedCellEnvironment> {
125
+ const ambientHome = baseEnv.HOME?.trim() || homedir()
126
+ const cellRoot = resolve(runDir)
127
+ const home = join(cellRoot, 'home')
128
+ const xdgRoot = join(cellRoot, 'xdg')
129
+ const configHome = join(xdgRoot, 'config')
130
+ const dataHome = join(xdgRoot, 'data')
131
+ const cacheHome = join(xdgRoot, 'cache')
132
+ const stateHome = join(xdgRoot, 'state')
133
+ const runtimeDir = join(xdgRoot, 'runtime')
134
+ const tmp = join(cellRoot, 'tmp')
135
+ const pythonUserBase = join(cellRoot, 'python-user')
136
+ const opencodeConfigDir = join(configHome, 'opencode')
137
+ const opencodeConfig = join(opencodeConfigDir, 'opencode.json')
138
+ const opencodeDb = join(dataHome, 'opencode', 'opencode.db')
139
+ const managedRoots = [home, xdgRoot, tmp, pythonUserBase]
140
+ await Promise.all(managedRoots.map((dir) => rm(dir, { recursive: true, force: true })))
141
+
142
+ const privateDirs = [home, configHome, dataHome, cacheHome, stateHome, runtimeDir, tmp, pythonUserBase, opencodeConfigDir]
143
+ await Promise.all(privateDirs.map((dir) => mkdir(dir, { recursive: true })))
144
+ await Promise.all(privateDirs.map((dir) => chmod(dir, 0o700)))
145
+
146
+ const ambientConfigHome = baseEnv.XDG_CONFIG_HOME?.trim() || join(ambientHome, '.config')
147
+ const configCandidates = [
148
+ baseEnv.OPENCODE_CONFIG?.trim(),
149
+ join(ambientConfigHome, 'opencode', 'opencode.json'),
150
+ join(ambientConfigHome, 'opencode', 'opencode.jsonc'),
151
+ ].filter((path): path is string => Boolean(path))
152
+ let configText = baseEnv.OPENCODE_CONFIG_CONTENT
153
+ if (configText === undefined) {
154
+ for (const candidate of configCandidates) {
155
+ configText = await readFile(candidate, 'utf8').catch(() => undefined)
156
+ if (configText !== undefined) break
157
+ }
158
+ }
159
+ await writeFile(opencodeConfig, configText ?? '{}\n', { mode: 0o600 })
160
+
161
+ const env: NodeJS.ProcessEnv = {
162
+ ...baseEnv,
163
+ HOME: home,
164
+ XDG_CONFIG_HOME: configHome,
165
+ XDG_DATA_HOME: dataHome,
166
+ XDG_CACHE_HOME: cacheHome,
167
+ XDG_STATE_HOME: stateHome,
168
+ XDG_RUNTIME_DIR: runtimeDir,
169
+ TMPDIR: tmp,
170
+ OPENCODE_CONFIG_DIR: opencodeConfigDir,
171
+ OPENCODE_CONFIG: opencodeConfig,
172
+ OPENCODE_DB: opencodeDb,
173
+ SWE_ARENA_OPENCODE_AUTH_FILE:
174
+ baseEnv.SWE_ARENA_OPENCODE_AUTH_FILE?.trim() ||
175
+ join(baseEnv.XDG_DATA_HOME?.trim() || join(ambientHome, '.local', 'share'), 'opencode', 'auth.json'),
176
+ PYTHONUSERBASE: pythonUserBase,
177
+ }
178
+ delete env.OPENCODE_CONFIG_CONTENT
179
+ delete env.OPENCODE_AUTH_CONTENT
180
+ delete env.PYTHONHOME
181
+ delete env.PYTHONPATH
182
+ delete env.PYTHONNOUSERSITE
183
+ return { env, opencodeDb }
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Arm specs. `ArmSpec` (types.ts) is the declarative identity M1 pinned;
188
+ // these are the executable forms, convertible back via `toArmIdentity`.
189
+ // ---------------------------------------------------------------------------
190
+
191
+ export interface SoloArmSpec {
192
+ kind: 'solo'
193
+ /** Ledger/run-dir label, e.g. 'SOLO'. */
194
+ name: string
195
+ /** opencode model id, e.g. 'zai-coding-plan/glm-5.2'. */
196
+ model: string
197
+ /** Appended to the problem statement. Default: the shared worker suffix. */
198
+ promptSuffix?: string
199
+ /** Whole-run ceiling. solo.sh used `timeout 1000` (s). */
200
+ timeoutMs?: number
201
+ }
202
+
203
+ export interface SupervisorArmSpec {
204
+ kind: 'supervisor'
205
+ /** Ledger/run-dir label, e.g. 'SUP4'. */
206
+ name: string
207
+ workerModel: string
208
+ driverModel: string
209
+ /** params.json knobs — defaults are sup4.sh's values. */
210
+ budget?: number
211
+ maxSandboxes?: number
212
+ maxUsd?: number
213
+ maxDepth?: number
214
+ /**
215
+ * Env knobs exported inside the dotenvx child. Defaults are sup4.sh's:
216
+ * WORKER_BACKEND=cli, LOOPS_WORKER_HARNESS=opencode, LOOPS_BRAIN_RETRIES=30,
217
+ * DRIVER_DEADLINE_MS=2600000. LOOPS_BRAIN_LOG is always run-dir-derived.
218
+ */
219
+ envKnobs?: Record<string, string>
220
+ /** The loops checkout the driver runs from. */
221
+ loopsRepo?: string
222
+ /** The pi extension entry. Default: <loopsRepo>/extensions/pi/loops.ts. */
223
+ extensionPath?: string
224
+ /** Whole-run ceiling. sup4.sh used `timeout 2800` (s). */
225
+ timeoutMs?: number
226
+ }
227
+
228
+ export type ExecutableArmSpec = SoloArmSpec | SupervisorArmSpec
229
+
230
+ export const DEFAULT_LOOPS_REPO = '/home/drew/code/loops'
231
+
232
+ export const SUP_DEFAULT_ENV_KNOBS: Record<string, string> = {
233
+ WORKER_BACKEND: 'cli',
234
+ LOOPS_WORKER_HARNESS: 'opencode',
235
+ LOOPS_BRAIN_RETRIES: '30',
236
+ DRIVER_DEADLINE_MS: '2600000',
237
+ }
238
+
239
+ export const DEFAULT_DRIVER_CANCEL_TIMEOUT_MS = 30_000
240
+ export const DRIVER_CANCELLATION_SETTLEMENT_BUFFER_MS = 10_000
241
+
242
+ /** Leave the driver more time than its own cancellation deadline to settle workers. */
243
+ export function supervisorDriverKillGraceMs(envKnobs: Record<string, string>): number {
244
+ const raw = envKnobs.DRIVER_CANCEL_TIMEOUT_MS
245
+ const cancelTimeoutMs = raw === undefined ? DEFAULT_DRIVER_CANCEL_TIMEOUT_MS : Number(raw)
246
+ if (!Number.isFinite(cancelTimeoutMs) || cancelTimeoutMs <= 0) {
247
+ throw new Error('DRIVER_CANCEL_TIMEOUT_MS must be a positive number')
248
+ }
249
+ return cancelTimeoutMs + DRIVER_CANCELLATION_SETTLEMENT_BUFFER_MS
250
+ }
251
+
252
+ export interface ArmProvenance {
253
+ repo: string
254
+ commit: string
255
+ envKnobs: Record<string, string>
256
+ }
257
+
258
+ /** Runtime provenance: which loops commit actually sat in the supervisor seat. */
259
+ export async function armProvenance(
260
+ loopsRepo: string,
261
+ envKnobs: Record<string, string>,
262
+ signal?: AbortSignal,
263
+ ): Promise<ArmProvenance> {
264
+ const head = await runArenaGitOk(['-C', loopsRepo, 'rev-parse', 'HEAD'], signal)
265
+ return { repo: loopsRepo, commit: head.stdout.trim(), envKnobs }
266
+ }
267
+
268
+ /** Collapse an executable spec to the M1 declarative identity for the record. */
269
+ export async function toArmIdentity(spec: ExecutableArmSpec): Promise<ArmSpec> {
270
+ if (spec.kind === 'solo') {
271
+ return {
272
+ name: spec.name,
273
+ kind: 'solo',
274
+ env: { model: spec.model },
275
+ provenance: { repo: 'opencode', commit: 'cli' },
276
+ }
277
+ }
278
+ const loopsRepo = spec.loopsRepo ?? DEFAULT_LOOPS_REPO
279
+ const envKnobs = { ...SUP_DEFAULT_ENV_KNOBS, ...spec.envKnobs }
280
+ const prov = await armProvenance(loopsRepo, envKnobs)
281
+ return {
282
+ name: spec.name,
283
+ kind: 'supervisor',
284
+ env: envKnobs,
285
+ provenance: { repo: prov.repo, commit: prov.commit },
286
+ }
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // opencode usage parse (oc_usage.py port).
291
+ // ---------------------------------------------------------------------------
292
+
293
+ /** Sum opencode `--format json` stream token parts into the ledger's SoloUsage. */
294
+ export function parseOcUsage(jsonl: string): SoloUsage {
295
+ let steps = 0
296
+ let inp = 0
297
+ let out = 0
298
+ let rea = 0
299
+ let cw = 0
300
+ let cr = 0
301
+ let maxTot = 0
302
+ let cost = 0
303
+ for (const rawLine of jsonl.split('\n')) {
304
+ const line = rawLine.trim()
305
+ if (!line.startsWith('{')) continue
306
+ let o: unknown
307
+ try {
308
+ o = JSON.parse(line)
309
+ } catch {
310
+ continue
311
+ }
312
+ const part = (o as { part?: unknown }).part
313
+ if (typeof part !== 'object' || part === null) continue
314
+ const tk = (part as { tokens?: unknown }).tokens
315
+ if (typeof tk !== 'object' || tk === null) continue
316
+ const t = tk as Record<string, unknown>
317
+ const num = (v: unknown): number => (typeof v === 'number' ? v : 0)
318
+ steps += 1
319
+ inp += num(t.input)
320
+ out += num(t.output)
321
+ rea += num(t.reasoning)
322
+ const cache = (t.cache ?? {}) as Record<string, unknown>
323
+ cw += num(cache.write)
324
+ cr += num(cache.read)
325
+ maxTot = Math.max(maxTot, num(t.total))
326
+ cost += num((part as Record<string, unknown>).cost)
327
+ }
328
+ return {
329
+ steps,
330
+ in: inp,
331
+ out,
332
+ reasoning: rea,
333
+ cache_w: cw,
334
+ cache_r: cr,
335
+ max_ctx: maxTot,
336
+ oc_cost: Number(cost.toFixed(6)),
337
+ total_io: inp + out + rea,
338
+ }
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Arm execution.
343
+ // ---------------------------------------------------------------------------
344
+
345
+ export interface ArmRunContext {
346
+ instanceId: string
347
+ image: string
348
+ baseCommit: string
349
+ /**
350
+ * Workspace materialization override. Default (undefined) = SWE instance
351
+ * image materialization (docker cp of /testbed). Factory instances inject
352
+ * their archive-export + synthetic-history materialization here so the arm
353
+ * runners themselves stay instance-kind-agnostic. Must leave `dest` a git
354
+ * repo whose HEAD is the diff base for patch extraction (`baseCommit`).
355
+ */
356
+ materialize?: (dest: string) => Promise<void>
357
+ problemStatement: string
358
+ /** Self-repro verify command (bash -c, cwd = ws) — the MEASUREMENT gate. */
359
+ verifyCmd: string
360
+ /** Root for runs/<iid>/<ARM>/ and patches/. */
361
+ outDir: string
362
+ secrets: SecretsEnv
363
+ excludes: string[]
364
+ signal?: AbortSignal
365
+ }
366
+
367
+ export interface SoloArmResult {
368
+ arm: string
369
+ iid: string
370
+ oc_rc: number
371
+ wall_s: number
372
+ patch_lines: number
373
+ verify_rc: number
374
+ verify_pass: boolean
375
+ usage: SoloUsage
376
+ patchPath: string
377
+ ws: string
378
+ }
379
+
380
+ export interface SupervisorArmResult {
381
+ arm: string
382
+ iid: string
383
+ driver_rc: number
384
+ wall_s: number
385
+ patch_lines: number
386
+ verify_rc: number
387
+ verify_pass: boolean
388
+ sup_status: string | null
389
+ sup_verdict: string | null
390
+ delivered: boolean | null
391
+ spentTokens: number | null
392
+ spentUsd: number | null
393
+ spawned: number
394
+ workers: number
395
+ settled: number
396
+ subtasks: string[]
397
+ patchPath: string
398
+ ws: string
399
+ provenance: ArmProvenance
400
+ recoveredSpend: SupSpend | null
401
+ }
402
+
403
+ const patchLineCount = (patch: string): number => (patch.length === 0 ? 0 : patch.split('\n').length - (patch.endsWith('\n') ? 1 : 0))
404
+
405
+ type ProcessCompletion = Pick<RunResult, 'code' | 'timedOut' | 'aborted'>
406
+
407
+ /** Reject a solo process outcome before its partial workspace can be scored. */
408
+ export function assertSoloProcessCompleted(result: ProcessCompletion, label = 'runSoloArm'): void {
409
+ if (result.timedOut) {
410
+ throw new Error(`${label}: opencode timed out (exit ${result.code})`)
411
+ }
412
+ if (result.aborted) {
413
+ throw new Error(`${label}: opencode was cancelled (exit ${result.code})`)
414
+ }
415
+ if (result.code !== 0) {
416
+ throw new Error(`${label}: opencode exited ${result.code}`)
417
+ }
418
+ }
419
+
420
+ /** Reject a driver or unfinished supervisor state before its partial workspace can be scored. */
421
+ export function assertSupervisorProcessCompleted(
422
+ driver: ProcessCompletion,
423
+ status: string | null,
424
+ label = 'runSupervisorArm',
425
+ ): void {
426
+ const state = status ?? 'missing'
427
+ if (driver.timedOut) {
428
+ throw new Error(`${label}: driver timed out (exit ${driver.code}, supervisor state ${state})`)
429
+ }
430
+ if (driver.aborted) {
431
+ throw new Error(`${label}: driver was cancelled (exit ${driver.code}, supervisor state ${state})`)
432
+ }
433
+ if (driver.code !== 0) {
434
+ throw new Error(`${label}: driver exited ${driver.code} (supervisor state ${state})`)
435
+ }
436
+ if (status !== 'completed') {
437
+ throw new Error(`${label}: supervisor state ${state}; expected completed`)
438
+ }
439
+ }
440
+
441
+ async function verifyWorkspace(
442
+ verifyCmd: string,
443
+ ws: string,
444
+ logPath: string,
445
+ env: NodeJS.ProcessEnv,
446
+ signal?: AbortSignal,
447
+ ): Promise<number> {
448
+ const res = await run('bash', ['-c', verifyCmd], { cwd: ws, timeoutMs: 600_000, env, signal })
449
+ await writeFile(logPath, res.stdout + res.stderr)
450
+ return res.code
451
+ }
452
+
453
+ /** solo.sh port: materialize → prompt → opencode run → patch extract → verify. */
454
+ export async function runSoloArm(spec: SoloArmSpec, ctx: ArmRunContext): Promise<SoloArmResult> {
455
+ const runDir = join(ctx.outDir, 'runs', ctx.instanceId, spec.name)
456
+ const ws = join(runDir, 'ws')
457
+ await mkdir(runDir, { recursive: true })
458
+ const cell = await prepareIsolatedCellEnvironment(runDir)
459
+ // A caller-supplied materializer (synthetic-history factory cells) replaces the
460
+ // SWE-bench image checkout, but still runs inside the isolated cell above.
461
+ if (ctx.materialize) await ctx.materialize(ws)
462
+ else
463
+ await materializeWorkspace({
464
+ instanceId: ctx.instanceId,
465
+ image: ctx.image,
466
+ baseCommit: ctx.baseCommit,
467
+ dest: ws,
468
+ signal: ctx.signal,
469
+ })
470
+
471
+ const promptFile = join(runDir, 'prompt.txt')
472
+ await writeFile(promptFile, ctx.problemStatement + (spec.promptSuffix ?? WORKER_PROMPT_SUFFIX))
473
+
474
+ const t0 = Date.now()
475
+ const oc = await dotenvxBash(
476
+ ctx.secrets,
477
+ `cd ${shq(ws)} && opencode run "$(cat ${shq(promptFile)})" -m ${shq(spec.model)} --format json`,
478
+ { timeoutMs: spec.timeoutMs ?? 1_000_000, env: cell.env, signal: ctx.signal },
479
+ )
480
+ const wall_s = Math.round((Date.now() - t0) / 1000)
481
+ await writeFile(join(runDir, 'oc.jsonl'), oc.stdout)
482
+ await writeFile(join(runDir, 'oc.err'), oc.stderr)
483
+ assertSoloProcessCompleted(oc, `runSoloArm ${ctx.instanceId}/${spec.name}`)
484
+ if (ctx.signal?.aborted) throw ctx.signal.reason
485
+
486
+ const patch = await extractPatch(ws, ctx.baseCommit, ctx.excludes, ctx.signal)
487
+ const patchPath = join(ctx.outDir, 'patches', `${ctx.instanceId}.${spec.name.toLowerCase()}.patch`)
488
+ await mkdir(join(ctx.outDir, 'patches'), { recursive: true })
489
+ await writeFile(patchPath, patch)
490
+
491
+ const verify_rc = await verifyWorkspace(ctx.verifyCmd, ws, join(runDir, 'verify.log'), cell.env, ctx.signal)
492
+ return {
493
+ arm: spec.name,
494
+ iid: ctx.instanceId,
495
+ oc_rc: oc.code,
496
+ wall_s,
497
+ patch_lines: patchLineCount(patch),
498
+ verify_rc,
499
+ verify_pass: verify_rc === 0,
500
+ usage: parseOcUsage(oc.stdout),
501
+ patchPath,
502
+ ws,
503
+ }
504
+ }
505
+
506
+ interface SupervisorRunArtifacts {
507
+ status: string | null
508
+ verdict: string | null
509
+ delivered: boolean | null
510
+ spentTokens: number | null
511
+ spentUsd: number | null
512
+ spawned: number
513
+ workers: number
514
+ settled: number
515
+ subtasks: string[]
516
+ supRunDir: string | null
517
+ }
518
+
519
+ /** Locate the (single) supervisor run dir under <ws>/.loops/supervisor. */
520
+ export async function findSupervisorRunDir(ws: string): Promise<string | null> {
521
+ const root = join(ws, '.loops', 'supervisor')
522
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => [])
523
+ const dirs = entries.filter((e) => e.isDirectory()).map((e) => join(root, e.name))
524
+ return dirs[0] ?? null
525
+ }
526
+
527
+ /** sup4.sh's state.json + journal.jsonl summary (spawned/settled/workers/goals). */
528
+ export async function parseSupervisorArtifacts(ws: string): Promise<SupervisorRunArtifacts> {
529
+ const supRunDir = await findSupervisorRunDir(ws)
530
+ let status: string | null = null
531
+ let verdict: string | null = null
532
+ let delivered: boolean | null = null
533
+ let spentTokens: number | null = null
534
+ let spentUsd: number | null = null
535
+ let spawned = 0
536
+ let workers = 0
537
+ let settled = 0
538
+ const subtasks: string[] = []
539
+ if (supRunDir) {
540
+ let state: Record<string, unknown> = {}
541
+ try {
542
+ state = JSON.parse(await readFile(join(supRunDir, 'state.json'), 'utf8')) as Record<string, unknown>
543
+ } catch {
544
+ // Absent/corrupt state.json (driver killed mid-write) = all-null summary.
545
+ }
546
+ status = typeof state.status === 'string' ? state.status : null
547
+ verdict = typeof state.verdict === 'string' ? state.verdict : null
548
+ const result = (state.result ?? {}) as Record<string, unknown>
549
+ delivered = typeof result.delivered === 'boolean' ? result.delivered : null
550
+ spentTokens = typeof result.spentTokens === 'number' ? result.spentTokens : null
551
+ spentUsd = typeof result.spentUsd === 'number' ? result.spentUsd : null
552
+ const journal = await readFile(join(supRunDir, 'journal.jsonl'), 'utf8').catch(() => '')
553
+ for (const line of journal.split('\n')) {
554
+ if (!line.trim()) continue
555
+ let o: Record<string, unknown>
556
+ try {
557
+ o = JSON.parse(line) as Record<string, unknown>
558
+ } catch {
559
+ continue
560
+ }
561
+ const kind = o.kind
562
+ const label = typeof o.label === 'string' ? o.label : ''
563
+ if (kind === 'spawned') {
564
+ spawned += 1
565
+ if (label && label !== 'root') {
566
+ workers += 1
567
+ subtasks.push(label.slice(0, 100))
568
+ }
569
+ } else if (kind === 'settled') {
570
+ settled += 1
571
+ }
572
+ }
573
+ }
574
+ return { status, verdict, delivered, spentTokens, spentUsd, spawned, workers, settled, subtasks, supRunDir }
575
+ }
576
+
577
+ /**
578
+ * sup4.sh port: materialize → params.json → loops driver via the tracked shim
579
+ * (env knobs incl. LOOPS_BRAIN_RETRIES / DRIVER_DEADLINE_MS) → patch extract →
580
+ * verify → cost recovery. The driver runs with cwd = the loops repo, exactly
581
+ * like the bash (`cd /home/drew/code/loops && node --import tsx …`).
582
+ */
583
+ export async function runSupervisorArm(spec: SupervisorArmSpec, ctx: ArmRunContext): Promise<SupervisorArmResult> {
584
+ const loopsRepo = spec.loopsRepo ?? DEFAULT_LOOPS_REPO
585
+ const extensionPath = spec.extensionPath ?? join(loopsRepo, 'extensions', 'pi', 'loops.ts')
586
+ const envKnobs = { ...SUP_DEFAULT_ENV_KNOBS, ...spec.envKnobs }
587
+ const provenance = await armProvenance(loopsRepo, envKnobs, ctx.signal)
588
+
589
+ const runDir = join(ctx.outDir, 'runs', ctx.instanceId, spec.name)
590
+ const ws = join(runDir, 'ws')
591
+ await mkdir(runDir, { recursive: true })
592
+ const cell = await prepareIsolatedCellEnvironment(runDir)
593
+ // A caller-supplied materializer (synthetic-history factory cells) replaces the
594
+ // SWE-bench image checkout, but still runs inside the isolated cell above.
595
+ if (ctx.materialize) await ctx.materialize(ws)
596
+ else
597
+ await materializeWorkspace({
598
+ instanceId: ctx.instanceId,
599
+ image: ctx.image,
600
+ baseCommit: ctx.baseCommit,
601
+ dest: ws,
602
+ signal: ctx.signal,
603
+ })
604
+
605
+ const paramsFile = join(runDir, 'params.json')
606
+ await writeFile(
607
+ paramsFile,
608
+ JSON.stringify(
609
+ {
610
+ task: ctx.problemStatement,
611
+ workspaceDir: ws,
612
+ budget: spec.budget ?? 40,
613
+ verifyCmd: ctx.verifyCmd,
614
+ workerModel: spec.workerModel,
615
+ driverModel: spec.driverModel,
616
+ maxSandboxes: spec.maxSandboxes ?? 4,
617
+ maxUsd: spec.maxUsd ?? 8,
618
+ maxDepth: spec.maxDepth ?? 3,
619
+ },
620
+ null,
621
+ 1,
622
+ ),
623
+ )
624
+ await rm(join(ws, '.loops'), { recursive: true, force: true })
625
+
626
+ const knobExports = Object.entries({ ...envKnobs, LOOPS_BRAIN_LOG: join(runDir, 'brain.jsonl') })
627
+ .map(([k, v]) => {
628
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(k)) throw new Error(`invalid env knob name: ${k}`)
629
+ return `export ${k}=${shq(v)}`
630
+ })
631
+ .join('\n')
632
+ const script = [
633
+ knobExports,
634
+ `cd ${shq(loopsRepo)}`,
635
+ `node --import tsx ${shq(runSupervisorShim)} ${shq(extensionPath)} ${shq(ws)} ${shq(paramsFile)}`,
636
+ ].join('\n')
637
+
638
+ const t0 = Date.now()
639
+ const driver = await dotenvxBash(ctx.secrets, script, {
640
+ timeoutMs: spec.timeoutMs ?? 2_800_000,
641
+ killGraceMs: supervisorDriverKillGraceMs(envKnobs),
642
+ env: cell.env,
643
+ signal: ctx.signal,
644
+ })
645
+ const wall_s = Math.round((Date.now() - t0) / 1000)
646
+ await writeFile(join(runDir, 'driver.log'), driver.stdout + driver.stderr)
647
+
648
+ const artifacts = await parseSupervisorArtifacts(ws)
649
+ assertSupervisorProcessCompleted(
650
+ driver,
651
+ artifacts.status,
652
+ `runSupervisorArm ${ctx.instanceId}/${spec.name}`,
653
+ )
654
+ if (ctx.signal?.aborted) throw ctx.signal.reason
655
+
656
+ const patch = await extractPatch(ws, ctx.baseCommit, ctx.excludes, ctx.signal)
657
+ const patchPath = join(ctx.outDir, 'patches', `${ctx.instanceId}.${spec.name.toLowerCase()}.patch`)
658
+ await mkdir(join(ctx.outDir, 'patches'), { recursive: true })
659
+ await writeFile(patchPath, patch)
660
+
661
+ const verify_rc = await verifyWorkspace(ctx.verifyCmd, ws, join(runDir, 'verify.log'), cell.env, ctx.signal)
662
+
663
+ const recoveredSpend = artifacts.supRunDir
664
+ ? await recoverSupSpend(artifacts.supRunDir, { opencodeDb: cell.opencodeDb }).catch(() => null)
665
+ : null
666
+
667
+ return {
668
+ arm: spec.name,
669
+ iid: ctx.instanceId,
670
+ driver_rc: driver.code,
671
+ wall_s,
672
+ patch_lines: patchLineCount(patch),
673
+ verify_rc,
674
+ verify_pass: verify_rc === 0,
675
+ sup_status: artifacts.status,
676
+ sup_verdict: artifacts.verdict,
677
+ delivered: artifacts.delivered,
678
+ spentTokens: artifacts.spentTokens,
679
+ spentUsd: artifacts.spentUsd,
680
+ spawned: artifacts.spawned,
681
+ workers: artifacts.workers,
682
+ settled: artifacts.settled,
683
+ subtasks: artifacts.subtasks,
684
+ patchPath,
685
+ ws,
686
+ provenance,
687
+ recoveredSpend,
688
+ }
689
+ }
690
+
691
+ // ---------------------------------------------------------------------------
692
+ // Cost recovery — true_sup_spend.py port + the opencode sqlite worker join.
693
+ // ---------------------------------------------------------------------------
694
+
695
+ export interface SupSpend {
696
+ /** Worker tokens the journal itself captured (`settled` spend) — often 0. */
697
+ workerTokJournal: number
698
+ /** Worker clone cwds found in workers/*.ndjson `started` events. */
699
+ workerCwds: string[]
700
+ /**
701
+ * Worker-session spend recovered from the opencode sqlite store by joining
702
+ * ndjson cwd → session.directory (the provenance of worker-tokens.json).
703
+ * `null` = the store was unavailable — a telemetry gap, never a zero.
704
+ * `workerTokIn`/`workerTokOut` split the same sessions so the campaign
705
+ * CostLedger receipt carries a real input/output usage, not a lump.
706
+ */
707
+ workerSessions: number | null
708
+ workerTokSqlite: number | null
709
+ workerTokIn: number | null
710
+ workerTokOut: number | null
711
+ }
712
+
713
+ export const DEFAULT_OPENCODE_DB = join(homedir(), '.local', 'share', 'opencode', 'opencode.db')
714
+
715
+ /**
716
+ * Recover the WORKER-side SUP-arm spend from a supervisor run dir
717
+ * (<ws>/.loops/supervisor/<id>): ndjson settled spend where captured, plus the
718
+ * opencode sqlite session totals for each worker clone cwd. CLI-backend worker
719
+ * sessions never meter into the journal, so the sqlite join is genuinely
720
+ * custom recovery. Brain/driver spend is NO LONGER re-parsed from journal
721
+ * `metered` events: the supervise runtime's spend tree now reaches state.json
722
+ * on BOTH result arms (loops extensions/pi/loops.ts writes
723
+ * `result.spentTokens`/`spentUsd` from `SupervisedResult.spentTotal`, winner
724
+ * AND no-winner), so `parseSupervisorArtifacts` already carries it — the old
725
+ * no-winner zeroing this parsing compensated for is fixed upstream.
726
+ */
727
+ export async function recoverSupSpend(
728
+ supRunDir: string,
729
+ opts: { opencodeDb?: string } = {},
730
+ ): Promise<SupSpend> {
731
+ let workerTokJournal = 0
732
+ const workerCwds: string[] = []
733
+ const workersDir = join(supRunDir, 'workers')
734
+ const workerFiles = (await readdir(workersDir).catch(() => [])).filter((f) => f.endsWith('.ndjson'))
735
+ for (const f of workerFiles) {
736
+ const nd = await readFile(join(workersDir, f), 'utf8').catch(() => '')
737
+ for (const line of nd.split('\n')) {
738
+ if (!line.trim()) continue
739
+ let o: Record<string, unknown>
740
+ try {
741
+ o = JSON.parse(line) as Record<string, unknown>
742
+ } catch {
743
+ continue
744
+ }
745
+ if (o.kind === 'settled') {
746
+ const tokens = ((o.spend ?? {}) as Record<string, unknown>).tokens as Record<string, unknown> | undefined
747
+ workerTokJournal +=
748
+ (typeof tokens?.input === 'number' ? tokens.input : 0) +
749
+ (typeof tokens?.output === 'number' ? tokens.output : 0)
750
+ } else if (o.kind === 'started' && typeof o.cwd === 'string') {
751
+ workerCwds.push(o.cwd)
752
+ }
753
+ }
754
+ }
755
+
756
+ let workerSessions: number | null = null
757
+ let workerTokSqlite: number | null = null
758
+ let workerTokIn: number | null = null
759
+ let workerTokOut: number | null = null
760
+ if (workerCwds.length > 0) {
761
+ try {
762
+ // Deferred import: node:sqlite is only needed on the cost-recovery path.
763
+ const { DatabaseSync } = await import('node:sqlite')
764
+ const db = new DatabaseSync(opts.opencodeDb ?? DEFAULT_OPENCODE_DB, { readOnly: true })
765
+ try {
766
+ const placeholders = workerCwds.map(() => '?').join(',')
767
+ const row = db
768
+ .prepare(
769
+ `SELECT COUNT(*) AS n,
770
+ COALESCE(SUM(tokens_input), 0) AS tin,
771
+ COALESCE(SUM(tokens_output + tokens_reasoning), 0) AS tout
772
+ FROM session WHERE directory IN (${placeholders})`,
773
+ )
774
+ .get(...workerCwds) as { n: number | bigint; tin: number | bigint; tout: number | bigint }
775
+ workerSessions = Number(row.n)
776
+ workerTokIn = Number(row.tin)
777
+ workerTokOut = Number(row.tout)
778
+ workerTokSqlite = workerTokIn + workerTokOut
779
+ } finally {
780
+ db.close()
781
+ }
782
+ } catch {
783
+ // Store unavailable/corrupt → telemetry gap (null), never a silent 0.
784
+ workerSessions = null
785
+ workerTokSqlite = null
786
+ workerTokIn = null
787
+ workerTokOut = null
788
+ }
789
+ } else {
790
+ workerSessions = 0
791
+ workerTokSqlite = 0
792
+ workerTokIn = 0
793
+ workerTokOut = 0
794
+ }
795
+
796
+ return {
797
+ workerTokJournal,
798
+ workerCwds,
799
+ workerSessions,
800
+ workerTokSqlite,
801
+ workerTokIn,
802
+ workerTokOut,
803
+ }
804
+ }