@tangle-network/agent-bench 0.3.6 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/dist/adapters.js +2 -2
- package/dist/benchmarks/humaneval.d.ts +10 -1
- package/dist/benchmarks/humaneval.js +5 -3
- package/dist/{chunk-PPYSEKFM.js → chunk-5H5XV76F.js} +73 -15
- package/dist/chunk-5H5XV76F.js.map +1 -0
- package/dist/{chunk-5SBJCB6W.js → chunk-PWQVGAJB.js} +2 -2
- package/dist/index.js +2 -2
- package/package.json +5 -4
- package/scripts/run-package-tests.mjs +30 -8
- package/scripts/verify-pier-agent.mts +1 -0
- package/src/benchmarks/humaneval.test.mts +122 -0
- package/src/benchmarks/humaneval.ts +100 -27
- package/src/david-attribution.mts +78 -0
- package/src/david-goliath.mts +149 -0
- package/src/hev-improve.mts +25 -6
- package/src/humaneval-object-ablation.mts +201 -0
- package/src/live-improve-campaign-mbpp.mts +641 -0
- package/src/live-improve-campaign.mts +500 -0
- package/src/mbpp-structural.mts +12 -7
- package/src/stream-observe.py +45 -0
- package/src/stream-observe.tpl.html +247 -0
- package/src/supervisor-arena.mts +816 -0
- package/src/swe-arena/analyze.ts +211 -0
- package/src/swe-arena/arms.ts +788 -0
- package/src/swe-arena/bootstrap-meta.mts +188 -0
- package/src/swe-arena/bootstrap-meta.test.mts +51 -0
- package/src/swe-arena/calibrate.ts +116 -0
- package/src/swe-arena/capabilities.mts +76 -0
- package/src/swe-arena/capabilities.test.mts +57 -0
- package/src/swe-arena/capacity.ts +194 -0
- package/src/swe-arena/cell-evidence.mts +405 -0
- package/src/swe-arena/cell-evidence.test.mts +248 -0
- package/src/swe-arena/diagnosis-ensemble.test.mts +210 -0
- package/src/swe-arena/diagnosis-ensemble.ts +520 -0
- package/src/swe-arena/execution.test.mts +1170 -0
- package/src/swe-arena/fixtures/analyze.py +80 -0
- package/src/swe-arena/fixtures/excludes.txt +8 -0
- package/src/swe-arena/fixtures/gen1-salvage/README.md +45 -0
- package/src/swe-arena/fixtures/gen1-salvage/cand0-e6d7361.diff +116 -0
- package/src/swe-arena/fixtures/gen1-salvage/cand1-76a8590.diff +293 -0
- package/src/swe-arena/fixtures/holdout-preregister.log +12 -0
- package/src/swe-arena/fixtures/holdout.json +44 -0
- package/src/swe-arena/fixtures/instances.json +146 -0
- package/src/swe-arena/fixtures/ledger.jsonl +12 -0
- package/src/swe-arena/fixtures/patches/pallets__flask-5014.solo.patch +36 -0
- package/src/swe-arena/fixtures/patches/pydata__xarray-4687.sup.patch +33 -0
- package/src/swe-arena/fixtures/rejudge.jsonl +15 -0
- package/src/swe-arena/fixtures/rematch.jsonl +3 -0
- package/src/swe-arena/fixtures/rematch2.jsonl +3 -0
- package/src/swe-arena/fixtures/rematch3.jsonl +3 -0
- package/src/swe-arena/fixtures/sup-journal-true.json +19 -0
- package/src/swe-arena/fixtures/verify/astropy__astropy-13033.sh +48 -0
- package/src/swe-arena/fixtures/verify/django__django-11532.sh +50 -0
- package/src/swe-arena/fixtures/verify/matplotlib__matplotlib-20826.sh +76 -0
- package/src/swe-arena/fixtures/verify/pydata__xarray-4687.sh +44 -0
- package/src/swe-arena/fixtures/verify/pytest-dev__pytest-6197.sh +32 -0
- package/src/swe-arena/fixtures/verify/sphinx-doc__sphinx-9658.sh +51 -0
- package/src/swe-arena/fixtures/worker-tokens.json +42 -0
- package/src/swe-arena/fixtures.ts +104 -0
- package/src/swe-arena/holdout-certify.mts +408 -0
- package/src/swe-arena/holdout-certify.test.mts +160 -0
- package/src/swe-arena/judge-child.mts +37 -0
- package/src/swe-arena/manifest.mts +293 -0
- package/src/swe-arena/manifest.test.mts +169 -0
- package/src/swe-arena/materialize.ts +142 -0
- package/src/swe-arena/outer-loop.mts +2145 -0
- package/src/swe-arena/outer-loop.test.mts +696 -0
- package/src/swe-arena/parity.test.mts +87 -0
- package/src/swe-arena/proc.test.mts +174 -0
- package/src/swe-arena/proc.ts +260 -0
- package/src/swe-arena/profiles/default-author.profile.json +4 -0
- package/src/swe-arena/proposer-fanout.mts +489 -0
- package/src/swe-arena/proposer-fanout.test.mts +372 -0
- package/src/swe-arena/reconcile.ts +0 -0
- package/src/swe-arena/replay.mts +183 -0
- package/src/swe-arena/replay.test.mts +300 -0
- package/src/swe-arena/run-experiment.mts +361 -0
- package/src/swe-arena/run-supervisor.mjs +297 -0
- package/src/swe-arena/run-supervisor.test.mts +498 -0
- package/src/swe-arena/serialized-judge.ts +414 -0
- package/src/swe-arena/types.ts +166 -0
- package/src/swe-code-improve.mts +328 -0
- package/src/swe-emit-patch.mts +104 -0
- package/src/swe-improve.mts +232 -0
- package/src/swe-jail.ts +2 -2
- package/src/swe-local-proof.mts +169 -0
- package/src/swe-repro-calibrate.mts +446 -0
- package/src/swe-stream.mts +1497 -0
- package/dist/chunk-PPYSEKFM.js.map +0 -1
- /package/dist/{chunk-5SBJCB6W.js.map → chunk-PWQVGAJB.js.map} +0 -0
|
@@ -0,0 +1,788 @@
|
|
|
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
|
+
problemStatement: string
|
|
350
|
+
/** Self-repro verify command (bash -c, cwd = ws) — the MEASUREMENT gate. */
|
|
351
|
+
verifyCmd: string
|
|
352
|
+
/** Root for runs/<iid>/<ARM>/ and patches/. */
|
|
353
|
+
outDir: string
|
|
354
|
+
secrets: SecretsEnv
|
|
355
|
+
excludes: string[]
|
|
356
|
+
signal?: AbortSignal
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export interface SoloArmResult {
|
|
360
|
+
arm: string
|
|
361
|
+
iid: string
|
|
362
|
+
oc_rc: number
|
|
363
|
+
wall_s: number
|
|
364
|
+
patch_lines: number
|
|
365
|
+
verify_rc: number
|
|
366
|
+
verify_pass: boolean
|
|
367
|
+
usage: SoloUsage
|
|
368
|
+
patchPath: string
|
|
369
|
+
ws: string
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export interface SupervisorArmResult {
|
|
373
|
+
arm: string
|
|
374
|
+
iid: string
|
|
375
|
+
driver_rc: number
|
|
376
|
+
wall_s: number
|
|
377
|
+
patch_lines: number
|
|
378
|
+
verify_rc: number
|
|
379
|
+
verify_pass: boolean
|
|
380
|
+
sup_status: string | null
|
|
381
|
+
sup_verdict: string | null
|
|
382
|
+
delivered: boolean | null
|
|
383
|
+
spentTokens: number | null
|
|
384
|
+
spentUsd: number | null
|
|
385
|
+
spawned: number
|
|
386
|
+
workers: number
|
|
387
|
+
settled: number
|
|
388
|
+
subtasks: string[]
|
|
389
|
+
patchPath: string
|
|
390
|
+
ws: string
|
|
391
|
+
provenance: ArmProvenance
|
|
392
|
+
recoveredSpend: SupSpend | null
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const patchLineCount = (patch: string): number => (patch.length === 0 ? 0 : patch.split('\n').length - (patch.endsWith('\n') ? 1 : 0))
|
|
396
|
+
|
|
397
|
+
type ProcessCompletion = Pick<RunResult, 'code' | 'timedOut' | 'aborted'>
|
|
398
|
+
|
|
399
|
+
/** Reject a solo process outcome before its partial workspace can be scored. */
|
|
400
|
+
export function assertSoloProcessCompleted(result: ProcessCompletion, label = 'runSoloArm'): void {
|
|
401
|
+
if (result.timedOut) {
|
|
402
|
+
throw new Error(`${label}: opencode timed out (exit ${result.code})`)
|
|
403
|
+
}
|
|
404
|
+
if (result.aborted) {
|
|
405
|
+
throw new Error(`${label}: opencode was cancelled (exit ${result.code})`)
|
|
406
|
+
}
|
|
407
|
+
if (result.code !== 0) {
|
|
408
|
+
throw new Error(`${label}: opencode exited ${result.code}`)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Reject a driver or unfinished supervisor state before its partial workspace can be scored. */
|
|
413
|
+
export function assertSupervisorProcessCompleted(
|
|
414
|
+
driver: ProcessCompletion,
|
|
415
|
+
status: string | null,
|
|
416
|
+
label = 'runSupervisorArm',
|
|
417
|
+
): void {
|
|
418
|
+
const state = status ?? 'missing'
|
|
419
|
+
if (driver.timedOut) {
|
|
420
|
+
throw new Error(`${label}: driver timed out (exit ${driver.code}, supervisor state ${state})`)
|
|
421
|
+
}
|
|
422
|
+
if (driver.aborted) {
|
|
423
|
+
throw new Error(`${label}: driver was cancelled (exit ${driver.code}, supervisor state ${state})`)
|
|
424
|
+
}
|
|
425
|
+
if (driver.code !== 0) {
|
|
426
|
+
throw new Error(`${label}: driver exited ${driver.code} (supervisor state ${state})`)
|
|
427
|
+
}
|
|
428
|
+
if (status !== 'completed') {
|
|
429
|
+
throw new Error(`${label}: supervisor state ${state}; expected completed`)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function verifyWorkspace(
|
|
434
|
+
verifyCmd: string,
|
|
435
|
+
ws: string,
|
|
436
|
+
logPath: string,
|
|
437
|
+
env: NodeJS.ProcessEnv,
|
|
438
|
+
signal?: AbortSignal,
|
|
439
|
+
): Promise<number> {
|
|
440
|
+
const res = await run('bash', ['-c', verifyCmd], { cwd: ws, timeoutMs: 600_000, env, signal })
|
|
441
|
+
await writeFile(logPath, res.stdout + res.stderr)
|
|
442
|
+
return res.code
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** solo.sh port: materialize → prompt → opencode run → patch extract → verify. */
|
|
446
|
+
export async function runSoloArm(spec: SoloArmSpec, ctx: ArmRunContext): Promise<SoloArmResult> {
|
|
447
|
+
const runDir = join(ctx.outDir, 'runs', ctx.instanceId, spec.name)
|
|
448
|
+
const ws = join(runDir, 'ws')
|
|
449
|
+
await mkdir(runDir, { recursive: true })
|
|
450
|
+
const cell = await prepareIsolatedCellEnvironment(runDir)
|
|
451
|
+
await materializeWorkspace({
|
|
452
|
+
instanceId: ctx.instanceId,
|
|
453
|
+
image: ctx.image,
|
|
454
|
+
baseCommit: ctx.baseCommit,
|
|
455
|
+
dest: ws,
|
|
456
|
+
signal: ctx.signal,
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
const promptFile = join(runDir, 'prompt.txt')
|
|
460
|
+
await writeFile(promptFile, ctx.problemStatement + (spec.promptSuffix ?? WORKER_PROMPT_SUFFIX))
|
|
461
|
+
|
|
462
|
+
const t0 = Date.now()
|
|
463
|
+
const oc = await dotenvxBash(
|
|
464
|
+
ctx.secrets,
|
|
465
|
+
`cd ${shq(ws)} && opencode run "$(cat ${shq(promptFile)})" -m ${shq(spec.model)} --format json`,
|
|
466
|
+
{ timeoutMs: spec.timeoutMs ?? 1_000_000, env: cell.env, signal: ctx.signal },
|
|
467
|
+
)
|
|
468
|
+
const wall_s = Math.round((Date.now() - t0) / 1000)
|
|
469
|
+
await writeFile(join(runDir, 'oc.jsonl'), oc.stdout)
|
|
470
|
+
await writeFile(join(runDir, 'oc.err'), oc.stderr)
|
|
471
|
+
assertSoloProcessCompleted(oc, `runSoloArm ${ctx.instanceId}/${spec.name}`)
|
|
472
|
+
if (ctx.signal?.aborted) throw ctx.signal.reason
|
|
473
|
+
|
|
474
|
+
const patch = await extractPatch(ws, ctx.baseCommit, ctx.excludes, ctx.signal)
|
|
475
|
+
const patchPath = join(ctx.outDir, 'patches', `${ctx.instanceId}.${spec.name.toLowerCase()}.patch`)
|
|
476
|
+
await mkdir(join(ctx.outDir, 'patches'), { recursive: true })
|
|
477
|
+
await writeFile(patchPath, patch)
|
|
478
|
+
|
|
479
|
+
const verify_rc = await verifyWorkspace(ctx.verifyCmd, ws, join(runDir, 'verify.log'), cell.env, ctx.signal)
|
|
480
|
+
return {
|
|
481
|
+
arm: spec.name,
|
|
482
|
+
iid: ctx.instanceId,
|
|
483
|
+
oc_rc: oc.code,
|
|
484
|
+
wall_s,
|
|
485
|
+
patch_lines: patchLineCount(patch),
|
|
486
|
+
verify_rc,
|
|
487
|
+
verify_pass: verify_rc === 0,
|
|
488
|
+
usage: parseOcUsage(oc.stdout),
|
|
489
|
+
patchPath,
|
|
490
|
+
ws,
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
interface SupervisorRunArtifacts {
|
|
495
|
+
status: string | null
|
|
496
|
+
verdict: string | null
|
|
497
|
+
delivered: boolean | null
|
|
498
|
+
spentTokens: number | null
|
|
499
|
+
spentUsd: number | null
|
|
500
|
+
spawned: number
|
|
501
|
+
workers: number
|
|
502
|
+
settled: number
|
|
503
|
+
subtasks: string[]
|
|
504
|
+
supRunDir: string | null
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Locate the (single) supervisor run dir under <ws>/.loops/supervisor. */
|
|
508
|
+
export async function findSupervisorRunDir(ws: string): Promise<string | null> {
|
|
509
|
+
const root = join(ws, '.loops', 'supervisor')
|
|
510
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => [])
|
|
511
|
+
const dirs = entries.filter((e) => e.isDirectory()).map((e) => join(root, e.name))
|
|
512
|
+
return dirs[0] ?? null
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** sup4.sh's state.json + journal.jsonl summary (spawned/settled/workers/goals). */
|
|
516
|
+
export async function parseSupervisorArtifacts(ws: string): Promise<SupervisorRunArtifacts> {
|
|
517
|
+
const supRunDir = await findSupervisorRunDir(ws)
|
|
518
|
+
let status: string | null = null
|
|
519
|
+
let verdict: string | null = null
|
|
520
|
+
let delivered: boolean | null = null
|
|
521
|
+
let spentTokens: number | null = null
|
|
522
|
+
let spentUsd: number | null = null
|
|
523
|
+
let spawned = 0
|
|
524
|
+
let workers = 0
|
|
525
|
+
let settled = 0
|
|
526
|
+
const subtasks: string[] = []
|
|
527
|
+
if (supRunDir) {
|
|
528
|
+
let state: Record<string, unknown> = {}
|
|
529
|
+
try {
|
|
530
|
+
state = JSON.parse(await readFile(join(supRunDir, 'state.json'), 'utf8')) as Record<string, unknown>
|
|
531
|
+
} catch {
|
|
532
|
+
// Absent/corrupt state.json (driver killed mid-write) = all-null summary.
|
|
533
|
+
}
|
|
534
|
+
status = typeof state.status === 'string' ? state.status : null
|
|
535
|
+
verdict = typeof state.verdict === 'string' ? state.verdict : null
|
|
536
|
+
const result = (state.result ?? {}) as Record<string, unknown>
|
|
537
|
+
delivered = typeof result.delivered === 'boolean' ? result.delivered : null
|
|
538
|
+
spentTokens = typeof result.spentTokens === 'number' ? result.spentTokens : null
|
|
539
|
+
spentUsd = typeof result.spentUsd === 'number' ? result.spentUsd : null
|
|
540
|
+
const journal = await readFile(join(supRunDir, 'journal.jsonl'), 'utf8').catch(() => '')
|
|
541
|
+
for (const line of journal.split('\n')) {
|
|
542
|
+
if (!line.trim()) continue
|
|
543
|
+
let o: Record<string, unknown>
|
|
544
|
+
try {
|
|
545
|
+
o = JSON.parse(line) as Record<string, unknown>
|
|
546
|
+
} catch {
|
|
547
|
+
continue
|
|
548
|
+
}
|
|
549
|
+
const kind = o.kind
|
|
550
|
+
const label = typeof o.label === 'string' ? o.label : ''
|
|
551
|
+
if (kind === 'spawned') {
|
|
552
|
+
spawned += 1
|
|
553
|
+
if (label && label !== 'root') {
|
|
554
|
+
workers += 1
|
|
555
|
+
subtasks.push(label.slice(0, 100))
|
|
556
|
+
}
|
|
557
|
+
} else if (kind === 'settled') {
|
|
558
|
+
settled += 1
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return { status, verdict, delivered, spentTokens, spentUsd, spawned, workers, settled, subtasks, supRunDir }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* sup4.sh port: materialize → params.json → loops driver via the tracked shim
|
|
567
|
+
* (env knobs incl. LOOPS_BRAIN_RETRIES / DRIVER_DEADLINE_MS) → patch extract →
|
|
568
|
+
* verify → cost recovery. The driver runs with cwd = the loops repo, exactly
|
|
569
|
+
* like the bash (`cd /home/drew/code/loops && node --import tsx …`).
|
|
570
|
+
*/
|
|
571
|
+
export async function runSupervisorArm(spec: SupervisorArmSpec, ctx: ArmRunContext): Promise<SupervisorArmResult> {
|
|
572
|
+
const loopsRepo = spec.loopsRepo ?? DEFAULT_LOOPS_REPO
|
|
573
|
+
const extensionPath = spec.extensionPath ?? join(loopsRepo, 'extensions', 'pi', 'loops.ts')
|
|
574
|
+
const envKnobs = { ...SUP_DEFAULT_ENV_KNOBS, ...spec.envKnobs }
|
|
575
|
+
const provenance = await armProvenance(loopsRepo, envKnobs, ctx.signal)
|
|
576
|
+
|
|
577
|
+
const runDir = join(ctx.outDir, 'runs', ctx.instanceId, spec.name)
|
|
578
|
+
const ws = join(runDir, 'ws')
|
|
579
|
+
await mkdir(runDir, { recursive: true })
|
|
580
|
+
const cell = await prepareIsolatedCellEnvironment(runDir)
|
|
581
|
+
await materializeWorkspace({
|
|
582
|
+
instanceId: ctx.instanceId,
|
|
583
|
+
image: ctx.image,
|
|
584
|
+
baseCommit: ctx.baseCommit,
|
|
585
|
+
dest: ws,
|
|
586
|
+
signal: ctx.signal,
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
const paramsFile = join(runDir, 'params.json')
|
|
590
|
+
await writeFile(
|
|
591
|
+
paramsFile,
|
|
592
|
+
JSON.stringify(
|
|
593
|
+
{
|
|
594
|
+
task: ctx.problemStatement,
|
|
595
|
+
workspaceDir: ws,
|
|
596
|
+
budget: spec.budget ?? 40,
|
|
597
|
+
verifyCmd: ctx.verifyCmd,
|
|
598
|
+
workerModel: spec.workerModel,
|
|
599
|
+
driverModel: spec.driverModel,
|
|
600
|
+
maxSandboxes: spec.maxSandboxes ?? 4,
|
|
601
|
+
maxUsd: spec.maxUsd ?? 8,
|
|
602
|
+
maxDepth: spec.maxDepth ?? 3,
|
|
603
|
+
},
|
|
604
|
+
null,
|
|
605
|
+
1,
|
|
606
|
+
),
|
|
607
|
+
)
|
|
608
|
+
await rm(join(ws, '.loops'), { recursive: true, force: true })
|
|
609
|
+
|
|
610
|
+
const knobExports = Object.entries({ ...envKnobs, LOOPS_BRAIN_LOG: join(runDir, 'brain.jsonl') })
|
|
611
|
+
.map(([k, v]) => {
|
|
612
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(k)) throw new Error(`invalid env knob name: ${k}`)
|
|
613
|
+
return `export ${k}=${shq(v)}`
|
|
614
|
+
})
|
|
615
|
+
.join('\n')
|
|
616
|
+
const script = [
|
|
617
|
+
knobExports,
|
|
618
|
+
`cd ${shq(loopsRepo)}`,
|
|
619
|
+
`node --import tsx ${shq(runSupervisorShim)} ${shq(extensionPath)} ${shq(ws)} ${shq(paramsFile)}`,
|
|
620
|
+
].join('\n')
|
|
621
|
+
|
|
622
|
+
const t0 = Date.now()
|
|
623
|
+
const driver = await dotenvxBash(ctx.secrets, script, {
|
|
624
|
+
timeoutMs: spec.timeoutMs ?? 2_800_000,
|
|
625
|
+
killGraceMs: supervisorDriverKillGraceMs(envKnobs),
|
|
626
|
+
env: cell.env,
|
|
627
|
+
signal: ctx.signal,
|
|
628
|
+
})
|
|
629
|
+
const wall_s = Math.round((Date.now() - t0) / 1000)
|
|
630
|
+
await writeFile(join(runDir, 'driver.log'), driver.stdout + driver.stderr)
|
|
631
|
+
|
|
632
|
+
const artifacts = await parseSupervisorArtifacts(ws)
|
|
633
|
+
assertSupervisorProcessCompleted(
|
|
634
|
+
driver,
|
|
635
|
+
artifacts.status,
|
|
636
|
+
`runSupervisorArm ${ctx.instanceId}/${spec.name}`,
|
|
637
|
+
)
|
|
638
|
+
if (ctx.signal?.aborted) throw ctx.signal.reason
|
|
639
|
+
|
|
640
|
+
const patch = await extractPatch(ws, ctx.baseCommit, ctx.excludes, ctx.signal)
|
|
641
|
+
const patchPath = join(ctx.outDir, 'patches', `${ctx.instanceId}.${spec.name.toLowerCase()}.patch`)
|
|
642
|
+
await mkdir(join(ctx.outDir, 'patches'), { recursive: true })
|
|
643
|
+
await writeFile(patchPath, patch)
|
|
644
|
+
|
|
645
|
+
const verify_rc = await verifyWorkspace(ctx.verifyCmd, ws, join(runDir, 'verify.log'), cell.env, ctx.signal)
|
|
646
|
+
|
|
647
|
+
const recoveredSpend = artifacts.supRunDir
|
|
648
|
+
? await recoverSupSpend(artifacts.supRunDir, { opencodeDb: cell.opencodeDb }).catch(() => null)
|
|
649
|
+
: null
|
|
650
|
+
|
|
651
|
+
return {
|
|
652
|
+
arm: spec.name,
|
|
653
|
+
iid: ctx.instanceId,
|
|
654
|
+
driver_rc: driver.code,
|
|
655
|
+
wall_s,
|
|
656
|
+
patch_lines: patchLineCount(patch),
|
|
657
|
+
verify_rc,
|
|
658
|
+
verify_pass: verify_rc === 0,
|
|
659
|
+
sup_status: artifacts.status,
|
|
660
|
+
sup_verdict: artifacts.verdict,
|
|
661
|
+
delivered: artifacts.delivered,
|
|
662
|
+
spentTokens: artifacts.spentTokens,
|
|
663
|
+
spentUsd: artifacts.spentUsd,
|
|
664
|
+
spawned: artifacts.spawned,
|
|
665
|
+
workers: artifacts.workers,
|
|
666
|
+
settled: artifacts.settled,
|
|
667
|
+
subtasks: artifacts.subtasks,
|
|
668
|
+
patchPath,
|
|
669
|
+
ws,
|
|
670
|
+
provenance,
|
|
671
|
+
recoveredSpend,
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ---------------------------------------------------------------------------
|
|
676
|
+
// Cost recovery — true_sup_spend.py port + the opencode sqlite worker join.
|
|
677
|
+
// ---------------------------------------------------------------------------
|
|
678
|
+
|
|
679
|
+
export interface SupSpend {
|
|
680
|
+
/** Worker tokens the journal itself captured (`settled` spend) — often 0. */
|
|
681
|
+
workerTokJournal: number
|
|
682
|
+
/** Worker clone cwds found in workers/*.ndjson `started` events. */
|
|
683
|
+
workerCwds: string[]
|
|
684
|
+
/**
|
|
685
|
+
* Worker-session spend recovered from the opencode sqlite store by joining
|
|
686
|
+
* ndjson cwd → session.directory (the provenance of worker-tokens.json).
|
|
687
|
+
* `null` = the store was unavailable — a telemetry gap, never a zero.
|
|
688
|
+
* `workerTokIn`/`workerTokOut` split the same sessions so the campaign
|
|
689
|
+
* CostLedger receipt carries a real input/output usage, not a lump.
|
|
690
|
+
*/
|
|
691
|
+
workerSessions: number | null
|
|
692
|
+
workerTokSqlite: number | null
|
|
693
|
+
workerTokIn: number | null
|
|
694
|
+
workerTokOut: number | null
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export const DEFAULT_OPENCODE_DB = join(homedir(), '.local', 'share', 'opencode', 'opencode.db')
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Recover the WORKER-side SUP-arm spend from a supervisor run dir
|
|
701
|
+
* (<ws>/.loops/supervisor/<id>): ndjson settled spend where captured, plus the
|
|
702
|
+
* opencode sqlite session totals for each worker clone cwd. CLI-backend worker
|
|
703
|
+
* sessions never meter into the journal, so the sqlite join is genuinely
|
|
704
|
+
* custom recovery. Brain/driver spend is NO LONGER re-parsed from journal
|
|
705
|
+
* `metered` events: the supervise runtime's spend tree now reaches state.json
|
|
706
|
+
* on BOTH result arms (loops extensions/pi/loops.ts writes
|
|
707
|
+
* `result.spentTokens`/`spentUsd` from `SupervisedResult.spentTotal`, winner
|
|
708
|
+
* AND no-winner), so `parseSupervisorArtifacts` already carries it — the old
|
|
709
|
+
* no-winner zeroing this parsing compensated for is fixed upstream.
|
|
710
|
+
*/
|
|
711
|
+
export async function recoverSupSpend(
|
|
712
|
+
supRunDir: string,
|
|
713
|
+
opts: { opencodeDb?: string } = {},
|
|
714
|
+
): Promise<SupSpend> {
|
|
715
|
+
let workerTokJournal = 0
|
|
716
|
+
const workerCwds: string[] = []
|
|
717
|
+
const workersDir = join(supRunDir, 'workers')
|
|
718
|
+
const workerFiles = (await readdir(workersDir).catch(() => [])).filter((f) => f.endsWith('.ndjson'))
|
|
719
|
+
for (const f of workerFiles) {
|
|
720
|
+
const nd = await readFile(join(workersDir, f), 'utf8').catch(() => '')
|
|
721
|
+
for (const line of nd.split('\n')) {
|
|
722
|
+
if (!line.trim()) continue
|
|
723
|
+
let o: Record<string, unknown>
|
|
724
|
+
try {
|
|
725
|
+
o = JSON.parse(line) as Record<string, unknown>
|
|
726
|
+
} catch {
|
|
727
|
+
continue
|
|
728
|
+
}
|
|
729
|
+
if (o.kind === 'settled') {
|
|
730
|
+
const tokens = ((o.spend ?? {}) as Record<string, unknown>).tokens as Record<string, unknown> | undefined
|
|
731
|
+
workerTokJournal +=
|
|
732
|
+
(typeof tokens?.input === 'number' ? tokens.input : 0) +
|
|
733
|
+
(typeof tokens?.output === 'number' ? tokens.output : 0)
|
|
734
|
+
} else if (o.kind === 'started' && typeof o.cwd === 'string') {
|
|
735
|
+
workerCwds.push(o.cwd)
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
let workerSessions: number | null = null
|
|
741
|
+
let workerTokSqlite: number | null = null
|
|
742
|
+
let workerTokIn: number | null = null
|
|
743
|
+
let workerTokOut: number | null = null
|
|
744
|
+
if (workerCwds.length > 0) {
|
|
745
|
+
try {
|
|
746
|
+
// Deferred import: node:sqlite is only needed on the cost-recovery path.
|
|
747
|
+
const { DatabaseSync } = await import('node:sqlite')
|
|
748
|
+
const db = new DatabaseSync(opts.opencodeDb ?? DEFAULT_OPENCODE_DB, { readOnly: true })
|
|
749
|
+
try {
|
|
750
|
+
const placeholders = workerCwds.map(() => '?').join(',')
|
|
751
|
+
const row = db
|
|
752
|
+
.prepare(
|
|
753
|
+
`SELECT COUNT(*) AS n,
|
|
754
|
+
COALESCE(SUM(tokens_input), 0) AS tin,
|
|
755
|
+
COALESCE(SUM(tokens_output + tokens_reasoning), 0) AS tout
|
|
756
|
+
FROM session WHERE directory IN (${placeholders})`,
|
|
757
|
+
)
|
|
758
|
+
.get(...workerCwds) as { n: number | bigint; tin: number | bigint; tout: number | bigint }
|
|
759
|
+
workerSessions = Number(row.n)
|
|
760
|
+
workerTokIn = Number(row.tin)
|
|
761
|
+
workerTokOut = Number(row.tout)
|
|
762
|
+
workerTokSqlite = workerTokIn + workerTokOut
|
|
763
|
+
} finally {
|
|
764
|
+
db.close()
|
|
765
|
+
}
|
|
766
|
+
} catch {
|
|
767
|
+
// Store unavailable/corrupt → telemetry gap (null), never a silent 0.
|
|
768
|
+
workerSessions = null
|
|
769
|
+
workerTokSqlite = null
|
|
770
|
+
workerTokIn = null
|
|
771
|
+
workerTokOut = null
|
|
772
|
+
}
|
|
773
|
+
} else {
|
|
774
|
+
workerSessions = 0
|
|
775
|
+
workerTokSqlite = 0
|
|
776
|
+
workerTokIn = 0
|
|
777
|
+
workerTokOut = 0
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
workerTokJournal,
|
|
782
|
+
workerCwds,
|
|
783
|
+
workerSessions,
|
|
784
|
+
workerTokSqlite,
|
|
785
|
+
workerTokIn,
|
|
786
|
+
workerTokOut,
|
|
787
|
+
}
|
|
788
|
+
}
|