@tangle-network/agent-bench 0.11.3 → 0.13.0

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