@tachikomagundam/abathur 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +29 -0
- package/.github/workflows/publish.yml +74 -0
- package/LICENSE +21 -0
- package/README.md +461 -0
- package/config/abathur.jsonc +17 -0
- package/config/genomes/historian.example.jsonc +124 -0
- package/dist/bench/adapter.js +201 -0
- package/dist/bench/fixture-probe.js +92 -0
- package/dist/bench/fixture-support.js +173 -0
- package/dist/bench/fixture.js +236 -0
- package/dist/bench/toy.js +152 -0
- package/dist/cli.js +110 -0
- package/dist/commands/bundle.js +79 -0
- package/dist/commands/genome.js +94 -0
- package/dist/commands/graft.js +71 -0
- package/dist/commands/kernel.js +47 -0
- package/dist/commands/promote.js +25 -0
- package/dist/commands/run.js +145 -0
- package/dist/commands/self-eval.js +240 -0
- package/dist/commands/status.js +186 -0
- package/dist/commands/tombstone.js +72 -0
- package/dist/config.js +161 -0
- package/dist/core/bundle-common.js +119 -0
- package/dist/core/bundle-export.js +212 -0
- package/dist/core/bundle-inspect.js +143 -0
- package/dist/core/bundle-manifest.js +105 -0
- package/dist/core/bundle-mask.js +75 -0
- package/dist/core/bundle-tar.js +240 -0
- package/dist/core/bundle.js +9 -0
- package/dist/core/evolve/brief.js +45 -0
- package/dist/core/evolve/candidate.js +140 -0
- package/dist/core/evolve/child-track.js +197 -0
- package/dist/core/evolve/friction.js +150 -0
- package/dist/core/evolve/reflect.js +191 -0
- package/dist/core/evolve/run-bench.js +170 -0
- package/dist/core/evolve/run-friction.js +63 -0
- package/dist/core/evolve/run-loop.js +282 -0
- package/dist/core/evolve/run-plan.js +39 -0
- package/dist/core/evolve/run-rows.js +145 -0
- package/dist/core/evolve/self-overlay.js +213 -0
- package/dist/core/evolve/self-snapshot.js +170 -0
- package/dist/core/evolve/stub-mutators.mjs +105 -0
- package/dist/core/evolve/udiff.js +189 -0
- package/dist/core/genome-paths.js +76 -0
- package/dist/core/genome.js +176 -0
- package/dist/core/glob.js +106 -0
- package/dist/core/graft-gates.js +184 -0
- package/dist/core/graft-rebench.js +187 -0
- package/dist/core/graft-support.js +181 -0
- package/dist/core/graft.js +218 -0
- package/dist/core/ids.js +154 -0
- package/dist/core/incumbent.js +46 -0
- package/dist/core/kernel.js +112 -0
- package/dist/core/ledger.js +198 -0
- package/dist/core/locks.js +172 -0
- package/dist/core/promote.js +119 -0
- package/dist/core/snapshot.js +61 -0
- package/dist/core/spec.js +178 -0
- package/dist/core/stats-math.js +102 -0
- package/dist/core/stats-pareto.js +57 -0
- package/dist/core/stats.js +184 -0
- package/dist/core/worktree.js +190 -0
- package/dist/exit.js +32 -0
- package/dist/genomes/toy-smoke/genome.jsonc +30 -0
- package/dist/genomes/toy-smoke/grader.mjs +61 -0
- package/dist/genomes/toy-smoke/init.mjs +63 -0
- package/dist/genomes/toy-smoke/units/add.mjs +17 -0
- package/dist/genomes/toy-smoke/units/explode.mjs +4 -0
- package/dist/genomes/toy-smoke/units/hang.mjs +16 -0
- package/dist/genomes/toy-smoke/units/mul.mjs +16 -0
- package/dist/genomes/toy-smoke/units/mutate.mjs +18 -0
- package/dist/genomes/toy-smoke/units/sub.mjs +16 -0
- package/dist/jsonc.js +77 -0
- package/dist/out.js +5 -0
- package/dist/test/bench-adapter.test.js +33 -0
- package/dist/test/bench-fixture.test.js +407 -0
- package/dist/test/bench-toy.test.js +251 -0
- package/dist/test/bundle.test.js +659 -0
- package/dist/test/config.test.js +185 -0
- package/dist/test/d7-gate.test.js +56 -0
- package/dist/test/fixture-loop.test.js +267 -0
- package/dist/test/fixtures/friction-writer.js +16 -0
- package/dist/test/fixtures-historian.js +82 -0
- package/dist/test/fixtures-self.js +143 -0
- package/dist/test/fixtures-wt.js +64 -0
- package/dist/test/friction.test.js +398 -0
- package/dist/test/genome.test.js +453 -0
- package/dist/test/git.test.js +69 -0
- package/dist/test/graft.test.js +567 -0
- package/dist/test/historian-genome.test.js +134 -0
- package/dist/test/historian-grader-io.test.js +148 -0
- package/dist/test/historian-grader.test.js +209 -0
- package/dist/test/ids.test.js +116 -0
- package/dist/test/include-val.test.js +120 -0
- package/dist/test/ledger-lock.test.js +99 -0
- package/dist/test/ledger.test.js +102 -0
- package/dist/test/promote.test.js +394 -0
- package/dist/test/reflect.test.js +410 -0
- package/dist/test/run-loop.test.js +433 -0
- package/dist/test/self-snapshot.test.js +328 -0
- package/dist/test/snapshot.test.js +86 -0
- package/dist/test/stats.test.js +423 -0
- package/dist/test/stub-mutators.test.js +17 -0
- package/dist/test/testutil.js +30 -0
- package/dist/test/worktree.test.js +198 -0
- package/dist/util/freeze.js +30 -0
- package/dist/util/git.js +85 -0
- package/docs/federation.md +184 -0
- package/docs/immutable-kernel.md +87 -0
- package/graders/historian/grader-core.d.mts +53 -0
- package/graders/historian/grader-core.mjs +276 -0
- package/graders/historian/grader-support.d.mts +57 -0
- package/graders/historian/grader-support.mjs +137 -0
- package/graders/historian/grader.mjs +113 -0
- package/graders/historian/mutate.sh +114 -0
- package/graders/historian/reset-sandbox.sh +60 -0
- package/graders/historian/run-scenario.sh +49 -0
- package/graders/historian/seed-wrapped.sh +32 -0
- package/package.json +42 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// Todo 9 AC pins (plan lines 141-148): resumable run-loop orchestrator.
|
|
2
|
+
// AC(a) toy 2-candidate run end-to-end exit 0 + generation_complete ledger rows;
|
|
3
|
+
// resume exactly-once: a truncated ledger (crash before a row commits) reruns the
|
|
4
|
+
// missing bench only — same samples, budget counters never double;
|
|
5
|
+
// AC(d) --dry-run prints the plan, mutates NOTHING (path-absence + no mkdir);
|
|
6
|
+
// AC(c) budget maxTokens=1 ⇒ inconclusive exit 2 (stats short-circuit, gain null);
|
|
7
|
+
// cancellation: --max-candidates clamps the session to exactly N benched candidates;
|
|
8
|
+
// AC(e) tampered kernel file ⇒ startup refusal exit 1 before any state exists;
|
|
9
|
+
// dirty target paths ⇒ openGenome refusal exit 1 before any generation spawns;
|
|
10
|
+
// orphan reaper: live dead-run groups SIGKILLed; malformed/own-pid records skipped;
|
|
11
|
+
// ChildTracker: append-before-exec + removal on exit; adapter onChild hook.
|
|
12
|
+
// No model calls anywhere: toy genome + runtime-written stub mutator only.
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
15
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, chmodSync, } from "node:fs";
|
|
16
|
+
import { mkdtemp } from "node:fs/promises";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import test, {} from "node:test";
|
|
20
|
+
import { ExitSignal } from "../exit.js";
|
|
21
|
+
import { loadConfig } from "../config.js";
|
|
22
|
+
import { registerGenome, requireGenomesByLabel } from "../core/genome.js";
|
|
23
|
+
import { Ledger, ledgerPath } from "../core/ledger.js";
|
|
24
|
+
import { decodeGenerationRecord, } from "../core/evolve/run-bench.js";
|
|
25
|
+
import { runEvolution, } from "../core/evolve/run-loop.js";
|
|
26
|
+
import { activeChildrenPath, readChildRecords, reapOrphans, ChildTracker, } from "../core/evolve/child-track.js";
|
|
27
|
+
import { prepareToyGenome, ToyBenchAdapter } from "../bench/toy.js";
|
|
28
|
+
import { runChild } from "../bench/adapter.js";
|
|
29
|
+
import { runCommand } from "../commands/run.js";
|
|
30
|
+
// ------------------------------------------------------------------- stubs
|
|
31
|
+
// Same three-candidate script as reflect.test.ts mode "three": annotate-add seals,
|
|
32
|
+
// touch-kernel is rejected (path policy), fix-add seals. The fix moves the train
|
|
33
|
+
// aggregate 0.5 -> 1.0 (gain = minEffect 0.5) and val 'sub' stays 1.0 ⇒ nominated.
|
|
34
|
+
const ADD_ANCHOR = "return a - b; // seeded bug: must be `return a + b;`";
|
|
35
|
+
const STUB_SOURCE = `#!/usr/bin/env node
|
|
36
|
+
import { readFileSync } from "node:fs";
|
|
37
|
+
const args = process.argv.slice(2);
|
|
38
|
+
const opt = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
|
|
39
|
+
const mode = opt("--mode");
|
|
40
|
+
const dir = opt("--dir");
|
|
41
|
+
const read = (f) => readFileSync(dir + "/" + f, "utf8");
|
|
42
|
+
function lineDiff(file, anchor, replacement) {
|
|
43
|
+
const lines = read(file).split("\\n");
|
|
44
|
+
const i = lines.findIndex((l) => l.includes(anchor));
|
|
45
|
+
if (i < 0) { process.stderr.write("stub: no anchor in " + file + "\\n"); process.exit(1); }
|
|
46
|
+
const changed = lines[i].replace(anchor, replacement);
|
|
47
|
+
return "--- a/" + file + "\\n+++ b/" + file + "\\n@@ -" + String(i + 1) + ",1 +" + String(i + 1) + ",1 @@\\n-" + lines[i] + "\\n+" + changed + "\\n";
|
|
48
|
+
}
|
|
49
|
+
const FIX = ${JSON.stringify(ADD_ANCHOR)};
|
|
50
|
+
const out = (candidates) => process.stdout.write(JSON.stringify({ candidates }) + "\\n");
|
|
51
|
+
if (mode === "three") {
|
|
52
|
+
out([
|
|
53
|
+
{ id: "annotate-add", rationale: "annotate add()", diffs: [lineDiff("units/add.mjs", "export function add(", "export function add /* patched */(")] },
|
|
54
|
+
{ id: "touch-kernel", rationale: "subvert grading", diffs: [lineDiff("grader.mjs", "model-free", "model-free-ish")] },
|
|
55
|
+
{ id: "fix-add", rationale: "fix add(): subtraction to addition", diffs: [lineDiff("units/add.mjs", FIX, "return a + b;")] },
|
|
56
|
+
]);
|
|
57
|
+
} else {
|
|
58
|
+
process.stderr.write("stub: unknown mode\\n");
|
|
59
|
+
process.exit(2);
|
|
60
|
+
}
|
|
61
|
+
`;
|
|
62
|
+
/** Fresh tmp home: config dir + registered toy genome + stub mutator, all isolated. */
|
|
63
|
+
async function loopFixture(t, tune) {
|
|
64
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "abathur-runloop-"));
|
|
65
|
+
t.after(() => {
|
|
66
|
+
// worktree dirs are git-administered; plain recursive rm is enough here.
|
|
67
|
+
spawnSync("rm", ["-rf", root], { encoding: "utf8" });
|
|
68
|
+
});
|
|
69
|
+
const configDir = path.join(root, "config");
|
|
70
|
+
mkdirSync(configDir, { recursive: true });
|
|
71
|
+
writeFileSync(path.join(configDir, "config.jsonc"), '{ "opencodeBin": null }\n', "utf8");
|
|
72
|
+
const repo = await prepareToyGenome(path.join(root, "genome"));
|
|
73
|
+
if (tune?.maxTokens !== undefined) {
|
|
74
|
+
const specPath = path.join(repo, "genome.jsonc");
|
|
75
|
+
const doc = JSON.parse(readFileSync(specPath, "utf8"));
|
|
76
|
+
doc.budget.maxTokens = tune.maxTokens;
|
|
77
|
+
writeFileSync(specPath, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
|
|
78
|
+
}
|
|
79
|
+
registerGenome(configDir, path.join(repo, "genome.jsonc"));
|
|
80
|
+
const entry = requireGenomesByLabel(configDir, "toy-smoke").entries[0];
|
|
81
|
+
if (entry === undefined)
|
|
82
|
+
throw new Error("fixture: toy-smoke registration vanished");
|
|
83
|
+
const stub = path.join(root, "stub.mjs");
|
|
84
|
+
writeFileSync(stub, STUB_SOURCE, "utf8");
|
|
85
|
+
chmodSync(stub, 0o755);
|
|
86
|
+
return {
|
|
87
|
+
root,
|
|
88
|
+
configDir,
|
|
89
|
+
repo,
|
|
90
|
+
spec: entry.spec,
|
|
91
|
+
entry,
|
|
92
|
+
mutatorCommand: `node ${stub} --mode three --dir {worktree} --brief {brief}`,
|
|
93
|
+
env: { XDG_CACHE_HOME: path.join(root, "xdg-cache"), HOME: path.join(root, "home") },
|
|
94
|
+
sandboxRoot: path.join(root, "sandboxes"),
|
|
95
|
+
xdgRoot: path.join(root, "xdg-cache"),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function generationRows(f) {
|
|
99
|
+
const ledger = Ledger.open(f.repo);
|
|
100
|
+
return ledger
|
|
101
|
+
.readAll()
|
|
102
|
+
.filter((r) => r.kind === "generation_complete")
|
|
103
|
+
.map((r) => decodeGenerationRecord(r));
|
|
104
|
+
}
|
|
105
|
+
function candidateRows(f) {
|
|
106
|
+
return generationRows(f).filter((d) => d.source === "candidate");
|
|
107
|
+
}
|
|
108
|
+
function scoredSamples(rows) {
|
|
109
|
+
return rows.reduce((acc, d) => acc + d.units.reduce((a, u) => a + u.scores.length, 0), 0);
|
|
110
|
+
}
|
|
111
|
+
function spentTokens(rows) {
|
|
112
|
+
return rows.reduce((acc, d) => acc + d.counters.tokens, 0);
|
|
113
|
+
}
|
|
114
|
+
function truncateLastLine(filePath) {
|
|
115
|
+
const lines = readFileSync(filePath, "utf8").split("\n").filter((l) => l.length > 0);
|
|
116
|
+
lines.pop();
|
|
117
|
+
writeFileSync(filePath, lines.length === 0 ? "" : `${lines.join("\n")}\n`, "utf8");
|
|
118
|
+
}
|
|
119
|
+
async function expectExitSignal(code, pattern, run) {
|
|
120
|
+
await assert.rejects(run, (cause) => {
|
|
121
|
+
assert.ok(cause instanceof ExitSignal, `expected ExitSignal, got ${String(cause)}`);
|
|
122
|
+
assert.equal(cause.code, code);
|
|
123
|
+
assert.match(cause.message, pattern);
|
|
124
|
+
return true;
|
|
125
|
+
});
|
|
126
|
+
return (await run.catch((c) => c));
|
|
127
|
+
}
|
|
128
|
+
function runEvolutionFor(f, opts = {}) {
|
|
129
|
+
const mutator = opts.mutatorCommand === null ? undefined : opts.mutatorCommand ?? f.mutatorCommand;
|
|
130
|
+
return runEvolution({
|
|
131
|
+
entry: f.entry,
|
|
132
|
+
configDir: f.configDir,
|
|
133
|
+
...(opts.reps === undefined ? {} : { reps: opts.reps }),
|
|
134
|
+
...(opts.maxCandidates === undefined ? {} : { maxCandidates: opts.maxCandidates }),
|
|
135
|
+
...(opts.dryRun === undefined ? {} : { dryRun: opts.dryRun }),
|
|
136
|
+
...(mutator === undefined ? {} : { mutatorCommand: mutator }),
|
|
137
|
+
env: f.env,
|
|
138
|
+
sandboxRoot: f.sandboxRoot,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
// ------------------------------------------------------------------ AC (a)
|
|
142
|
+
test("AC(a): toy run benches incumbent + 2 candidates, fix-add nominated, exit 0", async (t) => {
|
|
143
|
+
const f = await loopFixture(t);
|
|
144
|
+
const out = await runEvolutionFor(f);
|
|
145
|
+
assert.equal(out.exitCode, 0, `expected exit 0, lines: ${out.lines.join("\n")}`);
|
|
146
|
+
const rows = generationRows(f);
|
|
147
|
+
const incumbents = rows.filter((d) => d.source === "incumbent");
|
|
148
|
+
assert.equal(incumbents.length, 1, "exactly one incumbent baseline row");
|
|
149
|
+
assert.equal(incumbents[0]?.complete, true);
|
|
150
|
+
assert.equal(incumbents[0]?.reps, 2, "default reps = nReps.initial");
|
|
151
|
+
const cands = candidateRows(f);
|
|
152
|
+
assert.deepEqual(cands.map((d) => d.candidateId).sort(), ["annotate-add", "fix-add"], "touch-kernel was rejected by the path policy, never benched");
|
|
153
|
+
const fix = cands.find((d) => d.candidateId === "fix-add");
|
|
154
|
+
const annotate = cands.find((d) => d.candidateId === "annotate-add");
|
|
155
|
+
assert.equal(fix?.verdict, "nominated", `fix-add: ${fix?.gateFailures?.join("; ")}`);
|
|
156
|
+
assert.equal(annotate?.verdict, "culled");
|
|
157
|
+
assert.ok(fix !== undefined && typeof fix.gain === "number" && fix.gain >= 0.5);
|
|
158
|
+
// per-unit matrix: add/mul scored on train, sub scored on val, explode excluded
|
|
159
|
+
// (grader inconclusive => failures, never a fabricated 0 score).
|
|
160
|
+
for (const row of cands) {
|
|
161
|
+
const byId = new Map(row.units.map((u) => [u.unitId, u]));
|
|
162
|
+
assert.deepEqual(byId.get("add")?.scores, row.candidateId === "fix-add" ? [1, 1] : [0, 0]);
|
|
163
|
+
assert.deepEqual(byId.get("mul")?.scores, [1, 1]);
|
|
164
|
+
assert.deepEqual(byId.get("sub")?.scores, [1, 1], "val unit benched for the gate");
|
|
165
|
+
assert.equal(byId.get("explode")?.scores.length, 0, "inconclusive unit carries no score");
|
|
166
|
+
assert.ok((byId.get("explode")?.failures.length ?? 0) > 0, "inconclusive recorded as failure note");
|
|
167
|
+
assert.equal(row.complete, true);
|
|
168
|
+
assert.equal(row.counters.candidates, 1);
|
|
169
|
+
assert.equal(typeof row.treeSha, "string");
|
|
170
|
+
assert.match(row.treeSha ?? "", /^[0-9a-f]{40}$/);
|
|
171
|
+
assert.ok(row.manifest.some((m) => m.path === "grader.mjs"), "kernel manifest in the record");
|
|
172
|
+
assert.equal(row.benchProvenance.benchType, "toy");
|
|
173
|
+
assert.ok(row.benchProvenance.versions.length > 0);
|
|
174
|
+
assert.ok(row.units.flatMap((u) => u.runIds).every((id) => id.startsWith("r-g-")), "runIds are ids.runId(g-…) shaped");
|
|
175
|
+
}
|
|
176
|
+
// exactly-once bookkeeping: 3 targets x (add,mul,sub = 3 scored units) x 2 reps.
|
|
177
|
+
assert.equal(scoredSamples(rows), 18);
|
|
178
|
+
assert.ok(spentTokens(rows) > 0);
|
|
179
|
+
// every tracked child was removed on normal exit; the log file was created though.
|
|
180
|
+
assert.deepEqual(readChildRecords(f.repo), [], "active-children emptied on clean exit");
|
|
181
|
+
});
|
|
182
|
+
// ------------------------------------------------------- resume exactly-once
|
|
183
|
+
test("resume: truncated last row re-benches only the missing tree, counters never double", async (t) => {
|
|
184
|
+
const f = await loopFixture(t);
|
|
185
|
+
const first = await runEvolutionFor(f);
|
|
186
|
+
assert.equal(first.exitCode, 0);
|
|
187
|
+
const cleanRows = generationRows(f);
|
|
188
|
+
const cleanSamples = scoredSamples(cleanRows);
|
|
189
|
+
const cleanTokens = spentTokens(cleanRows);
|
|
190
|
+
// simulate SIGKILL after the annotate row committed but before the fix-add row:
|
|
191
|
+
truncateLastLine(ledgerPath(f.repo));
|
|
192
|
+
assert.deepEqual(candidateRows(f).map((d) => d.candidateId), ["annotate-add"]);
|
|
193
|
+
const again = await runEvolutionFor(f);
|
|
194
|
+
assert.equal(again.exitCode, 0, again.lines.join("\n"));
|
|
195
|
+
const rows = generationRows(f);
|
|
196
|
+
const cands = candidateRows(f);
|
|
197
|
+
assert.equal(cands.length, 2, "exactly two candidate generations after resume");
|
|
198
|
+
assert.deepEqual(new Set(cands.map((d) => d.candidateId)).size, 2, "no duplicate candidate generations");
|
|
199
|
+
assert.equal(rows.filter((d) => d.source === "incumbent").length, 1, "incumbent not re-benched");
|
|
200
|
+
assert.equal(scoredSamples(rows), cleanSamples, "score samples after resume == clean run");
|
|
201
|
+
assert.equal(spentTokens(rows), cleanTokens, "budget counters never double");
|
|
202
|
+
});
|
|
203
|
+
test("resume: third invocation of a finished run adds no generation rows at all", async (t) => {
|
|
204
|
+
const f = await loopFixture(t);
|
|
205
|
+
await runEvolutionFor(f);
|
|
206
|
+
const before = generationRows(f);
|
|
207
|
+
const out = await runEvolutionFor(f, { maxCandidates: 2 });
|
|
208
|
+
assert.equal(out.exitCode, 0, out.lines.join("\n"));
|
|
209
|
+
const after = generationRows(f);
|
|
210
|
+
assert.equal(after.length, before.length, "every candidate tree was already in the ledger");
|
|
211
|
+
assert.equal(spentTokens(after), spentTokens(before));
|
|
212
|
+
// Full-budget rerun: remaining > 0 so the session runs, but the driver slices
|
|
213
|
+
// the batch BEFORE our dedup — the recorded fix-add never re-delivers. The
|
|
214
|
+
// stored nominated verdict must still carry into the exit status (else a
|
|
215
|
+
// completed run flips to exit 1 on every rerun).
|
|
216
|
+
const wide = await runEvolutionFor(f);
|
|
217
|
+
assert.equal(wide.exitCode, 0, wide.lines.join("\n"));
|
|
218
|
+
assert.ok(wide.lines.some((l) => /carried into this run's exit status/.test(l)), wide.lines.join("\n"));
|
|
219
|
+
assert.equal(generationRows(f).length, before.length, "carry reporting benches nothing new");
|
|
220
|
+
});
|
|
221
|
+
// ---------------------------------------------------------- AC (d) --dry-run
|
|
222
|
+
test("AC(d): --dry-run prints the full plan and mutates nothing", async (t) => {
|
|
223
|
+
const f = await loopFixture(t, { maxTokens: 999999 });
|
|
224
|
+
const out = await runEvolutionFor(f, { dryRun: true, maxCandidates: 1 });
|
|
225
|
+
assert.equal(out.exitCode, 0);
|
|
226
|
+
const plan = out.lines.join("\n");
|
|
227
|
+
assert.match(plan, /genome 'toy-smoke'/);
|
|
228
|
+
assert.match(plan, /bench: toy/);
|
|
229
|
+
assert.match(plan, /units: add\(train\) mul\(train\) explode\(train\) sub\(val\)/);
|
|
230
|
+
assert.match(plan, /reps: 2/);
|
|
231
|
+
assert.match(plan, /maxCandidates=1 \(clamped from 4\)/);
|
|
232
|
+
assert.match(plan, /maxTokens=\d+/);
|
|
233
|
+
assert.match(plan, /resume: no completed generation/);
|
|
234
|
+
assert.match(plan, /incumbent bench: missing/);
|
|
235
|
+
assert.match(plan, /candidate source: node .* --mode three/);
|
|
236
|
+
// absence proof: none of the run's mutable state was created at all.
|
|
237
|
+
assert.equal(existsSync(ledgerPath(f.repo)), false, "dry-run created no ledger file");
|
|
238
|
+
assert.equal(existsSync(activeChildrenPath(f.repo)), false, "dry-run touched no child log");
|
|
239
|
+
assert.equal(existsSync(path.join(f.configDir, ".locks")), false, "dry-run took no lock");
|
|
240
|
+
assert.equal(existsSync(f.xdgRoot), false, "dry-run spawned no worktree");
|
|
241
|
+
assert.equal(existsSync(path.join(f.repo, ".state")), false, "dry-run did not mkdir .state");
|
|
242
|
+
// and the same fixture still runs for real afterwards (dry left nothing behind).
|
|
243
|
+
const real = await runEvolutionFor(f);
|
|
244
|
+
assert.equal(real.exitCode, 0, real.lines.join("\n"));
|
|
245
|
+
assert.ok(candidateRows(f).length > 0);
|
|
246
|
+
});
|
|
247
|
+
// ---------------------------------------------------------- AC (c) budget-exhaust
|
|
248
|
+
test("AC(c): maxTokens=1 ⇒ inconclusive exit 2, gain null, never a zero-score pass", async (t) => {
|
|
249
|
+
const f = await loopFixture(t, { maxTokens: 1 });
|
|
250
|
+
const out = await runEvolutionFor(f);
|
|
251
|
+
assert.equal(out.exitCode, 2, `budget exhaustion must be cannot-answer, lines: ${out.lines.join("\n")}`);
|
|
252
|
+
assert.match(out.lines.join("\n"), /budget exhausted: tokens=/);
|
|
253
|
+
const cands = candidateRows(f);
|
|
254
|
+
assert.ok(cands.length > 0, "spent candidates are booked in the ledger even when truncated");
|
|
255
|
+
for (const row of cands) {
|
|
256
|
+
assert.equal(row.verdict, "inconclusive");
|
|
257
|
+
assert.equal(row.gain, null, "inconclusive never fabricates an effect size");
|
|
258
|
+
assert.equal(row.complete, false);
|
|
259
|
+
assert.ok(row.gateFailures?.some((msg) => /budget exhausted: tokens=/.test(msg)));
|
|
260
|
+
}
|
|
261
|
+
assert.ok(cands.every((d) => d.units.every((u) => u.scores.length === 0)), "truncated benches carry no fabricated score samples");
|
|
262
|
+
assert.ok(!generationRows(f).some((d) => d.verdict === "nominated"));
|
|
263
|
+
});
|
|
264
|
+
// ------------------------------------------------------ cancellation + clamp
|
|
265
|
+
test("cancellation: --max-candidates 1 benches exactly one candidate and finishes", async (t) => {
|
|
266
|
+
const f = await loopFixture(t);
|
|
267
|
+
const out = await runEvolutionFor(f, { maxCandidates: 1 });
|
|
268
|
+
const cands = candidateRows(f);
|
|
269
|
+
assert.equal(cands.length, 1, "session clamped to the cap: one applied candidate");
|
|
270
|
+
assert.equal(cands[0]?.candidateId, "annotate-add");
|
|
271
|
+
assert.equal(out.exitCode, cands[0]?.verdict === "culled" ? 1 : out.exitCode);
|
|
272
|
+
// cap reached ⇒ a second run spends nothing more.
|
|
273
|
+
const second = await runEvolutionFor(f, { maxCandidates: 1 });
|
|
274
|
+
assert.match(second.lines.join("\n"), /cap 1 already spent/);
|
|
275
|
+
assert.equal(candidateRows(f).length, 1);
|
|
276
|
+
});
|
|
277
|
+
// ------------------------------------------------------------ AC (e) kernel tamper
|
|
278
|
+
test("AC(e): tampered kernel file refuses the run at startup, exit 1, nothing exists", async (t) => {
|
|
279
|
+
const f = await loopFixture(t);
|
|
280
|
+
writeFileSync(path.join(f.repo, "grader.mjs"), `${readFileSync(path.join(f.repo, "grader.mjs"), "utf8")}// tampered\n`, "utf8");
|
|
281
|
+
await expectExitSignal(1, /kernel drift/i, runEvolutionFor(f));
|
|
282
|
+
// refusal precedes every side effect: no ledger, no child log, no lock, no worktree.
|
|
283
|
+
assert.equal(existsSync(ledgerPath(f.repo)), false);
|
|
284
|
+
assert.equal(existsSync(activeChildrenPath(f.repo)), false);
|
|
285
|
+
assert.equal(existsSync(path.join(f.configDir, ".locks")), false);
|
|
286
|
+
assert.equal(existsSync(f.xdgRoot), false);
|
|
287
|
+
});
|
|
288
|
+
test("dirty target path: run refuses before any generation exists, exit 1", async (t) => {
|
|
289
|
+
const f = await loopFixture(t);
|
|
290
|
+
const addPath = path.join(f.repo, "units", "add.mjs");
|
|
291
|
+
writeFileSync(addPath, `${readFileSync(addPath, "utf8")}// local uncommitted edit\n`, "utf8");
|
|
292
|
+
await expectExitSignal(1, /dirty target path/i, runEvolutionFor(f));
|
|
293
|
+
assert.deepEqual(Ledger.open(f.repo).readAll(), [], "refusal happened before any row appended");
|
|
294
|
+
assert.equal(existsSync(activeChildrenPath(f.repo)), false, "nothing was ever spawned");
|
|
295
|
+
assert.equal(existsSync(f.xdgRoot), false);
|
|
296
|
+
});
|
|
297
|
+
// ---------------------------------------------------------------- reaper unit
|
|
298
|
+
test("reap: malformed, own-pid and dead-pid records are skipped/truncated without kills", (_t) => {
|
|
299
|
+
const repo = mkdtempSync(path.join(os.tmpdir(), "abathur-reap-bad-"));
|
|
300
|
+
try {
|
|
301
|
+
const log = activeChildrenPath(repo);
|
|
302
|
+
mkdirSync(path.dirname(log), { recursive: true });
|
|
303
|
+
writeFileSync(log, [
|
|
304
|
+
"this is not json {{{",
|
|
305
|
+
JSON.stringify({ pid: process.pid, pgid: process.pid, genId: "g-mine", kind: "bench-run", at: "2026-09-09T00:00:00.000Z" }),
|
|
306
|
+
JSON.stringify({ pid: 4_199_999, pgid: 4_199_999, genId: "g-dead", kind: "bench-run", at: "2026-09-09T00:00:00.000Z" }),
|
|
307
|
+
JSON.stringify({ pid: -3, genId: "g-bogus" }),
|
|
308
|
+
].join("\n") + "\n", "utf8");
|
|
309
|
+
const report = reapOrphans(repo);
|
|
310
|
+
// Both the unparseable line and the structurally invalid record (pid -3)
|
|
311
|
+
// are malformed: the reaper refuses to interpret them, counts them, and moves on.
|
|
312
|
+
assert.equal(report.malformed, 2, "unparseable + invalid-pid lines counted as malformed");
|
|
313
|
+
assert.ok(report.skipped.some((s) => s.includes("self")), "own pid skipped");
|
|
314
|
+
assert.equal(report.skipped.some((s) => s.includes("bogus")), false, "invalid records are malformed, never killed");
|
|
315
|
+
assert.ok(report.reaped.some((r) => r.pid === 4_199_999), "dead group still SIGKILLed (ESRCH no-op)");
|
|
316
|
+
assert.equal(readFileSync(log, "utf8").length, 0, "log truncated after reap");
|
|
317
|
+
process.kill(process.pid, 0); // still alive — the reap never touches unrecorded/self pids
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
spawnSync("rm", ["-rf", repo]);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
test("reap: a live orphan process group from a dead run is SIGKILLed", async (t) => {
|
|
324
|
+
const repo = mkdtempSync(path.join(os.tmpdir(), "abathur-reap-live-"));
|
|
325
|
+
const sleeper = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000); process.title = 'abathur-reap-e2e-sleeper';"], {
|
|
326
|
+
detached: true,
|
|
327
|
+
stdio: "ignore",
|
|
328
|
+
});
|
|
329
|
+
t.after(() => {
|
|
330
|
+
try {
|
|
331
|
+
if (sleeper.pid !== undefined)
|
|
332
|
+
process.kill(-sleeper.pid, "SIGKILL");
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
/* already reaped */
|
|
336
|
+
}
|
|
337
|
+
spawnSync("rm", ["-rf", repo]);
|
|
338
|
+
});
|
|
339
|
+
const pid = sleeper.pid;
|
|
340
|
+
assert.ok(pid !== undefined);
|
|
341
|
+
sleeper.unref();
|
|
342
|
+
await waitForAlive(pid, true);
|
|
343
|
+
const log = activeChildrenPath(repo);
|
|
344
|
+
mkdirSync(path.dirname(log), { recursive: true });
|
|
345
|
+
writeFileSync(log, `${JSON.stringify({ v: 1, pid, pgid: pid, genId: "g-orphan", kind: "bench-run", at: new Date().toISOString() })}\n`, "utf8");
|
|
346
|
+
const report = reapOrphans(repo);
|
|
347
|
+
assert.deepEqual(report.reaped, [{ pid, alive: true }]);
|
|
348
|
+
await waitForAlive(pid, false);
|
|
349
|
+
assert.equal(readFileSync(log, "utf8").length, 0);
|
|
350
|
+
});
|
|
351
|
+
async function waitForAlive(pid, expectAlive) {
|
|
352
|
+
const deadline = Date.now() + 5_000;
|
|
353
|
+
for (;;) {
|
|
354
|
+
let alive = true;
|
|
355
|
+
try {
|
|
356
|
+
process.kill(pid, 0);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
alive = false;
|
|
360
|
+
}
|
|
361
|
+
if (alive === expectAlive)
|
|
362
|
+
return;
|
|
363
|
+
if (Date.now() > deadline)
|
|
364
|
+
assert.fail(`pid ${String(pid)} never became ${expectAlive ? "alive" : "dead"}`);
|
|
365
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// ---------------------------------------------------------------- tracker
|
|
369
|
+
test("tracker: entries appear before exec and vanish once the child exits", async (t) => {
|
|
370
|
+
const repo = await (async () => {
|
|
371
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "abathur-track-"));
|
|
372
|
+
t.after(() => spawnSync("rm", ["-rf", dir]));
|
|
373
|
+
return dir;
|
|
374
|
+
})();
|
|
375
|
+
const tracker = new ChildTracker(repo);
|
|
376
|
+
tracker.phase("g-track", "bench-run");
|
|
377
|
+
const outcome = await runChild({
|
|
378
|
+
argv: [process.execPath, "-e", "console.log('hi')"],
|
|
379
|
+
cwd: repo,
|
|
380
|
+
timeoutS: 10,
|
|
381
|
+
onChild: tracker.onChild,
|
|
382
|
+
});
|
|
383
|
+
assert.equal(outcome.kind, "exited");
|
|
384
|
+
// the record existed while the child was alive:
|
|
385
|
+
// (runChild resolves after close; removal is queued on the same event — drain first)
|
|
386
|
+
await tracker.drain();
|
|
387
|
+
assert.deepEqual(readChildRecords(repo), [], "record removed after exit");
|
|
388
|
+
assert.equal(existsSync(activeChildrenPath(repo)), true, "the log file itself persists (append-only discipline)");
|
|
389
|
+
});
|
|
390
|
+
test("onChild hook: ToyBenchAdapter reports every spawn it makes", async (t) => {
|
|
391
|
+
const f = await loopFixture(t);
|
|
392
|
+
const handles = [];
|
|
393
|
+
const adapter = new ToyBenchAdapter(f.spec, {
|
|
394
|
+
onChild: (h) => {
|
|
395
|
+
handles.push(h);
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
const sandboxRoot = path.join(f.root, "hook-sandbox");
|
|
399
|
+
await adapter.reset(sandboxRoot);
|
|
400
|
+
await adapter.seed(sandboxRoot);
|
|
401
|
+
const unit = f.spec.bench.units.find((u) => u.id === "add");
|
|
402
|
+
assert.ok(unit !== undefined);
|
|
403
|
+
const run = await adapter.run(unit, sandboxRoot, 10);
|
|
404
|
+
assert.equal(run.status, "ok");
|
|
405
|
+
const score = await adapter.score(unit);
|
|
406
|
+
assert.equal(score.kind, "scored");
|
|
407
|
+
assert.ok(handles.length >= 2, "run + grader children both reported");
|
|
408
|
+
await Promise.all(handles.map((h) => h.exited));
|
|
409
|
+
});
|
|
410
|
+
// ---------------------------------------------------------------- CLI wiring
|
|
411
|
+
test("CLI: run wiring — arg misuse is exit 2, help line preserved", async (t) => {
|
|
412
|
+
const f = await loopFixture(t);
|
|
413
|
+
const configFile = path.join(f.configDir, "config.jsonc");
|
|
414
|
+
const loaded = loadConfig({ ABATHUR_CONFIG: configFile });
|
|
415
|
+
const saved = process.env["ABATHUR_CONFIG"];
|
|
416
|
+
process.env["ABATHUR_CONFIG"] = configFile; // runRun resolves the config dir from the real env
|
|
417
|
+
t.after(() => {
|
|
418
|
+
if (saved === undefined)
|
|
419
|
+
delete process.env["ABATHUR_CONFIG"];
|
|
420
|
+
else
|
|
421
|
+
process.env["ABATHUR_CONFIG"] = saved;
|
|
422
|
+
});
|
|
423
|
+
const ctx = { loaded, args: [] };
|
|
424
|
+
await expectExitSignal(2, /--genome/, Promise.resolve(runCommand.run(ctx)));
|
|
425
|
+
await expectExitSignal(2, /unknown flag --nope/, Promise.resolve(runCommand.run({ loaded, args: ["--genome", "toy-smoke", "--nope"] })));
|
|
426
|
+
await expectExitSignal(2, /--reps/, Promise.resolve(runCommand.run({ loaded, args: ["--genome", "toy-smoke", "--reps", "abc"] })));
|
|
427
|
+
await expectExitSignal(2, /--mutator/, Promise.resolve(runCommand.run({ loaded, args: ["--genome", "toy-smoke", "--mutator", "--oops"] })));
|
|
428
|
+
await expectExitSignal(2, /no registered genome/, Promise.resolve(runCommand.run({ loaded, args: ["--genome", "ghost"] })));
|
|
429
|
+
// --include-val parses, threads into the loop, and fails closed on the toy bench:
|
|
430
|
+
await expectExitSignal(2, /--include-val is only supported by the opencode-fixture-scenarios bench/, Promise.resolve(runCommand.run({ loaded, args: ["--genome", "toy-smoke", "--include-val", "--dry-run"] })));
|
|
431
|
+
const out = await runCommand.run({ loaded, args: ["--genome", "toy-smoke", "--dry-run"] });
|
|
432
|
+
assert.equal(out, 0);
|
|
433
|
+
});
|