@tangle-network/agent-bench 0.3.7 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/HARNESS.md +43 -0
- package/dist/adapters.js +23 -23
- package/dist/benchmarks/_harness.d.ts +1 -1
- package/dist/benchmarks/_harness.js +1 -1
- package/dist/benchmarks/aec-bench.js +2 -2
- package/dist/benchmarks/agentbench.js +2 -2
- package/dist/benchmarks/appworld.js +2 -2
- package/dist/benchmarks/bfcl.js +2 -2
- package/dist/benchmarks/commit0.js +2 -2
- package/dist/benchmarks/crag.js +2 -2
- package/dist/benchmarks/dabstep.js +2 -2
- package/dist/benchmarks/enterpriseops-gym.js +2 -2
- package/dist/benchmarks/finresearchbench.js +2 -2
- package/dist/benchmarks/nomiracl.js +2 -2
- package/dist/benchmarks/open-rag-bench.js +2 -2
- package/dist/benchmarks/programbench.js +2 -2
- package/dist/benchmarks/ragbench.js +2 -2
- package/dist/benchmarks/swe-bench.js +2 -2
- package/dist/benchmarks/t2-ragbench.js +2 -2
- package/dist/benchmarks/tau-bench-shared.js +2 -2
- package/dist/benchmarks/tau2-bench.js +3 -3
- package/dist/benchmarks/tau3-banking.js +3 -3
- package/dist/benchmarks/terminal-bench.js +2 -2
- package/dist/benchmarks/toollm.js +2 -2
- package/dist/benchmarks/webarena-verified.js +2 -2
- package/dist/{chunk-CKUVRZ2T.js → chunk-3U5TXJZS.js} +2 -2
- package/dist/{chunk-YCGY7UIZ.js → chunk-7GRVHU22.js} +2 -2
- package/dist/{chunk-Z7ML6L77.js → chunk-HWST3SED.js} +2 -2
- package/dist/{chunk-SYDW647C.js → chunk-IA2FBTWC.js} +2 -2
- package/dist/{chunk-R67DFVLO.js → chunk-IFVINJ4B.js} +2 -2
- package/dist/{chunk-R36V2VP7.js → chunk-IZ5M6OAC.js} +2 -2
- package/dist/{chunk-ODT47UAY.js → chunk-K3BQGZCT.js} +2 -2
- package/dist/{chunk-IFAV6KEM.js → chunk-KP5KD6EN.js} +2 -2
- package/dist/{chunk-ZEWMTR5M.js → chunk-MQMRLGOG.js} +2 -2
- package/dist/{chunk-TSWPNOYM.js → chunk-NQG5XDSB.js} +2 -2
- package/dist/{chunk-7WSD27QQ.js → chunk-PB64GYIG.js} +2 -2
- package/dist/{chunk-HBSWHQNJ.js → chunk-RCYQEFNX.js} +3 -3
- package/dist/{chunk-J3KDJNX2.js → chunk-RH5F53JT.js} +2 -2
- package/dist/{chunk-UAIOHCUK.js → chunk-SFLA7OH3.js} +3 -3
- package/dist/{chunk-Y6O2OCUO.js → chunk-SHM6MRRF.js} +2 -2
- package/dist/{chunk-KDIKRJGB.js → chunk-SHYIRB7I.js} +2 -2
- package/dist/{chunk-HHXFIHXC.js → chunk-SVR2LKYI.js} +2 -2
- package/dist/{chunk-PWQVGAJB.js → chunk-V7AEBY6U.js} +21 -21
- package/dist/{chunk-LRRD7NAG.js → chunk-WSKWVEQB.js} +18 -2
- package/dist/chunk-WSKWVEQB.js.map +1 -0
- package/dist/{chunk-2PVVP7GN.js → chunk-XKEFIFIC.js} +2 -2
- package/dist/{chunk-JRWWGMK7.js → chunk-XYA4XSNU.js} +2 -2
- package/dist/{chunk-X5YKXC6V.js → chunk-YSMEKBTD.js} +2 -2
- package/dist/{chunk-2XU6OGEN.js → chunk-Z4TZ76N7.js} +2 -2
- package/dist/index.js +23 -23
- package/package.json +4 -4
- package/scripts/verify-packed-consumer.mjs +1 -1
- package/src/benchmarks/_harness.ts +20 -2
- package/src/benchmarks/humaneval.test.mts +2 -2
- package/src/quant-arena/README.md +144 -0
- package/src/quant-arena/backtest.test.mts +135 -0
- package/src/quant-arena/backtest.ts +218 -0
- package/src/quant-arena/data.test.mts +44 -0
- package/src/quant-arena/data.ts +141 -0
- package/src/quant-arena/driver.test.mts +253 -0
- package/src/quant-arena/driver.ts +219 -0
- package/src/quant-arena/fixtures/data/PROVENANCE.md +26 -0
- package/src/quant-arena/fixtures/data/holdout/IDX.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S01.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S02.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S03.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S04.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S05.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S06.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S07.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S08.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S09.csv +523 -0
- package/src/quant-arena/fixtures/data/holdout/S10.csv +523 -0
- package/src/quant-arena/fixtures/data/insample/IDX.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S01.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S02.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S03.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S04.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S05.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S06.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S07.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S08.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S09.csv +2087 -0
- package/src/quant-arena/fixtures/data/insample/S10.csv +2087 -0
- package/src/quant-arena/fixtures/demo-campaign/cost-ledger.jsonl +16 -0
- package/src/quant-arena/fixtures/demo-campaign/notebook.jsonl +5 -0
- package/src/quant-arena/fixtures/demo-campaign/rollout-manifest.json +171 -0
- package/src/quant-arena/fixtures/demo-campaign/strategies/cand-001-default-author/strategy.ts +119 -0
- package/src/quant-arena/fixtures/demo-campaign/strategies/cand-002-default-author/strategy.ts +119 -0
- package/src/quant-arena/fixtures/demo-campaign/strategies/cand-003-quant-researcher/strategy.ts +105 -0
- package/src/quant-arena/fixtures/demo-campaign/strategies/cand-004-quant-researcher/strategy.ts +102 -0
- package/src/quant-arena/fixtures/demo-campaign-v2/cost-ledger.jsonl +4 -0
- package/src/quant-arena/fixtures/demo-campaign-v2/notebook.jsonl +2 -0
- package/src/quant-arena/fixtures/demo-campaign-v2/rollout-manifest.json +84 -0
- package/src/quant-arena/fixtures/demo-campaign-v2/strategies/cand-001-quant-researcher/strategy.ts +117 -0
- package/src/quant-arena/holdout-certify.mts +206 -0
- package/src/quant-arena/holdout-certify.test.mts +82 -0
- package/src/quant-arena/leak-audit.test.mts +79 -0
- package/src/quant-arena/leak-audit.ts +95 -0
- package/src/quant-arena/make-fixtures.mts +161 -0
- package/src/quant-arena/multiplicity.test.mts +68 -0
- package/src/quant-arena/multiplicity.ts +87 -0
- package/src/quant-arena/nautilus-certify.ts +31 -0
- package/src/quant-arena/oms.ts +90 -0
- package/src/quant-arena/profiles/quant-researcher.profile.json +7 -0
- package/src/quant-arena/python/pyproject.toml +8 -0
- package/src/quant-arena/python/uv.lock +1297 -0
- package/src/quant-arena/python/vbt-worker.py +192 -0
- package/src/quant-arena/quant-loop.mts +813 -0
- package/src/quant-arena/quant-loop.test.mts +75 -0
- package/src/quant-arena/strategies/buy-hold-index/strategy.ts +11 -0
- package/src/quant-arena/strategies/equal-weight/strategy.ts +20 -0
- package/src/quant-arena/strategies/sma-crossover/strategy.ts +42 -0
- package/src/quant-arena/types.ts +133 -0
- package/src/quant-arena/vbt-client.ts +321 -0
- package/src/quant-arena/vbt-parity.test.mts +183 -0
- package/src/quant-arena/windows.test.mts +45 -0
- package/src/quant-arena/windows.ts +54 -0
- package/src/rollout-ledger/backfill-swe-arena.mts +606 -0
- package/src/rollout-ledger/backfill-swe-arena.test.mts +338 -0
- package/src/rollout-ledger/settle-capture.mts +442 -0
- package/src/rollout-ledger/settle-capture.test.mts +270 -0
- package/src/swe-arena/activation.mts +228 -0
- package/src/swe-arena/activation.test.mts +303 -0
- package/src/swe-arena/arms.ts +30 -14
- package/src/swe-arena/briefing.mts +217 -0
- package/src/swe-arena/briefing.test.mts +178 -0
- package/src/swe-arena/calibrate.ts +102 -1
- package/src/swe-arena/cell-evidence.mts +41 -9
- package/src/swe-arena/factory-command-container.ts +284 -0
- package/src/swe-arena/factory-judge-child.mts +228 -0
- package/src/swe-arena/factory.test.mts +643 -0
- package/src/swe-arena/fixtures/factory/agent-eval-309/calibration.md +51 -0
- package/src/swe-arena/fixtures/factory/agent-eval-309/manifest.json +29 -0
- package/src/swe-arena/fixtures/factory/agent-eval-309/spec.md +64 -0
- package/src/swe-arena/fixtures/factory/agent-runtime-232/calibration.md +48 -0
- package/src/swe-arena/fixtures/factory/agent-runtime-232/manifest.json +29 -0
- package/src/swe-arena/fixtures/factory/agent-runtime-232/spec.md +48 -0
- package/src/swe-arena/fixtures/factory/loops-28/calibration.md +47 -0
- package/src/swe-arena/fixtures/factory/loops-28/manifest.json +30 -0
- package/src/swe-arena/fixtures/factory/loops-28/spec.md +50 -0
- package/src/swe-arena/fixtures/run-report/README.md +43 -0
- package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.json +173 -0
- package/src/swe-arena/fixtures/run-report/factory-agent-eval-309-FSUP0.md +100 -0
- package/src/swe-arena/fixtures/run-report/gen3-rollup.json +551 -0
- package/src/swe-arena/fixtures/run-report/gen3-rollup.md +64 -0
- package/src/swe-arena/fixtures.ts +135 -2
- package/src/swe-arena/gepa-seat.mts +583 -0
- package/src/swe-arena/gepa-seat.test.mts +635 -0
- package/src/swe-arena/ledger-orphans.mts +77 -0
- package/src/swe-arena/ledger-orphans.test.mts +147 -0
- package/src/swe-arena/lineage-record.mts +164 -0
- package/src/swe-arena/lineage-record.test.mts +115 -0
- package/src/swe-arena/outer-loop.mts +677 -27
- package/src/swe-arena/premeasured-from-cells.mts +281 -0
- package/src/swe-arena/premeasured-from-cells.test.mts +180 -0
- package/src/swe-arena/proposer-fanout.mts +295 -14
- package/src/swe-arena/proposer-fanout.test.mts +247 -0
- package/src/swe-arena/proposer-provenance.mts +177 -0
- package/src/swe-arena/proposer-provenance.test.mts +106 -0
- package/src/swe-arena/run-experiment.mts +367 -1
- package/src/swe-arena/run-report.mts +75 -0
- package/src/swe-arena/run-supervisor.test.mts +5 -3
- package/src/swe-arena/score-split.mts +140 -0
- package/src/swe-arena/score-split.test.mts +123 -0
- package/src/swe-arena/types.ts +52 -0
- package/src/swe-structural.mts +245 -837
- package/dist/chunk-LRRD7NAG.js.map +0 -1
- /package/dist/{chunk-CKUVRZ2T.js.map → chunk-3U5TXJZS.js.map} +0 -0
- /package/dist/{chunk-YCGY7UIZ.js.map → chunk-7GRVHU22.js.map} +0 -0
- /package/dist/{chunk-Z7ML6L77.js.map → chunk-HWST3SED.js.map} +0 -0
- /package/dist/{chunk-SYDW647C.js.map → chunk-IA2FBTWC.js.map} +0 -0
- /package/dist/{chunk-R67DFVLO.js.map → chunk-IFVINJ4B.js.map} +0 -0
- /package/dist/{chunk-R36V2VP7.js.map → chunk-IZ5M6OAC.js.map} +0 -0
- /package/dist/{chunk-ODT47UAY.js.map → chunk-K3BQGZCT.js.map} +0 -0
- /package/dist/{chunk-IFAV6KEM.js.map → chunk-KP5KD6EN.js.map} +0 -0
- /package/dist/{chunk-ZEWMTR5M.js.map → chunk-MQMRLGOG.js.map} +0 -0
- /package/dist/{chunk-TSWPNOYM.js.map → chunk-NQG5XDSB.js.map} +0 -0
- /package/dist/{chunk-7WSD27QQ.js.map → chunk-PB64GYIG.js.map} +0 -0
- /package/dist/{chunk-HBSWHQNJ.js.map → chunk-RCYQEFNX.js.map} +0 -0
- /package/dist/{chunk-J3KDJNX2.js.map → chunk-RH5F53JT.js.map} +0 -0
- /package/dist/{chunk-UAIOHCUK.js.map → chunk-SFLA7OH3.js.map} +0 -0
- /package/dist/{chunk-Y6O2OCUO.js.map → chunk-SHM6MRRF.js.map} +0 -0
- /package/dist/{chunk-KDIKRJGB.js.map → chunk-SHYIRB7I.js.map} +0 -0
- /package/dist/{chunk-HHXFIHXC.js.map → chunk-SVR2LKYI.js.map} +0 -0
- /package/dist/{chunk-PWQVGAJB.js.map → chunk-V7AEBY6U.js.map} +0 -0
- /package/dist/{chunk-2PVVP7GN.js.map → chunk-XKEFIFIC.js.map} +0 -0
- /package/dist/{chunk-JRWWGMK7.js.map → chunk-XYA4XSNU.js.map} +0 -0
- /package/dist/{chunk-X5YKXC6V.js.map → chunk-YSMEKBTD.js.map} +0 -0
- /package/dist/{chunk-2XU6OGEN.js.map → chunk-Z4TZ76N7.js.map} +0 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gen-5 MAP+TOOLBOX briefing: evidence-index generation (one line per
|
|
3
|
+
* evidence path, private instances excluded, leak guard fail-loud), the
|
|
4
|
+
* toolbox/permission prompt section, and the change-space briefing override.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
12
|
+
import {
|
|
13
|
+
AUTHOR_BRIEFING_RELPATH,
|
|
14
|
+
AUTHOR_BRIEFING_VERSION,
|
|
15
|
+
briefingPromptSection,
|
|
16
|
+
collectEvidenceIndexRows,
|
|
17
|
+
defaultAuthorBriefing,
|
|
18
|
+
EVIDENCE_INDEX_FILENAME,
|
|
19
|
+
resolveAuthorBriefing,
|
|
20
|
+
writeEvidenceIndex,
|
|
21
|
+
} from './briefing.mts'
|
|
22
|
+
import { splitInstances } from './score-split.mts'
|
|
23
|
+
import { runOk } from './proc.ts'
|
|
24
|
+
|
|
25
|
+
const SIX = [
|
|
26
|
+
'astropy__astropy-13033',
|
|
27
|
+
'django__django-11532',
|
|
28
|
+
'matplotlib__matplotlib-20826',
|
|
29
|
+
'pydata__xarray-4687',
|
|
30
|
+
'pytest-dev__pytest-6197',
|
|
31
|
+
'sphinx-doc__sphinx-9658',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
describe('evidence index', () => {
|
|
35
|
+
let root: string
|
|
36
|
+
let outDir: string
|
|
37
|
+
let roundsDir: string
|
|
38
|
+
let priorDir: string
|
|
39
|
+
const split = splitInstances('r4-seed', SIX, 4)
|
|
40
|
+
const publicIid = split.publicInstances[0]!
|
|
41
|
+
const privateIid = split.privateInstances[0]!
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
root = await mkdtemp(join(tmpdir(), 'briefing-'))
|
|
45
|
+
outDir = join(root, 'out')
|
|
46
|
+
roundsDir = join(root, 'rounds')
|
|
47
|
+
priorDir = join(root, 'gen4')
|
|
48
|
+
await mkdir(outDir, { recursive: true })
|
|
49
|
+
await mkdir(roundsDir, { recursive: true })
|
|
50
|
+
await writeFile(join(roundsDir, 'gen-0.jsonl'), '{}\n')
|
|
51
|
+
await writeFile(join(roundsDir, 'round4-summary-r4-x.json'), '{}\n')
|
|
52
|
+
// Seed artifact dirs: one public, one private instance.
|
|
53
|
+
for (const iid of [publicIid, privateIid]) {
|
|
54
|
+
await mkdir(join(root, 'runs', iid, 'SUP4'), { recursive: true })
|
|
55
|
+
await writeFile(join(root, 'patches', `${iid}.sup4.patch`), 'p\n').catch(async () => {
|
|
56
|
+
await mkdir(join(root, 'patches'), { recursive: true })
|
|
57
|
+
await writeFile(join(root, 'patches', `${iid}.sup4.patch`), 'p\n')
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
// Prior run outDir with arm-run judge evidence for a public + a private instance.
|
|
61
|
+
for (const iid of [publicIid, privateIid]) {
|
|
62
|
+
const runDir = join(priorDir, 'arm-runs', 'cand111111', 'rep-0', 'runs', iid)
|
|
63
|
+
await mkdir(runDir, { recursive: true })
|
|
64
|
+
await writeFile(join(runDir, 'judge.json'), '{}\n')
|
|
65
|
+
}
|
|
66
|
+
await mkdir(join(priorDir, 'candidates'), { recursive: true })
|
|
67
|
+
await mkdir(join(priorDir, 'proposer-shots'), { recursive: true })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
afterEach(async () => {
|
|
71
|
+
await rm(root, { recursive: true, force: true })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const args = () => ({
|
|
75
|
+
outDir,
|
|
76
|
+
roundsDir,
|
|
77
|
+
seedArtifactRuns: [publicIid, privateIid].map((iid) => ({
|
|
78
|
+
iid,
|
|
79
|
+
arm: 'SUP4',
|
|
80
|
+
dir: join(root, 'runs', iid, 'SUP4'),
|
|
81
|
+
patchPath: join(root, 'patches', `${iid}.sup4.patch`),
|
|
82
|
+
})),
|
|
83
|
+
priorEvidenceDirs: [priorDir],
|
|
84
|
+
paretoParentPatches: [] as Array<{ label: string; path: string }>,
|
|
85
|
+
split,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('lists one line per evidence path: staircase, summaries, seed runs, prior arm runs, shot receipts', async () => {
|
|
89
|
+
const { path, rows } = await writeEvidenceIndex(args())
|
|
90
|
+
expect(path).toBe(join(outDir, EVIDENCE_INDEX_FILENAME))
|
|
91
|
+
expect(existsSync(path)).toBe(true)
|
|
92
|
+
const text = await readFile(path, 'utf8')
|
|
93
|
+
expect(text).toContain(join(roundsDir, 'gen-0.jsonl'))
|
|
94
|
+
expect(text).toContain(join(roundsDir, 'round4-summary-r4-x.json'))
|
|
95
|
+
expect(text).toContain(join(root, 'runs', publicIid, 'SUP4'))
|
|
96
|
+
expect(text).toContain(join(root, 'patches', `${publicIid}.sup4.patch`))
|
|
97
|
+
expect(text).toContain(join(priorDir, 'candidates'))
|
|
98
|
+
expect(text).toContain(join(priorDir, 'proposer-shots'))
|
|
99
|
+
expect(text).toContain(join(priorDir, 'arm-runs', 'cand111111', 'rep-0', 'runs', publicIid))
|
|
100
|
+
// One markdown bullet per row.
|
|
101
|
+
expect(text.split('\n').filter((l) => l.startsWith('- ')).length).toBe(rows.length)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('NEVER surfaces private instances: no private row, no private id anywhere in the rendered text', async () => {
|
|
105
|
+
const { path, rows } = await writeEvidenceIndex(args())
|
|
106
|
+
const text = await readFile(path, 'utf8')
|
|
107
|
+
for (const iid of split.privateInstances) expect(text).not.toContain(iid)
|
|
108
|
+
expect(rows.every((r) => r.iid === null || !split.privateInstances.includes(r.iid))).toBe(true)
|
|
109
|
+
// The withholding itself is disclosed (count only, no identities).
|
|
110
|
+
expect(text).toContain('2 improvement instance(s) are PRIVATE')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('fails loud when a run-level path would leak a private id into the rendered index', async () => {
|
|
114
|
+
const leakPatch = join(outDir, `${privateIid}.parent.patch`)
|
|
115
|
+
await writeFile(leakPatch, 'x\n')
|
|
116
|
+
await expect(
|
|
117
|
+
writeEvidenceIndex({ ...args(), paretoParentPatches: [{ label: 'leaky', path: leakPatch }] }),
|
|
118
|
+
).rejects.toThrow(/leaked/)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('collectEvidenceIndexRows includes everything when no split is configured', async () => {
|
|
122
|
+
const rows = await collectEvidenceIndexRows({ ...args(), split: null })
|
|
123
|
+
const iids = rows.map((r) => r.iid).filter((i): i is string => i !== null)
|
|
124
|
+
expect(iids).toContain(privateIid)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('briefing text', () => {
|
|
129
|
+
it('default briefing names the toolbox (traces CLI, trace analysts, AxLLM) and grants subagent permission with a budget', () => {
|
|
130
|
+
const text = defaultAuthorBriefing()
|
|
131
|
+
expect(text).toContain('npx --yes @tangle-network/traces@latest')
|
|
132
|
+
expect(text).toContain('agent-eval trace analysts')
|
|
133
|
+
expect(text).toContain('AxLLM')
|
|
134
|
+
expect(text).toContain('spawn your own subagents')
|
|
135
|
+
expect(text).toContain('Research budget')
|
|
136
|
+
expect(text).toContain('ONE input')
|
|
137
|
+
expect(text).toContain(AUTHOR_BRIEFING_RELPATH)
|
|
138
|
+
expect(text).toContain(AUTHOR_BRIEFING_VERSION)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('briefingPromptSection leads with the evidence map path', () => {
|
|
142
|
+
const section = briefingPromptSection({
|
|
143
|
+
indexPath: '/tmp/x/evidence-index.md',
|
|
144
|
+
briefingText: defaultAuthorBriefing(),
|
|
145
|
+
briefingSource: 'default',
|
|
146
|
+
})
|
|
147
|
+
expect(section).toContain('EVIDENCE MAP: /tmp/x/evidence-index.md')
|
|
148
|
+
expect(section.indexOf('EVIDENCE MAP')).toBeLessThan(section.indexOf('TOOLBOX'))
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('resolveAuthorBriefing prefers the change-space file at the ref (authors may rewrite their own briefing)', async () => {
|
|
152
|
+
const repo = await mkdtemp(join(tmpdir(), 'briefing-repo-'))
|
|
153
|
+
try {
|
|
154
|
+
await runOk('git', ['init', '-q', '-b', 'main', repo])
|
|
155
|
+
await runOk('git', ['-C', repo, 'config', 'user.email', 't@t.dev'])
|
|
156
|
+
await runOk('git', ['-C', repo, 'config', 'user.name', 'T'])
|
|
157
|
+
await writeFile(join(repo, 'base.txt'), 'x\n')
|
|
158
|
+
await runOk('git', ['-C', repo, 'add', '-A'])
|
|
159
|
+
await runOk('git', ['-C', repo, 'commit', '-q', '-m', 'init'])
|
|
160
|
+
|
|
161
|
+
// No override committed → the versioned default.
|
|
162
|
+
const fallback = await resolveAuthorBriefing(repo, 'main')
|
|
163
|
+
expect(fallback.source).toBe('default')
|
|
164
|
+
expect(fallback.text).toBe(defaultAuthorBriefing())
|
|
165
|
+
|
|
166
|
+
// Commit the change-space override → it wins.
|
|
167
|
+
await mkdir(join(repo, 'extensions', 'pi'), { recursive: true })
|
|
168
|
+
await writeFile(join(repo, AUTHOR_BRIEFING_RELPATH), 'MY OWN RESEARCH RULES\n')
|
|
169
|
+
await runOk('git', ['-C', repo, 'add', '-A'])
|
|
170
|
+
await runOk('git', ['-C', repo, 'commit', '-q', '-m', 'briefing override'])
|
|
171
|
+
const override = await resolveAuthorBriefing(repo, 'main')
|
|
172
|
+
expect(override.source).toBe('change-space')
|
|
173
|
+
expect(override.text).toBe('MY OWN RESEARCH RULES\n')
|
|
174
|
+
} finally {
|
|
175
|
+
await rm(repo, { recursive: true, force: true })
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
})
|
|
@@ -19,8 +19,11 @@
|
|
|
19
19
|
|
|
20
20
|
import { rm, writeFile } from 'node:fs/promises'
|
|
21
21
|
import { join } from 'node:path'
|
|
22
|
+
import { pathToFileURL } from 'node:url'
|
|
23
|
+
import { judgeFactoryPatch } from './factory-judge-child.mts'
|
|
24
|
+
import { loadFactoryInstance, loadFactoryInstances, type LoadedFactoryInstance } from './fixtures'
|
|
22
25
|
import { materializeWorkspace } from './materialize'
|
|
23
|
-
import { run, shq } from './proc'
|
|
26
|
+
import { run, runOk, shq } from './proc'
|
|
24
27
|
import type { SerializedJudge } from './serialized-judge'
|
|
25
28
|
|
|
26
29
|
export interface CalibrateOptions {
|
|
@@ -114,3 +117,101 @@ export async function calibrateInstance(opts: CalibrateOptions): Promise<Calibra
|
|
|
114
117
|
}
|
|
115
118
|
}
|
|
116
119
|
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Factory-bench admission gate — the same "calibrate through the OFFICIAL
|
|
123
|
+
// judge" lesson, generalized: gold (the real PR's impl-only diff) must judge
|
|
124
|
+
// resolved, and the bare base (empty patch) must judge unresolved. Both runs
|
|
125
|
+
// go through the SAME judge code the arena uses (judgeFactoryPatch — the
|
|
126
|
+
// factory-judge-child body), deliberately bypassing serialized-judge's
|
|
127
|
+
// empty-patch short-circuit so the base direction really executes the judge
|
|
128
|
+
// tests on the bare tree instead of trivially returning false.
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
export interface FactoryCalibrationResult {
|
|
132
|
+
iid: string
|
|
133
|
+
/** Gold = impl-only PR diff. Must be resolved with full score. */
|
|
134
|
+
goldResolved: boolean
|
|
135
|
+
goldPassed: number
|
|
136
|
+
/** Base = empty patch. Must be unresolved. */
|
|
137
|
+
baseResolved: boolean
|
|
138
|
+
basePassed: number
|
|
139
|
+
total: number
|
|
140
|
+
/** goldResolved && !baseResolved — the pool admission bar. */
|
|
141
|
+
admitted: boolean
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The real PR's impl-only patch: full first-parent diff base→judge_ref minus
|
|
146
|
+
* the judge test files (they are the hidden judge, not the deliverable).
|
|
147
|
+
*/
|
|
148
|
+
export async function goldImplPatch(inst: LoadedFactoryInstance): Promise<string> {
|
|
149
|
+
const res = await runOk('git', [
|
|
150
|
+
'-C', inst.repo_local_mirror,
|
|
151
|
+
'diff', inst.base_commit, inst.judge_ref,
|
|
152
|
+
'--', '.',
|
|
153
|
+
...inst.judge_tests.map((t) => `:(exclude)${t}`),
|
|
154
|
+
])
|
|
155
|
+
if (res.stdout.trim().length === 0) {
|
|
156
|
+
throw new Error(`calibrate ${inst.id}: impl-only gold diff is empty — judge_tests exclude everything?`)
|
|
157
|
+
}
|
|
158
|
+
return res.stdout
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Run both admission directions for one instance. Throws only on infra failure. */
|
|
162
|
+
export async function calibrateFactoryInstance(inst: LoadedFactoryInstance): Promise<FactoryCalibrationResult> {
|
|
163
|
+
const gold = await judgeFactoryPatch(inst, await goldImplPatch(inst))
|
|
164
|
+
const base = await judgeFactoryPatch(inst, '')
|
|
165
|
+
return {
|
|
166
|
+
iid: inst.id,
|
|
167
|
+
goldResolved: gold.result.resolved,
|
|
168
|
+
goldPassed: gold.result.passed,
|
|
169
|
+
baseResolved: base.result.resolved,
|
|
170
|
+
basePassed: base.result.passed,
|
|
171
|
+
total: inst.judgeTestTotal,
|
|
172
|
+
admitted: gold.result.resolved && !base.result.resolved,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// CLI: tsx src/swe-arena/calibrate.ts --factory <instancesDirOrInstanceDir> [id ...]
|
|
178
|
+
// Rejection is LOUD: any instance failing either direction exits nonzero.
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href
|
|
182
|
+
|
|
183
|
+
if (isMain) {
|
|
184
|
+
const [mode, root, ...ids] = process.argv.slice(2)
|
|
185
|
+
if (mode !== '--factory' || !root) {
|
|
186
|
+
console.error('usage: tsx src/swe-arena/calibrate.ts --factory <instancesDir|instanceDir> [id ...]')
|
|
187
|
+
process.exit(2)
|
|
188
|
+
}
|
|
189
|
+
let instances: LoadedFactoryInstance[]
|
|
190
|
+
try {
|
|
191
|
+
instances = loadFactoryInstances(root)
|
|
192
|
+
} catch {
|
|
193
|
+
instances = [loadFactoryInstance(root)]
|
|
194
|
+
}
|
|
195
|
+
if (ids.length > 0) {
|
|
196
|
+
const byId = new Map(instances.map((i) => [i.id, i]))
|
|
197
|
+
instances = ids.map((id) => {
|
|
198
|
+
const inst = byId.get(id)
|
|
199
|
+
if (!inst) throw new Error(`unknown instance id ${id} (have: ${[...byId.keys()].join(', ')})`)
|
|
200
|
+
return inst
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
let rejected = 0
|
|
204
|
+
for (const inst of instances) {
|
|
205
|
+
const r = await calibrateFactoryInstance(inst)
|
|
206
|
+
const verdict = r.admitted ? 'ADMITTED' : 'REJECTED'
|
|
207
|
+
console.log(
|
|
208
|
+
`CALIBRATE ${r.iid}: gold ${r.goldPassed}/${r.total} resolved=${r.goldResolved}; ` +
|
|
209
|
+
`base ${r.basePassed}/${r.total} resolved=${r.baseResolved} → ${verdict}`,
|
|
210
|
+
)
|
|
211
|
+
if (!r.admitted) rejected += 1
|
|
212
|
+
}
|
|
213
|
+
if (rejected > 0) {
|
|
214
|
+
console.error(`calibration gate: ${rejected} instance(s) REJECTED (gold must pass AND base must fail)`)
|
|
215
|
+
process.exit(1)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -248,9 +248,17 @@ export type StaircaseVerdict =
|
|
|
248
248
|
* evaluation — the candidate never became a measured surface. Emitted by
|
|
249
249
|
* the outer loop's kill-row writer, never by `decideVerdict`. */
|
|
250
250
|
| 'rejected-prefilter'
|
|
251
|
+
/** GEN-5 activation gate: the candidate's own machine-checkable predicate
|
|
252
|
+
* (activation.mts) proved its mechanism NEVER fired in its campaign
|
|
253
|
+
* traces — recorded, never promoted, EVEN when the score improved. */
|
|
254
|
+
| 'quarantined-inactive'
|
|
251
255
|
|
|
252
256
|
/** protocol_v2 keep-if-better: improvement-set resolved-count must RISE and
|
|
253
|
-
* cost must stay within the guard. Fail-closed on unprovable cost.
|
|
257
|
+
* cost must stay within the guard. Fail-closed on unprovable cost.
|
|
258
|
+
* GEN-5: `activationFired === false` quarantines the candidate ahead of the
|
|
259
|
+
* score comparison — an improved score with an inactive mechanism is
|
|
260
|
+
* indistinguishable from luck (undefined/null = gate not applicable:
|
|
261
|
+
* disabled, baseline, or predicate unevaluated). */
|
|
254
262
|
export function decideVerdict(input: {
|
|
255
263
|
violations: string[]
|
|
256
264
|
coverageComplete: boolean
|
|
@@ -258,8 +266,10 @@ export function decideVerdict(input: {
|
|
|
258
266
|
parentResolvedCount: number
|
|
259
267
|
costRatio: number | null
|
|
260
268
|
costGuardRatio: number
|
|
269
|
+
activationFired?: boolean | null
|
|
261
270
|
}): StaircaseVerdict {
|
|
262
271
|
if (input.violations.length > 0) return 'rejected-out-of-space'
|
|
272
|
+
if (input.activationFired === false) return 'quarantined-inactive'
|
|
263
273
|
if (!input.coverageComplete) return 'rejected-incomplete'
|
|
264
274
|
if (input.resolvedCount <= input.parentResolvedCount) return 'rejected-no-gain'
|
|
265
275
|
if (input.costRatio === null || input.costRatio > input.costGuardRatio) return 'rejected-cost'
|
|
@@ -273,27 +283,46 @@ export function decideVerdict(input: {
|
|
|
273
283
|
// coverage-incomplete downstream, fail-closed).
|
|
274
284
|
// ---------------------------------------------------------------------------
|
|
275
285
|
|
|
276
|
-
/**
|
|
277
|
-
|
|
286
|
+
/** Every `<cellDir>/cached-result.json` under one campaign dir, verbatim and
|
|
287
|
+
* identity-checked. Missing dir = []. A cache that exists but carries no
|
|
288
|
+
* scenarioId/rep cannot be attributed to a cell, so it throws rather than
|
|
289
|
+
* disappearing from the campaign's coverage. */
|
|
290
|
+
export async function loadCampaignCellRecords(campaignDir: string): Promise<Record<string, unknown>[]> {
|
|
278
291
|
const entries = await readdir(campaignDir, { withFileTypes: true }).catch(() => [])
|
|
279
|
-
const
|
|
292
|
+
const records: Record<string, unknown>[] = []
|
|
280
293
|
for (const entry of entries) {
|
|
281
294
|
if (!entry.isDirectory()) continue
|
|
282
295
|
const path = join(campaignDir, entry.name, 'cached-result.json')
|
|
283
296
|
const raw = await readFile(path, 'utf8').catch(() => null)
|
|
284
297
|
if (raw === null) continue
|
|
285
|
-
let parsed:
|
|
298
|
+
let parsed: unknown
|
|
286
299
|
try {
|
|
287
|
-
parsed = JSON.parse(raw)
|
|
300
|
+
parsed = JSON.parse(raw)
|
|
288
301
|
} catch {
|
|
289
302
|
throw new Error(`loadCampaignCells: corrupt cell cache ${path}`)
|
|
290
303
|
}
|
|
291
|
-
|
|
304
|
+
// null and arrays parse fine and would make the identity check below throw a
|
|
305
|
+
// TypeError instead of naming what is wrong with the file.
|
|
306
|
+
const record =
|
|
307
|
+
parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
308
|
+
? (parsed as Record<string, unknown>)
|
|
309
|
+
: null
|
|
310
|
+
if (record === null || typeof record.scenarioId !== 'string' || typeof record.rep !== 'number') {
|
|
292
311
|
throw new Error(`loadCampaignCells: ${path} is not a campaign cell (scenarioId/rep missing)`)
|
|
293
312
|
}
|
|
313
|
+
records.push(record)
|
|
314
|
+
}
|
|
315
|
+
return records
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Parse every `<cellDir>/cached-result.json` under one campaign dir. Missing dir = []. */
|
|
319
|
+
export async function loadCampaignCells(campaignDir: string): Promise<EvidenceCell[]> {
|
|
320
|
+
const cells: EvidenceCell[] = []
|
|
321
|
+
for (const parsed of await loadCampaignCellRecords(campaignDir)) {
|
|
294
322
|
cells.push({
|
|
295
|
-
|
|
296
|
-
|
|
323
|
+
// loadCampaignCellRecords has already proven both are present and typed.
|
|
324
|
+
scenarioId: parsed.scenarioId as string,
|
|
325
|
+
rep: parsed.rep as number,
|
|
297
326
|
artifact: (parsed.artifact ?? null) as R4Artifact | null,
|
|
298
327
|
...(typeof parsed.error === 'string' ? { error: parsed.error } : {}),
|
|
299
328
|
...(typeof parsed.costUsd === 'number' ? { costUsd: parsed.costUsd } : {}),
|
|
@@ -377,6 +406,8 @@ export function gateEvidenceFromCells(input: {
|
|
|
377
406
|
iids: string[]
|
|
378
407
|
reps: number
|
|
379
408
|
costGuardRatio: number
|
|
409
|
+
/** GEN-5 activation-gate outcome for the winner (see decideVerdict). */
|
|
410
|
+
activationFired?: boolean | null
|
|
380
411
|
}): GateEvidence {
|
|
381
412
|
const winnerRuns = replicateRunsFromCells(input.winnerCells)
|
|
382
413
|
const baselineRuns = replicateRunsFromCells(input.baselineCells)
|
|
@@ -400,6 +431,7 @@ export function gateEvidenceFromCells(input: {
|
|
|
400
431
|
parentResolvedCount: baseResolved,
|
|
401
432
|
costRatio,
|
|
402
433
|
costGuardRatio: input.costGuardRatio,
|
|
434
|
+
...(input.activationFired !== undefined ? { activationFired: input.activationFired } : {}),
|
|
403
435
|
}),
|
|
404
436
|
}
|
|
405
437
|
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { mkdir, mkdtemp, realpath, rm, stat } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { delimiter, isAbsolute, join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { run, type RunResult } from './proc'
|
|
7
|
+
|
|
8
|
+
const WORKSPACE_PATH = '/workspace'
|
|
9
|
+
const STATE_PATH = '/factory-state'
|
|
10
|
+
const IMAGE_DIGEST_RE = /^.+@sha256:[0-9a-f]{64}$/
|
|
11
|
+
const CONTAINER_PATH =
|
|
12
|
+
`${STATE_PATH}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`
|
|
13
|
+
const CONTROL_TIMEOUT_MS = 30_000
|
|
14
|
+
|
|
15
|
+
export type FactoryCommandNetwork = 'enabled' | 'none'
|
|
16
|
+
|
|
17
|
+
export interface RunFactoryCommandOptions {
|
|
18
|
+
image: string
|
|
19
|
+
network: FactoryCommandNetwork
|
|
20
|
+
timeoutMs: number
|
|
21
|
+
signal?: AbortSignal
|
|
22
|
+
maxBuffer?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hostPath(baseEnv: NodeJS.ProcessEnv): string {
|
|
26
|
+
const path = (baseEnv.PATH ?? '')
|
|
27
|
+
.split(delimiter)
|
|
28
|
+
.filter((entry) => entry.length > 0 && isAbsolute(entry))
|
|
29
|
+
.join(delimiter)
|
|
30
|
+
if (!path) throw new Error('factory container control process requires an absolute PATH')
|
|
31
|
+
return path
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function createControlEnvironment(): Promise<{
|
|
35
|
+
env: NodeJS.ProcessEnv
|
|
36
|
+
dispose: () => Promise<void>
|
|
37
|
+
}> {
|
|
38
|
+
const root = await mkdtemp(join(tmpdir(), 'factory-container-control-'))
|
|
39
|
+
const home = join(root, 'home')
|
|
40
|
+
const dockerConfig = join(root, 'docker')
|
|
41
|
+
const xdgConfig = join(root, 'xdg')
|
|
42
|
+
const temp = join(root, 'tmp')
|
|
43
|
+
try {
|
|
44
|
+
await Promise.all(
|
|
45
|
+
[home, dockerConfig, xdgConfig, temp].map((dir) =>
|
|
46
|
+
mkdir(dir, { recursive: true, mode: 0o700 }),
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
let disposal: Promise<void> | undefined
|
|
50
|
+
return {
|
|
51
|
+
env: {
|
|
52
|
+
PATH: hostPath(process.env),
|
|
53
|
+
HOME: home,
|
|
54
|
+
DOCKER_CONFIG: dockerConfig,
|
|
55
|
+
XDG_CONFIG_HOME: xdgConfig,
|
|
56
|
+
TMPDIR: temp,
|
|
57
|
+
TMP: temp,
|
|
58
|
+
TEMP: temp,
|
|
59
|
+
LANG: 'C',
|
|
60
|
+
LC_ALL: 'C',
|
|
61
|
+
TZ: 'UTC',
|
|
62
|
+
},
|
|
63
|
+
dispose: () => (disposal ??= rm(root, { recursive: true, force: true })),
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
await rm(root, { recursive: true, force: true })
|
|
67
|
+
throw error
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function currentUser(): { uid: number; gid: number } {
|
|
72
|
+
const uid = process.getuid?.()
|
|
73
|
+
const gid = process.getgid?.()
|
|
74
|
+
if (
|
|
75
|
+
typeof uid !== 'number' ||
|
|
76
|
+
typeof gid !== 'number' ||
|
|
77
|
+
!Number.isSafeInteger(uid) ||
|
|
78
|
+
!Number.isSafeInteger(gid)
|
|
79
|
+
) {
|
|
80
|
+
throw new Error('factory command containers require a POSIX uid and gid')
|
|
81
|
+
}
|
|
82
|
+
if (uid === 0) {
|
|
83
|
+
throw new Error('factory command containers refuse to run candidate commands as root')
|
|
84
|
+
}
|
|
85
|
+
return { uid, gid }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function containerEnvironment(): string[] {
|
|
89
|
+
return [
|
|
90
|
+
`PATH=${CONTAINER_PATH}`,
|
|
91
|
+
`HOME=${STATE_PATH}/home`,
|
|
92
|
+
`XDG_CONFIG_HOME=${STATE_PATH}/xdg/config`,
|
|
93
|
+
`XDG_DATA_HOME=${STATE_PATH}/xdg/data`,
|
|
94
|
+
`XDG_CACHE_HOME=${STATE_PATH}/xdg/cache`,
|
|
95
|
+
`XDG_STATE_HOME=${STATE_PATH}/xdg/state`,
|
|
96
|
+
`XDG_RUNTIME_DIR=${STATE_PATH}/xdg/runtime`,
|
|
97
|
+
'TMPDIR=/tmp',
|
|
98
|
+
'TMP=/tmp',
|
|
99
|
+
'TEMP=/tmp',
|
|
100
|
+
'LANG=C',
|
|
101
|
+
'LC_ALL=C',
|
|
102
|
+
'TZ=UTC',
|
|
103
|
+
'CI=false',
|
|
104
|
+
`COREPACK_HOME=${STATE_PATH}/corepack`,
|
|
105
|
+
'COREPACK_DEFAULT_TO_LATEST=0',
|
|
106
|
+
'COREPACK_ENABLE_DOWNLOAD_PROMPT=0',
|
|
107
|
+
`PNPM_HOME=${STATE_PATH}/pnpm-home`,
|
|
108
|
+
`npm_config_store_dir=${STATE_PATH}/pnpm-store`,
|
|
109
|
+
`NPM_CONFIG_CACHE=${STATE_PATH}/npm-cache`,
|
|
110
|
+
`NPM_CONFIG_USERCONFIG=${STATE_PATH}/npm/user.npmrc`,
|
|
111
|
+
`NPM_CONFIG_GLOBALCONFIG=${STATE_PATH}/npm/global.npmrc`,
|
|
112
|
+
'GIT_CONFIG_NOSYSTEM=1',
|
|
113
|
+
`GIT_CONFIG_GLOBAL=${STATE_PATH}/git/global.config`,
|
|
114
|
+
`GIT_CONFIG_SYSTEM=${STATE_PATH}/git/system.config`,
|
|
115
|
+
'GIT_TERMINAL_PROMPT=0',
|
|
116
|
+
'GCM_INTERACTIVE=Never',
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const COMMAND_WRAPPER = `
|
|
121
|
+
set -eu
|
|
122
|
+
mkdir -p \
|
|
123
|
+
"$HOME" \
|
|
124
|
+
"$XDG_CONFIG_HOME" \
|
|
125
|
+
"$XDG_DATA_HOME" \
|
|
126
|
+
"$XDG_CACHE_HOME" \
|
|
127
|
+
"$XDG_STATE_HOME" \
|
|
128
|
+
"$XDG_RUNTIME_DIR" \
|
|
129
|
+
"$COREPACK_HOME" \
|
|
130
|
+
"$PNPM_HOME" \
|
|
131
|
+
"$npm_config_store_dir" \
|
|
132
|
+
"$NPM_CONFIG_CACHE" \
|
|
133
|
+
"${STATE_PATH}/npm" \
|
|
134
|
+
"${STATE_PATH}/git" \
|
|
135
|
+
"${STATE_PATH}/bin"
|
|
136
|
+
touch \
|
|
137
|
+
"$NPM_CONFIG_USERCONFIG" \
|
|
138
|
+
"$NPM_CONFIG_GLOBALCONFIG" \
|
|
139
|
+
"$GIT_CONFIG_GLOBAL" \
|
|
140
|
+
"$GIT_CONFIG_SYSTEM"
|
|
141
|
+
corepack enable --install-directory "${STATE_PATH}/bin" >/dev/null
|
|
142
|
+
exec /bin/bash --noprofile --norc -c "$1"
|
|
143
|
+
`
|
|
144
|
+
|
|
145
|
+
function dockerArgs(input: {
|
|
146
|
+
name: string
|
|
147
|
+
workspace: string
|
|
148
|
+
command: string
|
|
149
|
+
image: string
|
|
150
|
+
network: FactoryCommandNetwork
|
|
151
|
+
uid: number
|
|
152
|
+
gid: number
|
|
153
|
+
}): string[] {
|
|
154
|
+
const user = `${input.uid}:${input.gid}`
|
|
155
|
+
return [
|
|
156
|
+
'run',
|
|
157
|
+
'--rm',
|
|
158
|
+
'--name',
|
|
159
|
+
input.name,
|
|
160
|
+
'--hostname',
|
|
161
|
+
'factory-command',
|
|
162
|
+
'--pull=missing',
|
|
163
|
+
'--read-only',
|
|
164
|
+
'--init',
|
|
165
|
+
'--cap-drop=ALL',
|
|
166
|
+
'--security-opt=no-new-privileges',
|
|
167
|
+
'--pids-limit=512',
|
|
168
|
+
'--memory=4g',
|
|
169
|
+
'--memory-swap=4g',
|
|
170
|
+
'--cpus=2',
|
|
171
|
+
'--ulimit',
|
|
172
|
+
'nofile=4096:4096',
|
|
173
|
+
'--ipc=private',
|
|
174
|
+
'--network',
|
|
175
|
+
input.network === 'none' ? 'none' : 'bridge',
|
|
176
|
+
'--user',
|
|
177
|
+
user,
|
|
178
|
+
'--workdir',
|
|
179
|
+
WORKSPACE_PATH,
|
|
180
|
+
'--mount',
|
|
181
|
+
`type=bind,src=${input.workspace},dst=${WORKSPACE_PATH},bind-propagation=rprivate`,
|
|
182
|
+
'--tmpfs',
|
|
183
|
+
`/tmp:rw,nosuid,nodev,mode=1777,size=512m,uid=${input.uid},gid=${input.gid}`,
|
|
184
|
+
'--tmpfs',
|
|
185
|
+
`${STATE_PATH}:rw,nosuid,nodev,mode=700,size=2048m,uid=${input.uid},gid=${input.gid}`,
|
|
186
|
+
'--entrypoint=/usr/bin/env',
|
|
187
|
+
input.image,
|
|
188
|
+
'-i',
|
|
189
|
+
...containerEnvironment(),
|
|
190
|
+
'/bin/bash',
|
|
191
|
+
'--noprofile',
|
|
192
|
+
'--norc',
|
|
193
|
+
'-c',
|
|
194
|
+
COMMAND_WRAPPER,
|
|
195
|
+
'factory-command',
|
|
196
|
+
input.command,
|
|
197
|
+
]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isAlreadyRemoved(result: RunResult): boolean {
|
|
201
|
+
return /no such container/iu.test(result.stderr || result.stdout)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Run one repository-controlled command in a fresh container.
|
|
206
|
+
*
|
|
207
|
+
* The candidate sees one writable bind mount: its disposable workspace.
|
|
208
|
+
* Process ids, root files, HOME, XDG state, Corepack, npm, and pnpm caches are
|
|
209
|
+
* private to this invocation and disappear when the container exits.
|
|
210
|
+
*/
|
|
211
|
+
export async function runFactoryCommand(
|
|
212
|
+
workspace: string,
|
|
213
|
+
command: string,
|
|
214
|
+
options: RunFactoryCommandOptions,
|
|
215
|
+
): Promise<RunResult> {
|
|
216
|
+
if (!command.trim()) throw new Error('factory command must not be empty')
|
|
217
|
+
if (!IMAGE_DIGEST_RE.test(options.image)) {
|
|
218
|
+
throw new Error(`factory command image must be pinned by sha256 digest: ${options.image}`)
|
|
219
|
+
}
|
|
220
|
+
if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0) {
|
|
221
|
+
throw new Error(`factory command timeout must be a positive integer: ${options.timeoutMs}`)
|
|
222
|
+
}
|
|
223
|
+
const workspacePath = await realpath(workspace)
|
|
224
|
+
if (!(await stat(workspacePath)).isDirectory()) {
|
|
225
|
+
throw new Error(`factory command workspace is not a directory: ${workspacePath}`)
|
|
226
|
+
}
|
|
227
|
+
if (workspacePath.includes(',')) {
|
|
228
|
+
throw new Error(`factory command workspace path contains an unsupported comma: ${workspacePath}`)
|
|
229
|
+
}
|
|
230
|
+
const { uid, gid } = currentUser()
|
|
231
|
+
const name = `factory-command-${process.pid}-${randomUUID()}`
|
|
232
|
+
const control = await createControlEnvironment()
|
|
233
|
+
let commandResult: RunResult | undefined
|
|
234
|
+
let commandError: unknown
|
|
235
|
+
try {
|
|
236
|
+
commandResult = await run(
|
|
237
|
+
'docker',
|
|
238
|
+
dockerArgs({
|
|
239
|
+
name,
|
|
240
|
+
workspace: workspacePath,
|
|
241
|
+
command,
|
|
242
|
+
image: options.image,
|
|
243
|
+
network: options.network,
|
|
244
|
+
uid,
|
|
245
|
+
gid,
|
|
246
|
+
}),
|
|
247
|
+
{
|
|
248
|
+
env: control.env,
|
|
249
|
+
timeoutMs: options.timeoutMs,
|
|
250
|
+
killGraceMs: 2_000,
|
|
251
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
252
|
+
...(options.maxBuffer === undefined ? {} : { maxBuffer: options.maxBuffer }),
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
} catch (error) {
|
|
256
|
+
commandError = error
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let cleanupError: unknown
|
|
260
|
+
try {
|
|
261
|
+
const cleanup = await run('docker', ['rm', '-f', name], {
|
|
262
|
+
env: control.env,
|
|
263
|
+
timeoutMs: CONTROL_TIMEOUT_MS,
|
|
264
|
+
})
|
|
265
|
+
if (cleanup.code !== 0 && !isAlreadyRemoved(cleanup)) {
|
|
266
|
+
cleanupError = new Error(
|
|
267
|
+
`factory command container cleanup failed (${cleanup.code}): ${(cleanup.stderr || cleanup.stdout).slice(-1000)}`,
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
} catch (error) {
|
|
271
|
+
cleanupError = error
|
|
272
|
+
}
|
|
273
|
+
await control.dispose()
|
|
274
|
+
|
|
275
|
+
if (commandError !== undefined && cleanupError !== undefined) {
|
|
276
|
+
throw new AggregateError(
|
|
277
|
+
[commandError, cleanupError],
|
|
278
|
+
'factory command and container cleanup both failed',
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
if (commandError !== undefined) throw commandError
|
|
282
|
+
if (cleanupError !== undefined) throw cleanupError
|
|
283
|
+
return commandResult!
|
|
284
|
+
}
|