@tachikomagundam/abathur 0.2.6 → 0.2.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/README.md +3 -3
- package/dist/cli.js +4 -0
- package/dist/commands/readjudicate.js +28 -0
- package/dist/commands/retract.js +53 -0
- package/dist/commands/tombstone.js +28 -15
- package/dist/core/evolve/run-bench.js +7 -1
- package/dist/core/evolve/run-rows.js +14 -0
- package/dist/core/ledger.js +2 -0
- package/dist/core/promote.js +61 -46
- package/dist/core/readjudicate.js +247 -0
- package/dist/core/retract.js +166 -0
- package/dist/core/stats.js +7 -0
- package/dist/core/worktree.js +1 -1
- package/dist/genomes/toy-smoke/init.mjs +1 -1
- package/dist/test/bundle.test.js +1 -1
- package/dist/test/fixture-loop.test.js +1 -1
- package/dist/test/fixtures-self.js +1 -1
- package/dist/test/fixtures-wt.js +1 -1
- package/dist/test/friction.test.js +3 -3
- package/dist/test/graft.test.js +1 -1
- package/dist/test/historian-run-scenario.test.js +2 -2
- package/dist/test/promote.test.js +222 -5
- package/dist/test/readjudicate.test.js +212 -0
- package/dist/test/reflect.test.js +1 -1
- package/dist/test/repopath-seams.test.js +1 -0
- package/dist/test/seam-gates.test.js +6 -0
- package/dist/test/self-snapshot.test.js +2 -2
- package/dist/test/stats.test.js +17 -0
- package/docs/AGENTISM.md +58 -0
- package/docs/release/README.md +9 -0
- package/docs/release/TEMPLATE.md +10 -0
- package/docs/release/v0.2.7.md +7 -0
- package/docs/release/v0.2.8.md +8 -0
- package/graders/historian/judge-poststage.mjs +8 -3
- package/graders/pcb-agent/bench-approver.py +77 -0
- package/graders/pcb-agent/grader.mjs +130 -0
- package/graders/pcb-agent/mutate.sh +165 -0
- package/graders/pcb-agent/r20-bench.sh +27 -0
- package/graders/pcb-agent/reset-sandbox.sh +7 -0
- package/graders/pcb-agent/run-scenario-r20.sh +77 -0
- package/graders/pcb-agent/run-scenario.sh +94 -0
- package/graders/pcb-agent/seed/esp32s3-r12-BRIEF.txt +51 -0
- package/graders/pcb-agent/seed/spec.json +129 -0
- package/graders/pcb-agent/seed/tools/board_author_pcb.py +237 -0
- package/graders/pcb-agent/seed/tools/board_author_sch.py +118 -0
- package/graders/pcb-agent/seed/tools/board_finalize.sh +23 -0
- package/graders/pcb-agent/seed/tools/board_gate.py +69 -0
- package/graders/pcb-agent/seed/tools/board_gate_pcb.py +23 -0
- package/graders/pcb-agent/seed/tools/board_route.py +324 -0
- package/graders/pcb-agent/seed/tools/board_zone_pour.py +312 -0
- package/graders/pcb-agent/seed/tools/claims_lint.py +417 -0
- package/graders/pcb-agent/seed-r20-arena.sh +54 -0
- package/graders/pcb-agent/seed-sandbox.sh +13 -0
- package/package.json +1 -1
- package/plugin/abathur-command.md +3 -3
- package/plugin/abathur.ts +6 -6
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ instance learn from another's evidence; nothing in v1 talks to a network.
|
|
|
30
30
|
`peerClaim` metadata and never feed nomination math.
|
|
31
31
|
- **run loop** — incumbent → brief (failure observations) → mutator session →
|
|
32
32
|
candidate diffs → seal → re-bench → verdicts. Nominated generations sit in
|
|
33
|
-
the quarantine queue until a human runs `promote`; `tombstone` buries them.
|
|
33
|
+
the quarantine queue until a human runs `promote`; `tombstone` buries them; `retract` undoes a promote (LIFO, descendant-guarded).
|
|
34
34
|
Both are append-only ledger decisions; nothing is ever deleted.
|
|
35
35
|
|
|
36
36
|
### Install
|
|
@@ -289,7 +289,7 @@ table are [docs/federation.md](docs/federation.md).
|
|
|
289
289
|
same historian genome against one wiki will clobber each other mid-run.
|
|
290
290
|
Cross-machine serialization of historian benches is the operator's
|
|
291
291
|
responsibility (schedule, lockbox, one-wiki-one-bench rule — your call).
|
|
292
|
-
- Promotion and
|
|
292
|
+
- Promotion, burial and retraction are yours alone: `promote`/`tombstone`/`retract` carry no
|
|
293
293
|
`--confirm` because the CLI invocation **is** the gate; there is no
|
|
294
294
|
`--force` anywhere in the binary. Review `abathur status` quarantine depth
|
|
295
295
|
and the `graft decisions` rows before promoting.
|
|
@@ -476,7 +476,7 @@ opencode 会在启动时自行从 npm 把包下载到它自己的缓存
|
|
|
476
476
|
`self-eval`、`kernel`、`--help`),120 秒超时、64 KB 输出封顶。二进制从
|
|
477
477
|
`PATH` 解析,除非用 `ABATHUR_BIN` 覆盖。诚实的权限说明:该工具具备与
|
|
478
478
|
bash 等同的权限——`run` 与 `genome` 按设计就会 spawn 变异器/引擎二进制——
|
|
479
|
-
所以允许清单限制的是笔误与体验,而非能力。`promote` 与 `
|
|
479
|
+
所以允许清单限制的是笔误与体验,而非能力。`promote`、`tombstone` 与 `retract` 刻意
|
|
480
480
|
不可经由工具触达:它们是人类闸门,必须在终端里运行。
|
|
481
481
|
- 用户命令 **`/abathur <参数…>`**——斜杠命令,指示智能体把参数
|
|
482
482
|
翻译成工具调用并回报退出码。路线 A 由复制的 `commands/abathur.md`
|
package/dist/cli.js
CHANGED
|
@@ -12,10 +12,12 @@ import { graftCommand } from "./commands/graft.js";
|
|
|
12
12
|
import { kernelCommand } from "./commands/kernel.js";
|
|
13
13
|
import { opencodeCommand } from "./commands/opencode.js";
|
|
14
14
|
import { promoteCommand } from "./commands/promote.js";
|
|
15
|
+
import { readjudicateCommand } from "./commands/readjudicate.js";
|
|
15
16
|
import { runCommand } from "./commands/run.js";
|
|
16
17
|
import { selfEvalCommand } from "./commands/self-eval.js";
|
|
17
18
|
import { statusCommand } from "./commands/status.js";
|
|
18
19
|
import { tombstoneCommand } from "./commands/tombstone.js";
|
|
20
|
+
import { retractCommand } from "./commands/retract.js";
|
|
19
21
|
import { ConfigError, loadConfig } from "./config.js";
|
|
20
22
|
import { EXIT_CANNOT_ANSWER, EXIT_OK, ExitSignal, cannotAnswer, renderExitSignal, } from "./exit.js";
|
|
21
23
|
// Router order mirrors the plan; summaries appear verbatim in --help.
|
|
@@ -24,7 +26,9 @@ export const COMMANDS = [
|
|
|
24
26
|
runCommand,
|
|
25
27
|
statusCommand,
|
|
26
28
|
promoteCommand,
|
|
29
|
+
readjudicateCommand,
|
|
27
30
|
tombstoneCommand,
|
|
31
|
+
retractCommand,
|
|
28
32
|
bundleCommand,
|
|
29
33
|
graftCommand,
|
|
30
34
|
selfEvalCommand,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// `abathur re-adjudicate <label> <genId>` — replay the CURRENT gate over an epoch's
|
|
2
|
+
// archived rows and append a corrected verdict row (with provenance). Re-judges, never
|
|
3
|
+
// flips by hand; promote continues to read the LAST row. See core/readjudicate.ts for
|
|
4
|
+
// the replay contract (counters minus own slot, bank excludes this candidate's trees).
|
|
5
|
+
import { resolveConfigDir } from "../config.js";
|
|
6
|
+
import { EXIT_OK, EXIT_CANNOT_ANSWER, cannotAnswer } from "../exit.js";
|
|
7
|
+
import { readjudicateGeneration } from "../core/readjudicate.js";
|
|
8
|
+
import { writeStdout } from "../out.js";
|
|
9
|
+
import { resolveUniqueEntry } from "./run.js";
|
|
10
|
+
const USAGE = "usage: abathur re-adjudicate <label> <genId>";
|
|
11
|
+
function runReadjudicate(args) {
|
|
12
|
+
const [label, genId] = args;
|
|
13
|
+
if (label === undefined || genId === undefined || args.length !== 2)
|
|
14
|
+
cannotAnswer(`re-adjudicate: expected exactly <label> <genId>`, USAGE);
|
|
15
|
+
const configDir = resolveConfigDir();
|
|
16
|
+
const entry = resolveUniqueEntry(configDir, label, "re-adjudicate");
|
|
17
|
+
const outcome = readjudicateGeneration({ entry, configDir, genId, now: () => new Date() });
|
|
18
|
+
for (const line of outcome.lines)
|
|
19
|
+
writeStdout(line);
|
|
20
|
+
// an inconclusive replay means the gate cannot answer — surface it in the exit code,
|
|
21
|
+
// matching the VERDICT_EXIT semantics of run/promote consumers.
|
|
22
|
+
return outcome.verdict === "inconclusive" ? EXIT_CANNOT_ANSWER : EXIT_OK;
|
|
23
|
+
}
|
|
24
|
+
export const readjudicateCommand = {
|
|
25
|
+
name: "re-adjudicate",
|
|
26
|
+
summary: "replay the current gate over a gen's archived rows; append corrected verdict + provenance (files stay immutable)",
|
|
27
|
+
run: ({ args }) => Promise.resolve(runReadjudicate(args)),
|
|
28
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// `abathur retract <label> <genId> --reason <text>` — the human gate that
|
|
2
|
+
// undoes a promote (core in core/retract.ts, imported ONLY here — the
|
|
3
|
+
// import-policy pin lives in src/test/promote.test.ts). Terminal-only like
|
|
4
|
+
// promote/tombstone: the opencode plugin tool must never reach it. No --force:
|
|
5
|
+
// every refusal is a bug report, not a flag.
|
|
6
|
+
import { resolveConfigDir } from "../config.js";
|
|
7
|
+
import { EXIT_OK, cannotAnswer } from "../exit.js";
|
|
8
|
+
import { retractGeneration } from "../core/retract.js";
|
|
9
|
+
import { writeStdout } from "../out.js";
|
|
10
|
+
import { resolveUniqueEntry } from "./run.js";
|
|
11
|
+
const USAGE = "usage: abathur retract <label> <genId> --reason <text>";
|
|
12
|
+
const MAX_REASON_CHARS = 500;
|
|
13
|
+
async function runRetract(args) {
|
|
14
|
+
const positional = [];
|
|
15
|
+
let reason = null;
|
|
16
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
17
|
+
const arg = args[i];
|
|
18
|
+
if (arg === "--reason") {
|
|
19
|
+
const value = args[(i += 1)];
|
|
20
|
+
if (value === undefined || value.startsWith("--"))
|
|
21
|
+
cannotAnswer("retract: --reason requires a value", USAGE);
|
|
22
|
+
if (reason !== null)
|
|
23
|
+
cannotAnswer("retract: --reason given twice", USAGE);
|
|
24
|
+
reason = value;
|
|
25
|
+
}
|
|
26
|
+
else if (arg.startsWith("--")) {
|
|
27
|
+
cannotAnswer(`retract: unknown flag ${arg}`, USAGE);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
positional.push(arg);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const [label, genId] = positional;
|
|
34
|
+
if (label === undefined || genId === undefined || positional.length !== 2) {
|
|
35
|
+
cannotAnswer("retract: expected exactly <label> <genId> --reason <text>", USAGE);
|
|
36
|
+
}
|
|
37
|
+
if (reason === null || reason.trim().length === 0) {
|
|
38
|
+
cannotAnswer("retract: --reason <text> is required (why is this promote being undone?)", USAGE);
|
|
39
|
+
}
|
|
40
|
+
if (reason.length > MAX_REASON_CHARS)
|
|
41
|
+
cannotAnswer(`retract: --reason exceeds ${String(MAX_REASON_CHARS)} chars`, USAGE);
|
|
42
|
+
const configDir = resolveConfigDir();
|
|
43
|
+
const entry = resolveUniqueEntry(configDir, label, "retract");
|
|
44
|
+
const outcome = await retractGeneration({ entry, configDir, genId, reason });
|
|
45
|
+
for (const line of outcome.lines)
|
|
46
|
+
writeStdout(line);
|
|
47
|
+
return EXIT_OK;
|
|
48
|
+
}
|
|
49
|
+
export const retractCommand = {
|
|
50
|
+
name: "retract",
|
|
51
|
+
summary: "human gate: undo a promote (LIFO, descendant-guarded; append-only row, ref deleted, manifest cleared)",
|
|
52
|
+
run: ({ args }) => runRetract(args),
|
|
53
|
+
};
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
// row survive; the reason is stored verbatim (canonicalJson escapes control
|
|
4
4
|
// bytes) but NEVER echoed raw — the confirmation line is sanitized like todo-8's
|
|
5
5
|
// sealMessage so untrusted text cannot forge terminal output.
|
|
6
|
-
import
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { acquireGenomeLock, acquireLock, LEDGER_KIND_TOMBSTONE, Ledger } from "../core/ledger.js";
|
|
7
8
|
import { effectiveRepoPath } from "../core/spec.js";
|
|
8
9
|
import { resolveConfigDir } from "../config.js";
|
|
9
10
|
import { EXIT_OK, blocked, cannotAnswer } from "../exit.js";
|
|
10
11
|
import { writeStdout } from "../out.js";
|
|
11
12
|
import { resolveUniqueEntry } from "./run.js";
|
|
12
|
-
const LEDGER_KIND_TOMBSTONE = "tombstone";
|
|
13
13
|
const MAX_REASON_CHARS = 4000;
|
|
14
14
|
const USAGE = "usage: abathur tombstone <label> <genId> --reason <text>";
|
|
15
15
|
function echoed(reason) {
|
|
@@ -50,21 +50,34 @@ function runTombstone(args) {
|
|
|
50
50
|
const configDir = resolveConfigDir();
|
|
51
51
|
const entry = resolveUniqueEntry(configDir, label, "tombstone");
|
|
52
52
|
const ledger = Ledger.open(effectiveRepoPath(entry.spec.repoPath));
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
53
|
+
// single-flight with the run loop and sibling gates (same postmortem as promote)
|
|
54
|
+
const lease = acquireGenomeLock({ ledger, configDir, genomeFp: entry.fingerprint });
|
|
55
|
+
try {
|
|
56
|
+
const repoLease = acquireLock({ configDir: path.join(effectiveRepoPath(entry.spec.repoPath), ".state", "abathur"), key: "gate", label: "gate lock" });
|
|
57
|
+
try {
|
|
58
|
+
const records = ledger.readAll();
|
|
59
|
+
if (!records.some((r) => r.kind === "generation_complete" && r.genId === genId)) {
|
|
60
|
+
blocked(`tombstone: no generation_complete ledger row for gen '${genId}' of '${label}' — nothing to bury`);
|
|
61
|
+
}
|
|
62
|
+
const promoted = records.find((r) => r.kind === "promote" && r.genId === genId);
|
|
63
|
+
if (promoted !== undefined) {
|
|
64
|
+
blocked(`tombstone: gen '${genId}' was already promoted at ${promoted.ts} — cull is for candidates the human never accepted`);
|
|
65
|
+
}
|
|
66
|
+
const buried = records.find((r) => r.kind === LEDGER_KIND_TOMBSTONE && r.genId === genId);
|
|
67
|
+
if (buried !== undefined) {
|
|
68
|
+
blocked(`tombstone: gen '${genId}' is already tombstoned at ${buried.ts}`);
|
|
69
|
+
}
|
|
70
|
+
ledger.append({ kind: LEDGER_KIND_TOMBSTONE, genId, data: { genId, reason, actor: "cli" } });
|
|
71
|
+
writeStdout(`tombstone recorded: '${genId}' (reason: ${echoed(reason)}) — commits and lineage preserved (cull ≠ delete)`);
|
|
72
|
+
return EXIT_OK;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
repoLease.release();
|
|
76
|
+
}
|
|
60
77
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
blocked(`tombstone: gen '${genId}' is already tombstoned at ${buried.ts}`);
|
|
78
|
+
finally {
|
|
79
|
+
lease.release();
|
|
64
80
|
}
|
|
65
|
-
ledger.append({ kind: LEDGER_KIND_TOMBSTONE, genId, data: { genId, reason, actor: "cli" } });
|
|
66
|
-
writeStdout(`tombstone recorded: '${genId}' (reason: ${echoed(reason)}) — commits and lineage preserved (cull ≠ delete)`);
|
|
67
|
-
return EXIT_OK;
|
|
68
81
|
}
|
|
69
82
|
export const tombstoneCommand = {
|
|
70
83
|
name: "tombstone",
|
|
@@ -138,7 +138,13 @@ export async function benchTarget(o) {
|
|
|
138
138
|
continue;
|
|
139
139
|
}
|
|
140
140
|
const score = await bundle.adapter.score(unit);
|
|
141
|
-
if (score.kind === "scored") {
|
|
141
|
+
if (score.kind === "scored" && !Number.isFinite(score.result.score)) {
|
|
142
|
+
// r21 injury ticket: a NaN/Inf score is a BROKEN RULER, not a number —
|
|
143
|
+
// it must never become a replicate (vacuous aggregates minted bogus
|
|
144
|
+
// nominations: stats saw finite-looking rows and judged on nothing).
|
|
145
|
+
row.failures.push(`${unit.id} rep ${String(rep)}: grader score not finite (${String(score.result.score)}) — ruler failure, never counts as evidence`);
|
|
146
|
+
}
|
|
147
|
+
else if (score.kind === "scored") {
|
|
142
148
|
row.scores.push(score.result.score);
|
|
143
149
|
row.runIds.push(runId(o.genId, unit.id, rep));
|
|
144
150
|
if (!score.result.pass) {
|
|
@@ -49,6 +49,20 @@ export const generationRowDataSchema = z
|
|
|
49
49
|
gateFailures: z.array(z.string()).optional(),
|
|
50
50
|
/** Unrelated worktree dirtiness notices (todo 3 contract: todo 9 books them). */
|
|
51
51
|
dirtyWorktree: z.array(z.strictObject({ xy: z.string(), file: z.string() })).optional(),
|
|
52
|
+
/** Present only on rows appended by `abathur re-adjudicate`: the corrected verdict with
|
|
53
|
+
* full replay provenance (source rows, bank state, replaced verdict). The original row
|
|
54
|
+
* stays in the files untouched — this field documents the amendment, never hides it. */
|
|
55
|
+
readjudication: z.strictObject({
|
|
56
|
+
at: z.string().min(1),
|
|
57
|
+
specFingerprint: z.string().min(1),
|
|
58
|
+
gate: z.enum(["legacy", "acceptance"]),
|
|
59
|
+
bank: z.strictObject({ units: z.number().int().nonnegative(), priorSigma: z.number(), pooledDf: z.number() }).nullable(),
|
|
60
|
+
sourceRows: z.strictObject({
|
|
61
|
+
candidate: z.strictObject({ file: z.string().min(1), ts: z.string().min(1) }),
|
|
62
|
+
incumbent: z.strictObject({ file: z.string().min(1), ts: z.string().min(1) }),
|
|
63
|
+
}),
|
|
64
|
+
replacedVerdict: z.string().min(1),
|
|
65
|
+
}).optional(),
|
|
52
66
|
benchProvenance: z.strictObject({
|
|
53
67
|
benchType: z.enum(["toy", "opencode-fixture-scenarios"]),
|
|
54
68
|
versions: z.array(z.strictObject({ bin: z.string().min(1), version: z.string() })),
|
package/dist/core/ledger.js
CHANGED
|
@@ -14,6 +14,8 @@ import { acquireLock } from "./locks.js";
|
|
|
14
14
|
export { acquireLock, lockDirFor } from "./locks.js";
|
|
15
15
|
export const LEDGER_KIND_LOCK_TAKEOVER = "lock_takeover";
|
|
16
16
|
export const LEDGER_KIND_GENERATION_COMPLETE = "generation_complete";
|
|
17
|
+
export const LEDGER_KIND_PROMOTE = "promote";
|
|
18
|
+
export const LEDGER_KIND_TOMBSTONE = "tombstone";
|
|
17
19
|
export const ledgerRecordSchema = z.strictObject({
|
|
18
20
|
v: z.literal(1),
|
|
19
21
|
ts: z.string().min(1),
|
package/dist/core/promote.js
CHANGED
|
@@ -11,12 +11,12 @@ import { compileGlob } from "./glob.js";
|
|
|
11
11
|
import { fastForwardIncumbent } from "./incumbent.js";
|
|
12
12
|
import { effectiveRepoPath } from "./spec.js";
|
|
13
13
|
import { buildManifest, kernelsDirOf, manifestPathFor, readManifestFile, serializeManifest, } from "./kernel.js";
|
|
14
|
-
import { Ledger, ledgerPath } from "./ledger.js";
|
|
14
|
+
import { acquireGenomeLock, acquireLock, LEDGER_KIND_PROMOTE, Ledger, ledgerPath } from "./ledger.js";
|
|
15
15
|
import { gitOpts, requireSha } from "./genome-paths.js";
|
|
16
16
|
import { snapshotCommit } from "./snapshot.js";
|
|
17
17
|
import { tryGit } from "../util/git.js";
|
|
18
18
|
import { decodeGenerationRecord } from "./evolve/run-bench.js";
|
|
19
|
-
export
|
|
19
|
+
export { LEDGER_KIND_PROMOTE };
|
|
20
20
|
/** Ledger/display-safe flattening of untrusted text (todo-8 sealMessage discipline). */
|
|
21
21
|
function display(value, max = 96) {
|
|
22
22
|
const flat = value.replace(/[^ -~]/g, " ").replace(/\s+/g, " ").trim();
|
|
@@ -70,51 +70,66 @@ export async function promoteGeneration(req) {
|
|
|
70
70
|
const shown = display(req.genId);
|
|
71
71
|
const opts = req.env === undefined ? {} : { env: req.env };
|
|
72
72
|
const ledger = Ledger.open(repo);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
blocked(`promote: no generation_complete ledger row for gen '${shown}' of '${entry.label}'`, `state is read only from ${ledgerPath(repo)} — check candidates with \`abathur status ${entry.label}\``);
|
|
78
|
-
}
|
|
79
|
-
if (records.some((r) => r.kind === LEDGER_KIND_PROMOTE && r.genId === req.genId)) {
|
|
80
|
-
blocked(`promote: gen '${shown}' is already promoted (promote row in ledger) — nothing to do`);
|
|
81
|
-
}
|
|
82
|
-
const nom = nominatedGeneration(decodeGenerationRecord(lastRow), shown);
|
|
83
|
-
const manifestFile = manifestPathFor(configDir, entry.fingerprint);
|
|
84
|
-
let manifestEntries;
|
|
73
|
+
// the human gate is a state mutation: single-flight it against the run loop
|
|
74
|
+
// and any concurrent gate, same class as graft (postmortem 2026-09-28 retract
|
|
75
|
+
// review — the gate read→check→mutate→append sequence must not interleave).
|
|
76
|
+
const lease = acquireGenomeLock({ ledger, configDir, genomeFp: entry.fingerprint });
|
|
85
77
|
try {
|
|
86
|
-
|
|
78
|
+
const repoLease = acquireLock({ configDir: path.join(repo, ".state", "abathur"), key: "gate", label: "gate lock" });
|
|
79
|
+
try {
|
|
80
|
+
const records = ledger.readAll();
|
|
81
|
+
const genRows = records.filter((r) => r.kind === "generation_complete" && r.genId === req.genId);
|
|
82
|
+
const lastRow = genRows.at(-1);
|
|
83
|
+
if (lastRow === undefined) {
|
|
84
|
+
blocked(`promote: no generation_complete ledger row for gen '${shown}' of '${entry.label}'`, `state is read only from ${ledgerPath(repo)} — check candidates with \`abathur status ${entry.label}\``);
|
|
85
|
+
}
|
|
86
|
+
if (records.some((r) => r.kind === LEDGER_KIND_PROMOTE && r.genId === req.genId)) {
|
|
87
|
+
blocked(`promote: gen '${shown}' is already promoted (promote row in ledger) — nothing to do`);
|
|
88
|
+
}
|
|
89
|
+
const nom = nominatedGeneration(decodeGenerationRecord(lastRow), shown);
|
|
90
|
+
const manifestFile = manifestPathFor(configDir, entry.fingerprint);
|
|
91
|
+
let manifestEntries;
|
|
92
|
+
try {
|
|
93
|
+
manifestEntries = readManifestFile(manifestFile);
|
|
94
|
+
}
|
|
95
|
+
catch (cause) {
|
|
96
|
+
if (cause instanceof ExitSignal)
|
|
97
|
+
throw cause;
|
|
98
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
99
|
+
cannotAnswer(`promote: kernel manifest unreadable, refusing to gate: ${message}`);
|
|
100
|
+
}
|
|
101
|
+
const sealedPaths = new Set(manifestEntries.map((e) => e.path));
|
|
102
|
+
const matchers = entry.spec.kernel.immutableGlobs.map((glob) => compileGlob(glob));
|
|
103
|
+
await sealedPathCheck(req.genId, nom, matchers, sealedPaths, repo, opts);
|
|
104
|
+
const genome = { repoPath: repo, genomeFp: entry.fingerprint };
|
|
105
|
+
const ff = await fastForwardIncumbent(genome, nom.commitSha, opts);
|
|
106
|
+
// regeneration happens ONLY here, after the human-invoked diff passed:
|
|
107
|
+
// manifest reborn from the new incumbent's committed tree, not the worktree.
|
|
108
|
+
const snap = await snapshotCommit(genome, nom.commitSha, opts);
|
|
109
|
+
const fresh = buildManifest(snap.snapshotPath, entry.spec.kernel.immutableGlobs);
|
|
110
|
+
mkdirSync(kernelsDirOf(configDir), { recursive: true });
|
|
111
|
+
writeFileSync(manifestFile, serializeManifest(fresh), "utf8");
|
|
112
|
+
ledger.append({
|
|
113
|
+
kind: LEDGER_KIND_PROMOTE,
|
|
114
|
+
genId: req.genId,
|
|
115
|
+
data: { actor: "cli", genId: req.genId, from: ff.fromSha, to: ff.toSha },
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
genId: req.genId,
|
|
119
|
+
fromSha: ff.fromSha,
|
|
120
|
+
toSha: ff.toSha,
|
|
121
|
+
lines: [
|
|
122
|
+
`promoted '${entry.label}' (${entry.fingerprint}): ${ff.fromSha === null ? "incumbent branch created" : short(ff.fromSha)} -> ${short(ff.toSha)} (gen ${shown})`,
|
|
123
|
+
`manifest: rewrote ${path.relative(process.cwd(), manifestFile) || manifestFile} from tree ${short(nom.commitSha)} (${String(fresh.length)} sealed ${fresh.length === 1 ? "entry" : "entries"})`,
|
|
124
|
+
`ledger: appended '${LEDGER_KIND_PROMOTE}' row (actor: cli) to ${ledgerPath(repo)}`,
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
repoLease.release();
|
|
130
|
+
}
|
|
87
131
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
throw cause;
|
|
91
|
-
const message = cause instanceof Error ? cause.message : String(cause);
|
|
92
|
-
cannotAnswer(`promote: kernel manifest unreadable, refusing to gate: ${message}`);
|
|
132
|
+
finally {
|
|
133
|
+
lease.release();
|
|
93
134
|
}
|
|
94
|
-
const sealedPaths = new Set(manifestEntries.map((e) => e.path));
|
|
95
|
-
const matchers = entry.spec.kernel.immutableGlobs.map((glob) => compileGlob(glob));
|
|
96
|
-
await sealedPathCheck(req.genId, nom, matchers, sealedPaths, repo, opts);
|
|
97
|
-
const genome = { repoPath: repo, genomeFp: entry.fingerprint };
|
|
98
|
-
const ff = await fastForwardIncumbent(genome, nom.commitSha, opts);
|
|
99
|
-
// regeneration happens ONLY here, after the human-invoked diff passed:
|
|
100
|
-
// manifest reborn from the new incumbent's committed tree, not the worktree.
|
|
101
|
-
const snap = await snapshotCommit(genome, nom.commitSha, opts);
|
|
102
|
-
const fresh = buildManifest(snap.snapshotPath, entry.spec.kernel.immutableGlobs);
|
|
103
|
-
mkdirSync(kernelsDirOf(configDir), { recursive: true });
|
|
104
|
-
writeFileSync(manifestFile, serializeManifest(fresh), "utf8");
|
|
105
|
-
ledger.append({
|
|
106
|
-
kind: LEDGER_KIND_PROMOTE,
|
|
107
|
-
genId: req.genId,
|
|
108
|
-
data: { actor: "cli", genId: req.genId, from: ff.fromSha, to: ff.toSha },
|
|
109
|
-
});
|
|
110
|
-
return {
|
|
111
|
-
genId: req.genId,
|
|
112
|
-
fromSha: ff.fromSha,
|
|
113
|
-
toSha: ff.toSha,
|
|
114
|
-
lines: [
|
|
115
|
-
`promoted '${entry.label}' (${entry.fingerprint}): ${ff.fromSha === null ? "incumbent branch created" : short(ff.fromSha)} -> ${short(ff.toSha)} (gen ${shown})`,
|
|
116
|
-
`manifest: rewrote ${path.relative(process.cwd(), manifestFile) || manifestFile} from tree ${short(nom.commitSha)} (${String(fresh.length)} sealed ${fresh.length === 1 ? "entry" : "entries"})`,
|
|
117
|
-
`ledger: appended '${LEDGER_KIND_PROMOTE}' row (actor: cli) to ${ledgerPath(repo)}`,
|
|
118
|
-
],
|
|
119
|
-
};
|
|
120
135
|
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// re-adjudicate (institutional addition 2026-09-28, born of the c17 silent-downgrade
|
|
2
|
+
// incident): when the GATE code that judged a generation is later found to have been
|
|
3
|
+
// the wrong binary (or any honest cause), the corrected verdict must be able to enter
|
|
4
|
+
// machine state WITHOUT touching the immutable ledger files: this command replays the
|
|
5
|
+
// CURRENT evaluate() over the archived rows and APPENDS a fresh generation_complete
|
|
6
|
+
// row carrying the corrected verdict plus full provenance. Promote already reads the
|
|
7
|
+
// LAST row for a gen, so a corrected nomination unlocks the human gate; a corrected
|
|
8
|
+
// cull stays honest too — this tool re-judges, it never flips.
|
|
9
|
+
//
|
|
10
|
+
// Replay contract (mirrors scripts/backtest-acceptance.mjs, the established approach):
|
|
11
|
+
// - gate-time counters = lifetime sum over the epoch (all rows sharing the runId)
|
|
12
|
+
// MINUS the candidate row's own spend (its slot was already committed by the
|
|
13
|
+
// session clamp — feeding it back would self-trip the cap, run-loop.ts:316-318);
|
|
14
|
+
// - the score bank excludes THIS candidate's own tree groups (its verdict-time
|
|
15
|
+
// draws must not price its own noise — excludeCandidateTrees is the built hook);
|
|
16
|
+
// - nPairs = 1 (one finalist pair per re-judged epoch).
|
|
17
|
+
// Fail-closed: no candidate row / no incumbent row for the same runId / an existing
|
|
18
|
+
// promote row for the gen ⇒ refusal before any state read becomes a mutation.
|
|
19
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { blocked, cannotAnswer } from "../exit.js";
|
|
22
|
+
import { effectiveRepoPath } from "./spec.js";
|
|
23
|
+
import { evaluate, needsExit } from "./stats.js";
|
|
24
|
+
import { acquireGenomeLock, acquireLock, Ledger, ledgerPath, ledgerRecordSchema, LEDGER_KIND_GENERATION_COMPLETE, LEDGER_KIND_PROMOTE } from "./ledger.js";
|
|
25
|
+
import { asReplicates, decodeGenerationRecord } from "./evolve/run-bench.js";
|
|
26
|
+
import { effectiveCaps } from "./evolve/run-plan.js";
|
|
27
|
+
import { loadScoreBank } from "./evolve/score-bank.js";
|
|
28
|
+
const LIVE_LEDGER = "ledger.jsonl";
|
|
29
|
+
function isRotatedArchive(name) {
|
|
30
|
+
return name.startsWith("ledger.") && name.endsWith(".jsonl") && name !== LIVE_LEDGER && !name.startsWith("ledger.corrupt-");
|
|
31
|
+
}
|
|
32
|
+
/** Same read discipline as the score bank: schema-invalid lines are skipped with a notice, never a crash. */
|
|
33
|
+
function collectRows(dir) {
|
|
34
|
+
let names;
|
|
35
|
+
try {
|
|
36
|
+
names = readdirSync(dir);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return { rows: [], notices: [] };
|
|
40
|
+
}
|
|
41
|
+
const files = names.filter((n) => n === LIVE_LEDGER || isRotatedArchive(n)).sort();
|
|
42
|
+
const rows = [];
|
|
43
|
+
const notices = [];
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
let text;
|
|
46
|
+
try {
|
|
47
|
+
text = readFileSync(path.join(dir, file), "utf8");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
notices.push(`${file}: unreadable — skipped`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
for (const [index, raw] of text.split("\n").entries()) {
|
|
54
|
+
const line = raw.trim();
|
|
55
|
+
if (line.length === 0)
|
|
56
|
+
continue;
|
|
57
|
+
let candidate;
|
|
58
|
+
try {
|
|
59
|
+
candidate = JSON.parse(line);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
notices.push(`${file}:${String(index + 1)}: not JSON — row skipped`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const parsed = ledgerRecordSchema.safeParse(candidate);
|
|
66
|
+
if (!parsed.success) {
|
|
67
|
+
notices.push(`${file}:${String(index + 1)}: schema-invalid — row skipped`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
rows.push({ file, ts: parsed.data.ts, record: parsed.data });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { rows, notices };
|
|
74
|
+
}
|
|
75
|
+
function display(value, max = 96) {
|
|
76
|
+
const flat = value.replace(/[^ -~]/g, " ").replace(/\s+/g, " ").trim();
|
|
77
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
78
|
+
}
|
|
79
|
+
export function readjudicateGeneration(req) {
|
|
80
|
+
const { entry, genId } = req;
|
|
81
|
+
const shown = display(genId);
|
|
82
|
+
const repo = effectiveRepoPath(entry.spec.repoPath);
|
|
83
|
+
const dir = path.dirname(ledgerPath(repo));
|
|
84
|
+
const { rows, notices } = collectRows(dir);
|
|
85
|
+
// locate the candidate row: the LAST generation_complete row for this gen that is a
|
|
86
|
+
// candidate arm; if it already carries readjudication provenance, this is the second pass.
|
|
87
|
+
const genRows = rows.filter((r) => r.record.kind === LEDGER_KIND_GENERATION_COMPLETE && r.record.genId === genId);
|
|
88
|
+
let incumbent = null;
|
|
89
|
+
let candidate = null;
|
|
90
|
+
for (const r of genRows) {
|
|
91
|
+
let data;
|
|
92
|
+
try {
|
|
93
|
+
data = decodeGenerationRecord(r.record);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
continue; // not a decodable generation row — skip like the bank does
|
|
97
|
+
}
|
|
98
|
+
if (data.source === "candidate")
|
|
99
|
+
candidate = { row: r, data };
|
|
100
|
+
}
|
|
101
|
+
if (candidate === null) {
|
|
102
|
+
blocked(`re-adjudicate: no candidate generation_complete row for gen '${shown}' (searched live ledger + rotated archives in ${dir})`, `state is read only from the ledger files — check candidates with \`abathur status ${entry.label}\``);
|
|
103
|
+
}
|
|
104
|
+
if (candidate.data.verdict === undefined) {
|
|
105
|
+
blocked(`re-adjudicate: candidate row for gen '${shown}' has no verdict — nothing to re-judge`);
|
|
106
|
+
}
|
|
107
|
+
// non-null alias: TS narrowing does not survive let-assignment inside loops
|
|
108
|
+
const cand = candidate;
|
|
109
|
+
if (rows.some((r) => r.record.kind === LEDGER_KIND_PROMOTE && r.record.genId === genId)) {
|
|
110
|
+
blocked(`re-adjudicate: gen '${shown}' is already promoted — a corrected verdict cannot and need not move it`);
|
|
111
|
+
}
|
|
112
|
+
// incumbent row: same runId, candidate's epoch — search ALL rows, archives included.
|
|
113
|
+
const runId = cand.row.record.runId;
|
|
114
|
+
if (runId === undefined)
|
|
115
|
+
cannotAnswer(`re-adjudicate: candidate row for gen '${shown}' lacks runId — cannot pair it with its incumbent baseline`);
|
|
116
|
+
for (const r of rows) {
|
|
117
|
+
if (r.record.kind !== LEDGER_KIND_GENERATION_COMPLETE || r.record.runId !== runId)
|
|
118
|
+
continue;
|
|
119
|
+
if (r.record.genId === genId)
|
|
120
|
+
continue; // same gen never supplies the incumbent arm
|
|
121
|
+
let data;
|
|
122
|
+
try {
|
|
123
|
+
data = decodeGenerationRecord(r.record);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (data.source === "incumbent") {
|
|
129
|
+
if (incumbent !== null && incumbent.row.record.genId !== r.record.genId) {
|
|
130
|
+
cannotAnswer(`re-adjudicate: multiple incumbent rows for runId '${display(runId)}' with differing gens — ambiguous epoch, refusing`);
|
|
131
|
+
}
|
|
132
|
+
incumbent = { row: r, data };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (incumbent === null) {
|
|
136
|
+
blocked(`re-adjudicate: no incumbent baseline row for runId '${display(runId)}' (candidate gen '${shown}') — cannot re-judge an unpaired epoch`);
|
|
137
|
+
}
|
|
138
|
+
const inc = incumbent; // non-null alias (incumbent guarded above; narrowing dies in the loop)
|
|
139
|
+
// gate-time counters mirror the backtest: lifetime epoch sum minus the candidate's own slot.
|
|
140
|
+
let tokens = 0;
|
|
141
|
+
let modelCalls = 0;
|
|
142
|
+
let wallS = 0;
|
|
143
|
+
for (const r of rows) {
|
|
144
|
+
if (r.record.runId !== runId)
|
|
145
|
+
continue;
|
|
146
|
+
if (r === cand.row)
|
|
147
|
+
continue;
|
|
148
|
+
let data;
|
|
149
|
+
try {
|
|
150
|
+
data = decodeGenerationRecord(r.record);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
tokens += data.counters.tokens;
|
|
156
|
+
modelCalls += data.counters.modelCalls;
|
|
157
|
+
wallS += data.counters.wallS;
|
|
158
|
+
}
|
|
159
|
+
const counters = { candidates: 0, modelCalls, tokens, wallS: Number(wallS.toFixed(3)) };
|
|
160
|
+
const bank = loadScoreBank(repo, { excludeCandidateTrees: cand.data.treeSha === undefined ? [] : [cand.data.treeSha] });
|
|
161
|
+
const gate = evaluateWith(entry, cand.data, inc.data, counters, runId, bank);
|
|
162
|
+
const lines = [];
|
|
163
|
+
for (const n of notices)
|
|
164
|
+
lines.push(`notice: ${n}`);
|
|
165
|
+
lines.push(`replay: gen ${shown} verdict-time ${gate.candidateVerdictBefore} -> current gate '${gate.verdict}' (gain ${gate.gain === null ? "null" : gate.gain.toFixed(4)}, bank ${bank === null ? "absent → legacy gate" : `${String(bank.units.size)} units, prior sigma ${bank.priorSigma.toFixed(4)}, pooled df ${String(bank.totalDf)}`})`);
|
|
166
|
+
if (gate.verdict === cand.data.verdict && cand.data.readjudication === undefined) {
|
|
167
|
+
lines.push(`no-op: current gate already agrees with the row verdict '${gate.verdict}' — nothing appended`);
|
|
168
|
+
return { appended: false, verdict: gate.verdict, lines };
|
|
169
|
+
}
|
|
170
|
+
if (cand.data.readjudication !== undefined && gate.verdict === cand.data.verdict) {
|
|
171
|
+
lines.push(`no-op: gen already carries a readjudication row with verdict '${gate.verdict}' — nothing appended`);
|
|
172
|
+
return { appended: false, verdict: gate.verdict, lines };
|
|
173
|
+
}
|
|
174
|
+
const ledger = Ledger.open(repo);
|
|
175
|
+
const lease = acquireGenomeLock({ ledger, configDir: req.configDir, genomeFp: entry.fingerprint });
|
|
176
|
+
try {
|
|
177
|
+
const repoLease = acquireLock({ configDir: path.join(repo, ".state", "abathur"), key: "gate", label: "re-adjudicate lock" });
|
|
178
|
+
try {
|
|
179
|
+
// re-read under lock: a promote or younger row may have landed while we computed.
|
|
180
|
+
const fresh = collectRows(dir).rows.filter((r) => r.record.kind === LEDGER_KIND_GENERATION_COMPLETE && r.record.genId === genId);
|
|
181
|
+
const lastData = [...fresh].reverse().map((r) => {
|
|
182
|
+
try {
|
|
183
|
+
return decodeGenerationRecord(r.record);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}).find((d) => d !== null);
|
|
189
|
+
if (lastData === undefined) {
|
|
190
|
+
blocked(`re-adjudicate: gen '${shown}' rows vanished under lock — state moved, nothing appended`);
|
|
191
|
+
}
|
|
192
|
+
if (lastData.verdict === gate.verdict) {
|
|
193
|
+
lines.push(`no-op (under lock): last row already reads '${gate.verdict}'`);
|
|
194
|
+
return { appended: false, verdict: gate.verdict, lines };
|
|
195
|
+
}
|
|
196
|
+
if (lastData.readjudication !== undefined) {
|
|
197
|
+
blocked(`re-adjudicate: gen '${shown}' already has a readjudication row verdict '${lastData.verdict}' disagreeing with replay '${gate.verdict}' — a second correction would erase the first; hand the ledger a ruling instead`);
|
|
198
|
+
}
|
|
199
|
+
if (fresh.some((r) => r.record.kind === LEDGER_KIND_PROMOTE)) {
|
|
200
|
+
blocked(`re-adjudicate: gen '${shown}' was promoted while this command computed — refusing to amend a promoted epoch`);
|
|
201
|
+
}
|
|
202
|
+
const replayExit = needsExit(gate.verdict);
|
|
203
|
+
const data = {
|
|
204
|
+
...cand.data,
|
|
205
|
+
verdict: gate.verdict,
|
|
206
|
+
exitCode: replayExit === 0 ? 0 : replayExit === 1 ? 1 : 2,
|
|
207
|
+
gain: gate.gain,
|
|
208
|
+
gateFailures: [...gate.failures],
|
|
209
|
+
readjudication: {
|
|
210
|
+
at: req.now().toISOString(),
|
|
211
|
+
specFingerprint: entry.fingerprint,
|
|
212
|
+
gate: bank === null ? "legacy" : "acceptance",
|
|
213
|
+
bank: bank === null ? null : { units: bank.units.size, priorSigma: bank.priorSigma, pooledDf: bank.totalDf },
|
|
214
|
+
sourceRows: {
|
|
215
|
+
candidate: { file: cand.row.file, ts: cand.row.ts },
|
|
216
|
+
incumbent: { file: inc.row.file, ts: inc.row.ts },
|
|
217
|
+
},
|
|
218
|
+
replacedVerdict: cand.data.verdict ?? "none",
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
ledger.append({ kind: LEDGER_KIND_GENERATION_COMPLETE, genId, ...(runId === undefined ? {} : { runId }), data });
|
|
222
|
+
lines.push(`ledger: appended corrected generation_complete row (verdict '${gate.verdict}', actor: re-adjudicate) to ${ledgerPath(repo)}`);
|
|
223
|
+
lines.push(`files are immutable — the original '${cand.data.verdict}' row remains in ${cand.row.file} as history`);
|
|
224
|
+
for (const f of gate.failures)
|
|
225
|
+
lines.push(` gate: ${f}`);
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
repoLease.release();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
lease.release();
|
|
233
|
+
}
|
|
234
|
+
return { appended: true, verdict: gate.verdict, lines };
|
|
235
|
+
}
|
|
236
|
+
// the replay itself — kept tiny and explicit so the counters/bank/nPairs contract above is auditable.
|
|
237
|
+
function evaluateWith(entry, candidate, incumbent, counters, runId, bank) {
|
|
238
|
+
const verdict = evaluate({
|
|
239
|
+
candidate: { runId: runId ?? "re-adjudicate", units: asReplicates(candidate.units), counters },
|
|
240
|
+
incumbent: { units: asReplicates(incumbent.units) },
|
|
241
|
+
stats: entry.spec.bench.stats,
|
|
242
|
+
budgetCaps: effectiveCaps(entry.spec, undefined),
|
|
243
|
+
nPairs: 1,
|
|
244
|
+
...(bank === null ? {} : { bank: bank.units }),
|
|
245
|
+
});
|
|
246
|
+
return { verdict: verdict.verdict, gain: verdict.gain, failures: verdict.failures, candidateVerdictBefore: String(candidate.verdict) };
|
|
247
|
+
}
|