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