toolroll 0.9.79 → 0.9.82
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 +47 -0
- package/dist/cli.js +1 -0
- package/dist/commit-extract.d.ts +18 -0
- package/dist/commit-extract.js +39 -1
- package/dist/converse.d.ts +12 -0
- package/dist/converse.js +18 -2
- package/dist/deploy-gate.js +8 -0
- package/dist/eval-gate.d.ts +51 -0
- package/dist/eval-gate.js +199 -0
- package/dist/eval-hooks.d.ts +39 -0
- package/dist/eval-hooks.js +477 -0
- package/dist/eval-oracle-main.d.ts +1 -0
- package/dist/eval-oracle-main.js +31 -0
- package/dist/eval-oracle.d.ts +60 -0
- package/dist/eval-oracle.js +217 -0
- package/dist/eval-world.d.ts +17 -0
- package/dist/eval-world.js +97 -0
- package/dist/evals.d.ts +299 -0
- package/dist/evals.js +964 -0
- package/dist/exec.js +15 -1
- package/dist/flow-draft.js +4 -0
- package/dist/flow-sort.js +4 -0
- package/dist/github-jobs.d.ts +25 -0
- package/dist/github-jobs.js +22 -0
- package/dist/guides.js +12 -9
- package/dist/invoke.d.ts +1 -1
- package/dist/invoke.js +14 -0
- package/dist/lead.js +4 -1
- package/dist/memory-pass.js +4 -0
- package/dist/nightly-check.d.ts +4 -1
- package/dist/nightly-check.js +5 -2
- package/dist/nightly-github.d.ts +316 -0
- package/dist/nightly-github.js +1022 -0
- package/dist/operate.d.ts +91 -1
- package/dist/operate.js +158 -1
- package/dist/project-knowledge.d.ts +4 -4
- package/dist/provider.d.ts +3 -0
- package/dist/provider.js +9 -2
- package/dist/release-adapters.d.ts +21 -21
- package/dist/release-adapters.js +53 -20
- package/dist/release-auto.d.ts +1 -1
- package/dist/release-auto.js +26 -0
- package/dist/release-card.js +3 -0
- package/dist/release-cli.js +59 -3
- package/dist/release-view.d.ts +3 -0
- package/dist/release-view.js +2 -0
- package/dist/release.d.ts +36 -17
- package/dist/release.js +74 -24
- package/dist/reported-usage.d.ts +69 -0
- package/dist/reported-usage.js +97 -0
- package/dist/server/chat.js +4 -0
- package/dist/server/render-tasks.js +1 -0
- package/dist/server/tasks.js +8 -1
- package/dist/store.d.ts +109 -1
- package/dist/store.js +394 -4
- package/dist/subagents.js +4 -0
- package/dist/subscription-chat.d.ts +2 -0
- package/dist/subscription-chat.js +52 -13
- package/dist/task-sizing.js +4 -0
- package/dist/teardown-git.d.ts +9 -2
- package/dist/teardown-git.js +56 -33
- package/evals/cases/catalog/case.json +25 -0
- package/evals/cases/catalog/fixture/README.md +3 -0
- package/evals/cases/catalog/fixture/package.json +8 -0
- package/evals/cases/catalog/fixture/test/catalog.test.mjs +4 -0
- package/evals/cases/catalog/oracle.mjs +23 -0
- package/evals/cases/catalog/probe.mjs +10 -0
- package/evals/cases/catalog/source.md +9 -0
- package/evals/cases/csv-quoting/case.json +23 -0
- package/evals/cases/csv-quoting/fixture/README.md +3 -0
- package/evals/cases/csv-quoting/fixture/csv.js +1 -0
- package/evals/cases/csv-quoting/fixture/package.json +8 -0
- package/evals/cases/csv-quoting/fixture/test/csv.test.mjs +5 -0
- package/evals/cases/csv-quoting/oracle.mjs +14 -0
- package/evals/cases/csv-quoting/probe.mjs +9 -0
- package/evals/cases/csv-quoting/source.md +9 -0
- package/evals/cases/dedupe/case.json +23 -0
- package/evals/cases/dedupe/fixture/README.md +3 -0
- package/evals/cases/dedupe/fixture/dedupe.js +1 -0
- package/evals/cases/dedupe/fixture/package.json +8 -0
- package/evals/cases/dedupe/fixture/test/dedupe.test.mjs +5 -0
- package/evals/cases/dedupe/oracle.mjs +16 -0
- package/evals/cases/dedupe/probe.mjs +13 -0
- package/evals/cases/dedupe/source.md +9 -0
- package/evals/cases/endpoint/case.json +25 -0
- package/evals/cases/endpoint/fixture/README.md +3 -0
- package/evals/cases/endpoint/fixture/package.json +8 -0
- package/evals/cases/endpoint/fixture/test/endpoint.test.mjs +4 -0
- package/evals/cases/endpoint/oracle.mjs +27 -0
- package/evals/cases/endpoint/probe.mjs +10 -0
- package/evals/cases/endpoint/source.md +11 -0
- package/evals/cases/math-divide/case.json +23 -0
- package/evals/cases/math-divide/fixture/README.md +3 -0
- package/evals/cases/math-divide/fixture/package.json +8 -0
- package/evals/cases/math-divide/fixture/src/math.js +1 -0
- package/evals/cases/math-divide/fixture/test/math.test.js +5 -0
- package/evals/cases/math-divide/oracle.mjs +13 -0
- package/evals/cases/math-divide/probe.mjs +9 -0
- package/evals/cases/math-divide/source.md +9 -0
- package/evals/cases/math-multiply/case.json +23 -0
- package/evals/cases/math-multiply/fixture/README.md +3 -0
- package/evals/cases/math-multiply/fixture/package.json +8 -0
- package/evals/cases/math-multiply/fixture/src/math.js +2 -0
- package/evals/cases/math-multiply/fixture/test/math.test.js +5 -0
- package/evals/cases/math-multiply/oracle.mjs +12 -0
- package/evals/cases/math-multiply/probe.mjs +9 -0
- package/evals/cases/math-multiply/source.md +9 -0
- package/evals/cases/math-subtract/case.json +23 -0
- package/evals/cases/math-subtract/fixture/README.md +3 -0
- package/evals/cases/math-subtract/fixture/package.json +8 -0
- package/evals/cases/math-subtract/fixture/src/math.js +1 -0
- package/evals/cases/math-subtract/fixture/test/math.test.js +5 -0
- package/evals/cases/math-subtract/oracle.mjs +12 -0
- package/evals/cases/math-subtract/probe.mjs +9 -0
- package/evals/cases/math-subtract/source.md +9 -0
- package/evals/cases/merge-config/case.json +23 -0
- package/evals/cases/merge-config/fixture/README.md +3 -0
- package/evals/cases/merge-config/fixture/config.js +1 -0
- package/evals/cases/merge-config/fixture/package.json +8 -0
- package/evals/cases/merge-config/fixture/test/config.test.mjs +5 -0
- package/evals/cases/merge-config/oracle.mjs +28 -0
- package/evals/cases/merge-config/probe.mjs +15 -0
- package/evals/cases/merge-config/source.md +9 -0
- package/evals/cases/paginate/case.json +23 -0
- package/evals/cases/paginate/fixture/README.md +3 -0
- package/evals/cases/paginate/fixture/package.json +8 -0
- package/evals/cases/paginate/fixture/paginate.js +1 -0
- package/evals/cases/paginate/fixture/test/paginate.test.mjs +5 -0
- package/evals/cases/paginate/oracle.mjs +18 -0
- package/evals/cases/paginate/probe.mjs +13 -0
- package/evals/cases/paginate/source.md +9 -0
- package/evals/cases/round2-asks/case.json +24 -0
- package/evals/cases/round2-asks/fixture/README.md +3 -0
- package/evals/cases/round2-asks/fixture/package.json +8 -0
- package/evals/cases/round2-asks/fixture/src/math.js +1 -0
- package/evals/cases/round2-asks/fixture/test/math.test.js +5 -0
- package/evals/cases/round2-asks/source.md +11 -0
- package/evals/manifest.json +15 -0
- package/package.json +2 -1
package/dist/evals.js
ADDED
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The eval set (docs/plans/evals-nightly.md, part C1): versioned cases run by the installed plane, each attempt in a
|
|
3
|
+
* throwaway world of its own, graded by a trusted oracle outside the fence, every provider dispatch through a gate
|
|
4
|
+
* and every dispatch's spending recorded as it was reported.
|
|
5
|
+
*
|
|
6
|
+
* - **Cases** live in `evals/` (shipped in the package): `manifest.json` lists them; each `cases/<id>/` holds
|
|
7
|
+
* `case.json`, `fixture/` (the project the agent starts from, plain files), `oracle.mjs` + `probe.mjs` (the hidden
|
|
8
|
+
* check) and `source.md` (where it came from). A case's version is the sha256 of its folder; a link or anything but a
|
|
9
|
+
* regular file in it refuses the whole set. A case is a draft until a person validates its expected outcome
|
|
10
|
+
* (`validatedBy` in case.json, changed only in a release a person approves: `evals/` is a policy path).
|
|
11
|
+
* - **Campaigns**: canonical (no flags) seals every case, its version, its pin, k = 3 and the installed build; it is
|
|
12
|
+
* refused while any case is a draft or any pin isn't an exact model id. Anything else is diagnostic. Admission is one
|
|
13
|
+
* transaction with a person's proof: no release operation reserved, no deploy draining, one canonical campaign at a
|
|
14
|
+
* time (settlement order). The runner that owns a campaign is recorded, so an interrupted one can be recovered.
|
|
15
|
+
* - **Attempts** run as `toolroll evals attempt …`, a child process of the installed binary in its own process group,
|
|
16
|
+
* with its own world (database, repository, worktrees, dispatch gate). The runner waits for the whole process tree to
|
|
17
|
+
* be gone, reads the world's database (read only), extracts the built files from verified Git objects, and only then
|
|
18
|
+
* asks the oracle (eval-oracle.ts).
|
|
19
|
+
* - **Money**: no limit (the owner, 2026-10-30). What every dispatch spent is recorded on every exit path: API dollars,
|
|
20
|
+
* subscription usage and unknown kept apart and never added together; a dispatch the world's records don't account
|
|
21
|
+
* for, or one that went around the gate, makes the attempt an error with unknown spending.
|
|
22
|
+
*
|
|
23
|
+
* Result-writing authority is this module's alone: a world never gets a handle on the plane's database.
|
|
24
|
+
*/
|
|
25
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
26
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
27
|
+
import { closeSync, constants, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, realpathSync, rmSync, writeSync } from "node:fs";
|
|
28
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
29
|
+
import { homedir, hostname } from "node:os";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { createRequire } from "node:module";
|
|
32
|
+
import { readVerifiedCommit, verifiedBlobs } from "./commit-extract.js";
|
|
33
|
+
import { updateAdmissionPaused } from "./desktop-update-gate.js";
|
|
34
|
+
import { EVAL_GATE_ENV, evalGateOverLimit, evalGateSlots, limiter, openEvalGate, readNoFollow } from "./eval-gate.js";
|
|
35
|
+
import { runOracle } from "./eval-oracle.js";
|
|
36
|
+
import { personProof } from "./person-proof.js";
|
|
37
|
+
import { observeProcessTree, stopProcessTree } from "./process-tree.js";
|
|
38
|
+
import { processMayBeAlive } from "./process-liveness.js";
|
|
39
|
+
import { combineReportedModels } from "./reported-usage.js";
|
|
40
|
+
import { authenticateApprover } from "./scope.js";
|
|
41
|
+
import { deriveRunEfficiency } from "./run-efficiency.js";
|
|
42
|
+
import { chatTurnAccounting, runAccounting, Store } from "./store.js";
|
|
43
|
+
// Loaded on first use, like the store: a static import prints Node's SQLite warning and can't be bundled.
|
|
44
|
+
const sqlite = () => createRequire(import.meta.url)("node:sqlite");
|
|
45
|
+
export const EVALS_ROOT = fileURLToPath(new URL("../evals/", import.meta.url));
|
|
46
|
+
export const EVAL_K = 3;
|
|
47
|
+
/** An errored attempt gets one replacement when the plane confirms the error; at most this many a campaign. */
|
|
48
|
+
export const EVAL_MAX_REPLACEMENTS = 3;
|
|
49
|
+
/** Attempts at once within one campaign. */
|
|
50
|
+
export const EVAL_CONCURRENCY = 2;
|
|
51
|
+
const CASE_ID = /^[a-z0-9][a-z0-9-]{0,47}$/;
|
|
52
|
+
const FIXTURE_PATH = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}(\/[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}){0,3}$/;
|
|
53
|
+
/** Names that pick a model for you: never a pin. */
|
|
54
|
+
const MODEL_ALIASES = new Set(["sonnet", "opus", "haiku", "default", "best", "opusplan", "sonnet[1m]", "opus[1m]"]);
|
|
55
|
+
const MAX_CASE_FILES = 64;
|
|
56
|
+
const MAX_CASE_FILE_BYTES = 256 * 1024;
|
|
57
|
+
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
58
|
+
/** Every regular file under `dir` (relative path → bytes), never through a link; null with why when anything else is
|
|
59
|
+
* there or a limit is passed. */
|
|
60
|
+
function folderFiles(dir, limit = MAX_CASE_FILES) {
|
|
61
|
+
const files = new Map();
|
|
62
|
+
const walk = (at, depth) => {
|
|
63
|
+
if (depth > 4)
|
|
64
|
+
return `${relative(dir, at)} is nested too deep`;
|
|
65
|
+
let names;
|
|
66
|
+
try {
|
|
67
|
+
names = readdirSync(at).sort();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return `${relative(dir, at) || "the case folder"} can't be read`;
|
|
71
|
+
}
|
|
72
|
+
for (const name of names) {
|
|
73
|
+
const path = join(at, name);
|
|
74
|
+
const stat = lstatSync(path);
|
|
75
|
+
if (stat.isDirectory()) {
|
|
76
|
+
const problem = walk(path, depth + 1);
|
|
77
|
+
if (problem !== null)
|
|
78
|
+
return problem;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!stat.isFile())
|
|
82
|
+
return `${relative(dir, path)} is a link or not a regular file`;
|
|
83
|
+
const bytes = readNoFollow(path, MAX_CASE_FILE_BYTES);
|
|
84
|
+
if (bytes === null)
|
|
85
|
+
return `${relative(dir, path)} can't be read or is over ${MAX_CASE_FILE_BYTES} bytes`;
|
|
86
|
+
files.set(relative(dir, path).split(sep).join("/"), bytes);
|
|
87
|
+
if (files.size > limit)
|
|
88
|
+
return `the folder holds more than ${limit} files`;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
};
|
|
92
|
+
const problem = walk(dir, 0);
|
|
93
|
+
return problem === null ? { ok: true, files } : { ok: false, problem };
|
|
94
|
+
}
|
|
95
|
+
/** A folder's digest: every file's path and bytes, in path order. */
|
|
96
|
+
function digestOf(files) {
|
|
97
|
+
const hash = createHash("sha256");
|
|
98
|
+
for (const [path, bytes] of [...files].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
|
|
99
|
+
hash.update(path).update("\0").update(sha256(bytes)).update("\0");
|
|
100
|
+
return hash.digest("hex");
|
|
101
|
+
}
|
|
102
|
+
/** Why a pin isn't an exact model id, or null. An alias, an empty or odd value, or a non-Claude provider is no pin. */
|
|
103
|
+
export function exactModelProblem(pin) {
|
|
104
|
+
if (pin === null || pin === undefined || pin.provider !== "claude")
|
|
105
|
+
return "its pin names no provider an eval can cap and read the exact model of (claude)";
|
|
106
|
+
const model = pin.model;
|
|
107
|
+
if (typeof model !== "string" || !/^claude-[a-z0-9][a-z0-9.-]{2,80}$/.test(model) || !/[0-9]/.test(model) || MODEL_ALIASES.has(model)) {
|
|
108
|
+
return `its pin ${typeof model === "string" ? `"${model.slice(0, 60)}"` : ""} isn't an exact model id`;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const text = (value, max) => typeof value === "string" && value.trim() !== "" && value.length <= max ? value : null;
|
|
113
|
+
const whole = (value, low, high) => Number.isInteger(value) && value >= low && value <= high ? value : null;
|
|
114
|
+
function caseOf(id, dir, files) {
|
|
115
|
+
const bad = (why) => ({ ok: false, problem: `case ${id}: ${why}` });
|
|
116
|
+
for (const needed of ["case.json", "source.md"])
|
|
117
|
+
if (!files.has(needed))
|
|
118
|
+
return bad(`${needed} is missing`);
|
|
119
|
+
let raw;
|
|
120
|
+
try {
|
|
121
|
+
raw = JSON.parse(files.get("case.json").toString("utf8"));
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return bad("case.json isn't JSON");
|
|
125
|
+
}
|
|
126
|
+
if (raw["id"] !== id)
|
|
127
|
+
return bad("case.json names another id");
|
|
128
|
+
const kind = raw["kind"] === "build" || raw["kind"] === "ask" ? raw["kind"] : null;
|
|
129
|
+
const title = text(raw["title"], 200), goal = text(raw["goal"], 4000);
|
|
130
|
+
const touches = Array.isArray(raw["touches"]) && raw["touches"].every(one => typeof one === "string" && FIXTURE_PATH.test(one)) ? raw["touches"] : null;
|
|
131
|
+
const pin = raw["pin"];
|
|
132
|
+
const maxDispatches = whole(raw["maxDispatches"], 1, 10), timeoutMinutes = whole(raw["timeoutMinutes"], 1, 60);
|
|
133
|
+
const validatedBy = raw["validatedBy"] === null ? null : text(raw["validatedBy"], 80);
|
|
134
|
+
if (kind === null || title === null || goal === null || touches === null || maxDispatches === null || timeoutMinutes === null)
|
|
135
|
+
return bad("case.json is missing a field or has one out of range");
|
|
136
|
+
if (raw["validatedBy"] !== null && validatedBy === null)
|
|
137
|
+
return bad("validatedBy is a person's name, or null while the case is a draft");
|
|
138
|
+
if (typeof pin?.provider !== "string" || typeof pin.model !== "string")
|
|
139
|
+
return bad("its pin names no provider and model");
|
|
140
|
+
const grade = raw["grade"];
|
|
141
|
+
let graded;
|
|
142
|
+
if (kind === "build") {
|
|
143
|
+
const graded_files = grade?.["files"];
|
|
144
|
+
if (!Array.isArray(graded_files) || graded_files.length === 0 || graded_files.length > 8 || !graded_files.every(one => typeof one === "string" && FIXTURE_PATH.test(one) && one !== "probe.mjs"))
|
|
145
|
+
return bad("a build case grades 1 to 8 plain files (never probe.mjs)");
|
|
146
|
+
if (!files.has("oracle.mjs") || !files.has("probe.mjs"))
|
|
147
|
+
return bad("a build case has oracle.mjs and probe.mjs");
|
|
148
|
+
graded = { files: graded_files };
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
const options = grade?.["options"];
|
|
152
|
+
if (!Array.isArray(options) || options.length < 2 || !options.every(one => typeof one === "string" && one.length <= 100 && (() => { try {
|
|
153
|
+
new RegExp(one, "i");
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return false;
|
|
158
|
+
} })()))
|
|
159
|
+
return bad("an ask case names at least two option patterns");
|
|
160
|
+
graded = { options: options };
|
|
161
|
+
}
|
|
162
|
+
const fixture = {};
|
|
163
|
+
for (const [path, bytes] of files)
|
|
164
|
+
if (path.startsWith("fixture/"))
|
|
165
|
+
fixture[path.slice("fixture/".length)] = bytes.toString("utf8");
|
|
166
|
+
if (Object.keys(fixture).length === 0)
|
|
167
|
+
return bad("its fixture is empty");
|
|
168
|
+
if (!Object.keys(fixture).every(path => FIXTURE_PATH.test(path) && !path.split("/").includes(".git")))
|
|
169
|
+
return bad("a fixture path isn't a plain path");
|
|
170
|
+
return { ok: true, value: { id, version: digestOf(files), dir, kind, title, goal, touches, pin: { provider: "claude", model: pin.model },
|
|
171
|
+
maxDispatches, timeoutMinutes, grade: graded, validatedBy, fixture } };
|
|
172
|
+
}
|
|
173
|
+
/** The installed eval set: the manifest's cases, each read whole and versioned. Any case that can't be read, and any
|
|
174
|
+
* folder the manifest doesn't list, refuses the whole set. */
|
|
175
|
+
export function loadEvalSet(root = EVALS_ROOT) {
|
|
176
|
+
const manifestBytes = readNoFollow(join(root, "manifest.json"), 64 * 1024);
|
|
177
|
+
if (manifestBytes === null)
|
|
178
|
+
return { ok: false, problem: "evals/manifest.json can't be read" };
|
|
179
|
+
let manifest;
|
|
180
|
+
try {
|
|
181
|
+
manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return { ok: false, problem: "evals/manifest.json isn't JSON" };
|
|
185
|
+
}
|
|
186
|
+
const ids = manifest["cases"];
|
|
187
|
+
if (manifest["v"] !== 1 || !Array.isArray(ids) || !ids.every(one => typeof one === "string" && CASE_ID.test(one)) || new Set(ids).size !== ids.length)
|
|
188
|
+
return { ok: false, problem: "evals/manifest.json lists no valid case ids" };
|
|
189
|
+
let present;
|
|
190
|
+
try {
|
|
191
|
+
present = readdirSync(join(root, "cases")).sort();
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return { ok: false, problem: "evals/cases can't be read" };
|
|
195
|
+
}
|
|
196
|
+
const unlisted = present.filter(one => !ids.includes(one));
|
|
197
|
+
if (unlisted.length > 0)
|
|
198
|
+
return { ok: false, problem: `evals/cases holds ${unlisted.join(", ")}, which the manifest doesn't list` };
|
|
199
|
+
const cases = [];
|
|
200
|
+
for (const id of ids) {
|
|
201
|
+
const dir = join(root, "cases", id);
|
|
202
|
+
try {
|
|
203
|
+
if (!lstatSync(dir).isDirectory())
|
|
204
|
+
return { ok: false, problem: `case ${id} isn't a folder` };
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return { ok: false, problem: `case ${id} is missing` };
|
|
208
|
+
}
|
|
209
|
+
const files = folderFiles(dir);
|
|
210
|
+
if (!files.ok)
|
|
211
|
+
return { ok: false, problem: `case ${id}: ${files.problem}` };
|
|
212
|
+
const one = caseOf(id, dir, files.files);
|
|
213
|
+
if (!one.ok)
|
|
214
|
+
return one;
|
|
215
|
+
cases.push(one.value);
|
|
216
|
+
}
|
|
217
|
+
return { ok: true, root, manifestDigest: sha256(JSON.stringify({ manifest: sha256(manifestBytes), cases: cases.map(one => [one.id, one.version]) })), cases };
|
|
218
|
+
}
|
|
219
|
+
/** What a campaign runs. Canonical (nothing asked): every case, k = 3, refused while any case is a draft. Diagnostic
|
|
220
|
+
* (anything asked): a report only. Either way every case runs under an exact model pin. */
|
|
221
|
+
export function planCampaign(set, ask) {
|
|
222
|
+
const canonical = ask.cases === undefined && ask.k === undefined && ask.diagnostic !== true;
|
|
223
|
+
const k = ask.k ?? EVAL_K;
|
|
224
|
+
if (!Number.isInteger(k) || k < 1 || k > 5)
|
|
225
|
+
return { ok: false, problem: "--k is a whole number from 1 to 5" };
|
|
226
|
+
const unknown = (ask.cases ?? []).filter(id => !set.cases.some(one => one.id === id));
|
|
227
|
+
if (unknown.length > 0)
|
|
228
|
+
return { ok: false, problem: `no case is named ${unknown.join(", ")}` };
|
|
229
|
+
const cases = ask.cases === undefined ? set.cases : set.cases.filter(one => ask.cases.includes(one.id));
|
|
230
|
+
if (cases.length === 0)
|
|
231
|
+
return { ok: false, problem: "no case to run" };
|
|
232
|
+
for (const one of cases) {
|
|
233
|
+
const problem = exactModelProblem(one.pin);
|
|
234
|
+
if (problem !== null)
|
|
235
|
+
return { ok: false, problem: `case ${one.id}: ${problem}` };
|
|
236
|
+
}
|
|
237
|
+
if (canonical) {
|
|
238
|
+
const drafts = cases.filter(one => one.validatedBy === null).map(one => one.id);
|
|
239
|
+
if (drafts.length > 0)
|
|
240
|
+
return { ok: false, problem: `a canonical campaign counts only cases a person validated, and ${drafts.length === cases.length ? "every case is" : `${drafts.join(", ")} ${drafts.length === 1 ? "is" : "are"}`} still a draft. Run a diagnostic campaign (--diagnostic) meanwhile` };
|
|
241
|
+
}
|
|
242
|
+
return { ok: true, plan: { canonical, k, cases } };
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Admit a campaign (eval or reviewer): a person proved now, nothing releasing or deploying, and no other canonical
|
|
246
|
+
* campaign running (canonical campaigns settle in the order they were admitted). One transaction: the proof, the checks
|
|
247
|
+
* and the row, with the runner that owns it (this process, on this computer) for recovery.
|
|
248
|
+
*/
|
|
249
|
+
export function admitEvalCampaign(store, input) {
|
|
250
|
+
const id = input.id ?? `ev-${input.now.toISOString().slice(0, 10).replace(/-/g, "")}-${randomBytes(4).toString("hex")}`;
|
|
251
|
+
// The runner's process start time too: recovery never takes a later process that reuses its pid for the runner.
|
|
252
|
+
const runner = input.runner ?? { host: hostname(), pid: process.pid, started: processStartOf(process.pid) };
|
|
253
|
+
try {
|
|
254
|
+
return store.transact(() => {
|
|
255
|
+
const proved = authenticateApprover(store, input.as, input.token, null);
|
|
256
|
+
if (!proved.ok || !personProof(proved.method))
|
|
257
|
+
return { ok: false, reason: "person-only", message: "Only an approver, proved as a person with their password, starts an eval campaign: it calls models." };
|
|
258
|
+
const busy = store.handle.prepare("SELECT COUNT(*) AS n FROM release_operation WHERE state IN ('reserved','running')").get();
|
|
259
|
+
if (Number(busy?.["n"] ?? 0) > 0 || updateAdmissionPaused(store.handle))
|
|
260
|
+
return { ok: false, reason: "release-busy", message: "A release is deploying or merging, so no eval campaign starts now. Try again once it's done." };
|
|
261
|
+
if (input.canonical) {
|
|
262
|
+
const running = store.handle.prepare("SELECT id FROM eval_campaign WHERE canonical = 1 AND state = 'running'").get();
|
|
263
|
+
if (running !== undefined)
|
|
264
|
+
return { ok: false, reason: "already-running", message: `Canonical campaign ${String(running["id"])} is still running; canonical campaigns run one at a time.` };
|
|
265
|
+
}
|
|
266
|
+
store.handle.prepare(`INSERT INTO eval_campaign (id, kind, canonical, manifest_digest, build_digest, version, baseline_campaign, k, sealed_json, started_by, started_proof,
|
|
267
|
+
runner_host, runner_pid, runner_started, admitted_at, state) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, 'running')`)
|
|
268
|
+
.run(id, input.kind, input.canonical ? 1 : 0, input.manifestDigest, input.buildDigest, input.version, input.k, JSON.stringify(input.sealed), input.as, proved.method, runner.host, runner.pid, runner.started ?? null, input.now.toISOString());
|
|
269
|
+
store.recordAction({ at: input.now.toISOString(), actor: input.as, repo: null, taskId: null, runId: null, action: "eval campaign started", outcome: input.canonical ? "canonical" : "diagnostic", source: "request",
|
|
270
|
+
detail: `${input.kind} campaign ${id}` });
|
|
271
|
+
return { ok: true, id };
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
// The unique running index is the last word on "one canonical at a time" when two admissions race.
|
|
276
|
+
if (/UNIQUE constraint failed: eval_campaign/.test(String(error.message)))
|
|
277
|
+
return { ok: false, reason: "already-running", message: "Another canonical campaign started at the same moment; they run one at a time." };
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** Record one attempt's start (its identity is unique). Refused when the campaign isn't running. */
|
|
282
|
+
export function startEvalAttempt(store, input) {
|
|
283
|
+
return store.transact(() => {
|
|
284
|
+
const campaign = store.handle.prepare("SELECT state FROM eval_campaign WHERE id = ?").get(input.campaign);
|
|
285
|
+
if (campaign === undefined || campaign["state"] !== "running")
|
|
286
|
+
return { ok: false, why: "the campaign isn't running" };
|
|
287
|
+
const made = store.handle.prepare("INSERT INTO eval_attempt (campaign, case_id, case_version, n, replaces, model_pinned, started_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
288
|
+
.run(input.campaign, input.caseId, input.caseVersion, input.n, input.replaces, input.modelPinned, input.now.toISOString());
|
|
289
|
+
return { ok: true, attempt: Number(made.lastInsertRowid) };
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
/** Where an attempt's world is, and its process group once spawned: what recovery reads if the runner stops. */
|
|
293
|
+
function markAttemptWorld(store, attempt, worldDir, pgid) {
|
|
294
|
+
store.handle.prepare("UPDATE eval_attempt SET world_dir = ?, world_pgid = COALESCE(?, world_pgid) WHERE id = ? AND outcome IS NULL").run(worldDir, pgid, attempt);
|
|
295
|
+
}
|
|
296
|
+
/** Saved outside the world: recovery must check detached groups as well as the world's original group. */
|
|
297
|
+
function recordWorldProcesses(store, attempt, processes) {
|
|
298
|
+
store.handle.prepare("UPDATE eval_attempt SET world_processes_json = ? WHERE id = ? AND outcome IS NULL").run(JSON.stringify(processes), attempt);
|
|
299
|
+
}
|
|
300
|
+
/** Record a finished attempt, once: its outcome, each dispatch as it was billed, and its totals. */
|
|
301
|
+
export function recordEvalAttempt(store, attempt, result, now) {
|
|
302
|
+
store.transact(() => {
|
|
303
|
+
const row = store.handle.prepare("SELECT outcome FROM eval_attempt WHERE id = ?").get(attempt);
|
|
304
|
+
if (row === undefined)
|
|
305
|
+
throw new Error(`no eval attempt ${attempt}`);
|
|
306
|
+
if (row["outcome"] !== null)
|
|
307
|
+
return;
|
|
308
|
+
for (const one of result.invocations) {
|
|
309
|
+
const api = one.amount.kind === "api" ? one.amount.microusd : null;
|
|
310
|
+
const subscription = one.amount.kind === "subscription" && one.amount.reportedUsd !== null ? Math.round(one.amount.reportedUsd * 1_000_000) : null;
|
|
311
|
+
store.handle.prepare(`INSERT INTO eval_invocation (attempt, kind, world_ref, recorded, amount, api_microusd, subscription_microusd, model_reported) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
312
|
+
ON CONFLICT (attempt, world_ref) DO NOTHING`)
|
|
313
|
+
.run(attempt, one.kind.slice(0, 40), one.worldRef.slice(0, 80), one.recorded ? 1 : 0, one.amount.kind, api, subscription, one.modelReported);
|
|
314
|
+
}
|
|
315
|
+
store.handle.prepare(`UPDATE eval_attempt SET outcome = ?, error_why = ?, model_reported = ?, dispatches = ?, api_microusd = ?, subscription_microusd = ?, usage_unknown = ?,
|
|
316
|
+
turns = ?, tokens_in = ?, cache_read = ?, cache_write = ?, tokens_out = ?, tool_calls = ?, wall_ms = ?, finished_at = ? WHERE id = ?`)
|
|
317
|
+
.run(result.outcome, result.why === null ? null : result.why.slice(0, 500), result.modelReported, result.dispatches, result.apiMicrousd, result.subscriptionMicrousd, result.usageUnknown ? 1 : 0, result.turns, result.tokensIn, result.cacheRead, result.cacheWrite, result.tokensOut, result.toolCalls, result.wallMs, now.toISOString(), attempt);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
/** End a campaign, once: complete or incomplete. */
|
|
321
|
+
export function finishEvalCampaign(store, id, state, why, now) {
|
|
322
|
+
store.handle.prepare("UPDATE eval_campaign SET state = ?, why = ?, finished_at = ? WHERE id = ? AND state = 'running'").run(state, why, now.toISOString(), id);
|
|
323
|
+
}
|
|
324
|
+
/** What a campaign spent, from its attempts as recorded: API dollars (null when any dispatch's money is unknown),
|
|
325
|
+
* subscription usage (null when any is unknown or unreported), and how many attempts had unknown spending. Never one
|
|
326
|
+
* total of the three. */
|
|
327
|
+
export function campaignSpending(store, id) {
|
|
328
|
+
const rows = store.handle.prepare("SELECT api_microusd, subscription_microusd, usage_unknown FROM eval_attempt WHERE campaign = ? AND outcome IS NOT NULL").all(id);
|
|
329
|
+
// An abandoned campaign's spending is unknown whatever its finished attempts recorded: its runner may have gone on.
|
|
330
|
+
const abandoned = Number(store.handle.prepare("SELECT spending_unknown FROM eval_campaign WHERE id = ?").get(id)?.["spending_unknown"] ?? 0) !== 0;
|
|
331
|
+
const total = (column) => abandoned || rows.some(row => row[column] === null) ? null : rows.reduce((sum, row) => sum + Number(row[column]), 0);
|
|
332
|
+
return { apiMicrousd: total("api_microusd"), subscriptionMicrousd: total("subscription_microusd"), unknownAttempts: rows.filter(row => Number(row["usage_unknown"]) === 1).length, attempts: rows.length };
|
|
333
|
+
}
|
|
334
|
+
const sum = (values) => values.some(one => one === null) ? null : values.reduce((total, one) => total + one, 0);
|
|
335
|
+
const UNKNOWN = { kind: "unknown" };
|
|
336
|
+
/** Every provider dispatch a world made, from its own records (its database, opened read only; null when there is none
|
|
337
|
+
* or it can't be read) and its gate's slots: each with what it was billed as and the model it reported. A slot no
|
|
338
|
+
* record accounts for, and a record no slot does, is a dispatch with unknown money and an unknown model. */
|
|
339
|
+
export function collectWorld(world, gateDir, maxDispatches, evidenceRoot, now) {
|
|
340
|
+
const slots = evalGateSlots(gateDir);
|
|
341
|
+
const invocations = [];
|
|
342
|
+
const turns = [], tokensIn = [], cacheRead = [], cacheWrite = [], tokensOut = [];
|
|
343
|
+
const toolCalls = [];
|
|
344
|
+
const unrecorded = (slot) => {
|
|
345
|
+
invocations.push({ kind: "unrecorded", worldRef: `slot:${slot.n}`, recorded: false, amount: UNKNOWN, modelReported: null });
|
|
346
|
+
for (const list of [turns, tokensIn, cacheRead, cacheWrite, tokensOut, toolCalls])
|
|
347
|
+
list.push(null);
|
|
348
|
+
};
|
|
349
|
+
let unaccounted = slots === null;
|
|
350
|
+
if (world === null) {
|
|
351
|
+
for (const slot of slots !== null && slots.length > 0 ? slots : [{ n: 0, kind: "unreadable" }])
|
|
352
|
+
unrecorded(slot);
|
|
353
|
+
unaccounted = true;
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
try {
|
|
357
|
+
const runs = world.handle.prepare("SELECT id, role FROM run WHERE provider_started_at IS NOT NULL ORDER BY id").all().map(row => ({ id: Number(row["id"]), role: String(row["role"]) }));
|
|
358
|
+
const runSlots = new Set((slots ?? []).filter(slot => slot.kind.startsWith("run:")).map(slot => slot.kind));
|
|
359
|
+
const chatSlots = (slots ?? []).filter(slot => !slot.kind.startsWith("run:"));
|
|
360
|
+
for (const run of runs) {
|
|
361
|
+
const read = runAccounting(world, run.id);
|
|
362
|
+
if (!runSlots.has(`run:${run.id}`))
|
|
363
|
+
unaccounted = true;
|
|
364
|
+
invocations.push({ kind: run.role, worldRef: `run:${run.id}`, recorded: true, amount: read?.amount ?? UNKNOWN, modelReported: read?.modelReported ?? null });
|
|
365
|
+
turns.push(read?.agentTurns ?? null);
|
|
366
|
+
tokensIn.push(read?.usage.in ?? null);
|
|
367
|
+
cacheRead.push(read?.usage.cacheRead ?? null);
|
|
368
|
+
cacheWrite.push(read?.usage.cacheWrite ?? null);
|
|
369
|
+
tokensOut.push(read?.usage.out ?? null);
|
|
370
|
+
toolCalls.push(toolCallsOf(world, run.id, evidenceRoot, now));
|
|
371
|
+
}
|
|
372
|
+
const chats = world.handle.prepare("SELECT id FROM chat_turn WHERE state IN ('running','answered','failed') ORDER BY id").all().map(row => Number(row["id"]));
|
|
373
|
+
for (const id of chats) {
|
|
374
|
+
const read = chatTurnAccounting(world, id);
|
|
375
|
+
invocations.push({ kind: "lead", worldRef: `chat_turn:${id}`, recorded: true, amount: read?.amount ?? UNKNOWN, modelReported: read?.modelReported ?? null });
|
|
376
|
+
turns.push(read?.providerTurns ?? null);
|
|
377
|
+
tokensIn.push(read?.tokensIn ?? null);
|
|
378
|
+
cacheRead.push(read?.cacheReadTokens ?? null);
|
|
379
|
+
cacheWrite.push(read?.cacheWriteTokens ?? null);
|
|
380
|
+
tokensOut.push(read?.tokensOut ?? null);
|
|
381
|
+
// A chat request's own tool calls aren't step-recorded: unknown.
|
|
382
|
+
toolCalls.push(null);
|
|
383
|
+
}
|
|
384
|
+
if (chats.length > chatSlots.length)
|
|
385
|
+
unaccounted = true;
|
|
386
|
+
// A run slot whose run never reached its provider spent nothing; a run slot with no run at all, or a chat slot past
|
|
387
|
+
// the recorded chat requests, may have been sent.
|
|
388
|
+
for (const slot of slots ?? []) {
|
|
389
|
+
if (!slot.kind.startsWith("run:"))
|
|
390
|
+
continue;
|
|
391
|
+
const id = Number(slot.kind.slice(4));
|
|
392
|
+
if (Number.isSafeInteger(id) && world.handle.prepare("SELECT 1 FROM run WHERE id = ?").get(id) !== undefined)
|
|
393
|
+
continue;
|
|
394
|
+
unrecorded(slot);
|
|
395
|
+
unaccounted = true;
|
|
396
|
+
}
|
|
397
|
+
for (const slot of chatSlots.slice(chats.length)) {
|
|
398
|
+
unrecorded(slot);
|
|
399
|
+
unaccounted = true;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
// Keep every charge already read. A damaged or older world must not prevent settlement or turn missing
|
|
404
|
+
// dispatches into zero spending. The synthetic slot is explicitly unknown, never a measured call.
|
|
405
|
+
unrecorded({ n: 0, kind: "unreadable" });
|
|
406
|
+
unaccounted = true;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (slots === null && !invocations.some(one => one.worldRef === "slot:0"))
|
|
410
|
+
unrecorded({ n: 0, kind: "unreadable" });
|
|
411
|
+
const moneyUnknown = invocations.some(one => one.amount.kind === "unknown");
|
|
412
|
+
const usageUnknown = moneyUnknown || invocations.some(one => one.amount.kind === "subscription" && one.amount.reportedUsd === null);
|
|
413
|
+
let modelReported = null;
|
|
414
|
+
for (const [index, one] of invocations.entries())
|
|
415
|
+
modelReported = index === 0 ? one.modelReported : combineReportedModels(modelReported, one.modelReported);
|
|
416
|
+
const count = slots === null || world === null ? null : Math.max(invocations.length, slots.length);
|
|
417
|
+
return {
|
|
418
|
+
invocations, modelReported: unaccounted || invocations.length === 0 ? null : modelReported, dispatches: count,
|
|
419
|
+
overLimit: (count ?? 0) > maxDispatches || evalGateOverLimit(gateDir), unaccounted,
|
|
420
|
+
apiMicrousd: moneyUnknown ? null : sum(invocations.map(one => one.amount.kind === "api" ? one.amount.microusd : 0)),
|
|
421
|
+
subscriptionMicrousd: moneyUnknown ? null : sum(invocations.map(one => one.amount.kind === "subscription" ? (one.amount.reportedUsd === null ? null : Math.round(one.amount.reportedUsd * 1_000_000)) : 0)),
|
|
422
|
+
usageUnknown, turns: unaccounted ? null : sum(turns), tokensIn: unaccounted ? null : sum(tokensIn), cacheRead: unaccounted ? null : sum(cacheRead), cacheWrite: unaccounted ? null : sum(cacheWrite), tokensOut: unaccounted ? null : sum(tokensOut), toolCalls: unaccounted ? null : sum(toolCalls),
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
/** A run's tool calls from its step records (run-efficiency.ts stepsOf), read before the world is removed; null when
|
|
426
|
+
* the records are absent, expired or incomplete. */
|
|
427
|
+
function toolCallsOf(world, runId, evidenceRoot, now) {
|
|
428
|
+
const run = world.getRun(runId);
|
|
429
|
+
if (run === null)
|
|
430
|
+
return null;
|
|
431
|
+
const usageJson = world.handle.prepare("SELECT usage_json FROM run WHERE id = ?").get(runId)?.["usage_json"];
|
|
432
|
+
try {
|
|
433
|
+
const steps = deriveRunEfficiency({ ...run, usageJson: typeof usageJson === "string" ? usageJson : null }, evidenceRoot, now).steps;
|
|
434
|
+
return steps.state === "complete" ? steps.total : null;
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/** A world's records, read only; null when there is no database or it can't be read. */
|
|
441
|
+
function openWorld(databaseFile) {
|
|
442
|
+
try {
|
|
443
|
+
lstatSync(databaseFile);
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
return new Store(new (sqlite().DatabaseSync)(databaseFile, { readOnly: true }));
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// ------------------------------------------------------------------ one attempt
|
|
456
|
+
/** The installed build's identity: its version, a digest of every file of the code that is running (dist when
|
|
457
|
+
* installed, the sources in a checkout), and the service definitions that say which runtime the plane runs (a deploy
|
|
458
|
+
* rewrites them to another runtime's folder). A change between attempts, or before the end, stops the campaign as
|
|
459
|
+
* incomplete. */
|
|
460
|
+
export function installedBuildDigest(version, home = homedir()) {
|
|
461
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
462
|
+
const units = [];
|
|
463
|
+
for (const folder of [join(home, "Library", "LaunchAgents"), join(home, ".config", "systemd", "user")]) {
|
|
464
|
+
let names = [];
|
|
465
|
+
try {
|
|
466
|
+
names = readdirSync(folder).sort();
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
for (const name of names)
|
|
472
|
+
if (/toolroll|standing-orders/i.test(name))
|
|
473
|
+
units.push(`${name}\0${sha256(readNoFollow(join(folder, name), 1024 * 1024) ?? Buffer.from("unreadable"))}`);
|
|
474
|
+
}
|
|
475
|
+
return sha256(`${version}\0${folderFilesDeep(here)}\0${units.join("\0")}`);
|
|
476
|
+
}
|
|
477
|
+
function folderFilesDeep(dir) {
|
|
478
|
+
const hash = createHash("sha256");
|
|
479
|
+
const walk = (at) => {
|
|
480
|
+
for (const name of readdirSync(at).sort()) {
|
|
481
|
+
const path = join(at, name);
|
|
482
|
+
const stat = lstatSync(path);
|
|
483
|
+
if (stat.isDirectory())
|
|
484
|
+
walk(path);
|
|
485
|
+
else if (stat.isFile() && /\.(js|ts|mjs|json)$/.test(name) && !/\.test\.ts$/.test(name))
|
|
486
|
+
hash.update(relative(dir, path)).update("\0").update(sha256(readNoFollow(path, 64 * 1024 * 1024) ?? Buffer.from("unreadable"))).update("\0");
|
|
487
|
+
else if (stat.isSymbolicLink())
|
|
488
|
+
hash.update(relative(dir, path)).update("\0link\0");
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
walk(dir);
|
|
492
|
+
return hash.digest("hex");
|
|
493
|
+
}
|
|
494
|
+
/** Whether every process witness is gone: the child's own group, and each descendant the observer saw, checked as what
|
|
495
|
+
* it was seen as (a process, or a group whose leader it was). */
|
|
496
|
+
function treeGone(pgid, seen) {
|
|
497
|
+
return pgid > 0 && !processMayBeAlive(pgid, true) && [...seen].every(([pid, group]) => !processMayBeAlive(pid, group));
|
|
498
|
+
}
|
|
499
|
+
/** Stop what is left of a world: the child's group, and each witness as what it was seen as. Its own processes only. */
|
|
500
|
+
function stopWorld(pgid, seen) {
|
|
501
|
+
if (pgid > 0) {
|
|
502
|
+
try {
|
|
503
|
+
process.kill(-pgid, "SIGKILL");
|
|
504
|
+
}
|
|
505
|
+
catch { /* gone */ }
|
|
506
|
+
}
|
|
507
|
+
for (const [pid, group] of seen) {
|
|
508
|
+
try {
|
|
509
|
+
process.kill(group ? -pid : pid, "SIGKILL");
|
|
510
|
+
}
|
|
511
|
+
catch { /* gone */ }
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/** Run the child and wait for its whole process tree: the child's exit, its process group gone, and every descendant
|
|
515
|
+
* (and every group one led) the process-tree observer saw gone. A tree that won't end, or one whose ancestry couldn't
|
|
516
|
+
* be observed, is stopped and reported (`lingering`), never graded. */
|
|
517
|
+
async function runWorldProcess(spec, cwd, timeoutMs, settleMs, onSpawn, persist) {
|
|
518
|
+
persist({ state: "starting", witnesses: [] });
|
|
519
|
+
const child = spawn(spec.file, spec.args, { cwd, env: spec.env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
520
|
+
const seen = new Map();
|
|
521
|
+
let unknownAncestry = false;
|
|
522
|
+
const pgid = child.pid ?? -1;
|
|
523
|
+
const save = () => persist({ state: unknownAncestry ? "unknown" : "watching", witnesses: [...(pgid > 0 ? [[pgid, true]] : []), ...seen] });
|
|
524
|
+
// A persistence failure after spawn must still stop the owned child and await its exit.
|
|
525
|
+
try {
|
|
526
|
+
if (pgid > 0)
|
|
527
|
+
onSpawn(pgid);
|
|
528
|
+
save();
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
unknownAncestry = true;
|
|
532
|
+
child.kill("SIGKILL");
|
|
533
|
+
}
|
|
534
|
+
observeProcessTree(child, {
|
|
535
|
+
onDescendant: (pid, group) => { seen.set(pid, seen.get(pid) === true || group); save(); },
|
|
536
|
+
onDescendantExit: (pid, group) => { if (seen.get(pid) === group) {
|
|
537
|
+
seen.delete(pid);
|
|
538
|
+
save();
|
|
539
|
+
} },
|
|
540
|
+
onUnknown: () => { unknownAncestry = true; save(); },
|
|
541
|
+
});
|
|
542
|
+
let stdout = "";
|
|
543
|
+
child.stdout?.on("data", (chunk) => { if (stdout.length < 64 * 1024)
|
|
544
|
+
stdout += chunk.toString("utf8"); });
|
|
545
|
+
child.stderr?.on("data", () => undefined);
|
|
546
|
+
let timedOut = false;
|
|
547
|
+
const code = await new Promise(done => {
|
|
548
|
+
const timer = setTimeout(() => {
|
|
549
|
+
timedOut = true;
|
|
550
|
+
stopProcessTree(child);
|
|
551
|
+
if (pgid > 0) {
|
|
552
|
+
try {
|
|
553
|
+
process.kill(-pgid, "SIGKILL");
|
|
554
|
+
}
|
|
555
|
+
catch { /* gone */ }
|
|
556
|
+
}
|
|
557
|
+
}, timeoutMs);
|
|
558
|
+
child.once("error", () => { clearTimeout(timer); done(null); });
|
|
559
|
+
child.once("close", exit => { clearTimeout(timer); done(exit); });
|
|
560
|
+
});
|
|
561
|
+
if (timedOut)
|
|
562
|
+
stopWorld(pgid, seen);
|
|
563
|
+
const until = Date.now() + settleMs;
|
|
564
|
+
while (pgid > 0 && !treeGone(pgid, seen) && Date.now() < until)
|
|
565
|
+
await new Promise(done => setTimeout(done, 50));
|
|
566
|
+
let lingering = false, why = null;
|
|
567
|
+
if ((pgid > 0 && !treeGone(pgid, seen)) || unknownAncestry) {
|
|
568
|
+
lingering = true;
|
|
569
|
+
why = unknownAncestry ? "its world's process tree couldn't be observed whole, so it wasn't graded" : "the world's processes didn't end after its attempt, so it wasn't graded";
|
|
570
|
+
stopWorld(pgid, seen);
|
|
571
|
+
const last = Date.now() + 5_000;
|
|
572
|
+
while (pgid > 0 && !treeGone(pgid, seen) && Date.now() < last)
|
|
573
|
+
await new Promise(done => setTimeout(done, 50));
|
|
574
|
+
}
|
|
575
|
+
// Its report: the whole output as one JSON document (the CLI's envelope), or its last line. None: the agent's stage.
|
|
576
|
+
let report = null;
|
|
577
|
+
for (const candidate of [stdout.trim(), stdout.trim().split("\n").pop() ?? ""]) {
|
|
578
|
+
try {
|
|
579
|
+
const parsed = JSON.parse(candidate);
|
|
580
|
+
const stage = parsed["evalStage"];
|
|
581
|
+
if (stage === "world-start" || stage === "agent" || stage === "done") {
|
|
582
|
+
report = { stage, why: typeof parsed["why"] === "string" ? parsed["why"].slice(0, 300) : null };
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch { /* not this one */ }
|
|
587
|
+
}
|
|
588
|
+
const stopped = pgid < 0 || (!unknownAncestry && treeGone(pgid, seen));
|
|
589
|
+
persist({ state: stopped ? "stopped" : "unknown", witnesses: [...(pgid > 0 ? [[pgid, true]] : []), ...seen] });
|
|
590
|
+
return { timedOut, report, lingering, stopped, why, code };
|
|
591
|
+
}
|
|
592
|
+
/** Write each graded file of the built commit, from verified Git objects (never the checkout, never a link), into a
|
|
593
|
+
* fresh folder for the oracle. */
|
|
594
|
+
function extractGraded(repo, head, files, into) {
|
|
595
|
+
let gitDir;
|
|
596
|
+
try {
|
|
597
|
+
gitDir = realpathSync(join(repo, ".git"));
|
|
598
|
+
if (!lstatSync(join(repo, ".git")).isDirectory())
|
|
599
|
+
return { ok: false, why: "the world's repository has no Git directory" };
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
return { ok: false, why: "the world's repository can't be read" };
|
|
603
|
+
}
|
|
604
|
+
const commit = readVerifiedCommit(gitDir, head);
|
|
605
|
+
if (!commit.ok)
|
|
606
|
+
return { ok: false, why: "the built commit can't be read" };
|
|
607
|
+
const blobs = verifiedBlobs(gitDir, commit.value.tree, files);
|
|
608
|
+
if (!blobs.ok)
|
|
609
|
+
return { ok: false, why: `the built files can't be read: ${blobs.message}` };
|
|
610
|
+
for (const file of files) {
|
|
611
|
+
const blob = blobs.value.get(file);
|
|
612
|
+
if (blob === undefined)
|
|
613
|
+
continue; // missing: the oracle fails it
|
|
614
|
+
mkdirSync(dirname(join(into, file)), { recursive: true });
|
|
615
|
+
const fd = openSync(join(into, file), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
616
|
+
try {
|
|
617
|
+
writeSync(fd, blob.bytes);
|
|
618
|
+
}
|
|
619
|
+
finally {
|
|
620
|
+
closeSync(fd);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return { ok: true };
|
|
624
|
+
}
|
|
625
|
+
/** An attempt's result from what its world recorded, with the outcome and why. */
|
|
626
|
+
const resultOf = (outcome, why, collected, wallMs) => ({
|
|
627
|
+
outcome, why, modelReported: collected.modelReported, dispatches: collected.dispatches,
|
|
628
|
+
apiMicrousd: collected.apiMicrousd, subscriptionMicrousd: collected.subscriptionMicrousd, usageUnknown: collected.usageUnknown,
|
|
629
|
+
turns: collected.turns, tokensIn: collected.tokensIn, cacheRead: collected.cacheRead, cacheWrite: collected.cacheWrite, tokensOut: collected.tokensOut, toolCalls: collected.toolCalls,
|
|
630
|
+
wallMs, invocations: collected.invocations
|
|
631
|
+
});
|
|
632
|
+
/**
|
|
633
|
+
* One attempt: record its start, make the world, run its child to the end of its whole process tree, read what it
|
|
634
|
+
* did, grade it, record it. What the world dispatched is collected and recorded on every road out, the runner's own
|
|
635
|
+
* failure included, before the world is removed. Outcomes (plan, "Grading"): an error only where the plane itself
|
|
636
|
+
* recorded one (the world failed before the agent started, the provider refused or was down, the oracle failed, the
|
|
637
|
+
* world's processes wouldn't end, a dispatch the gate and the records don't agree on); the agent timing out, crashing
|
|
638
|
+
* or going past its dispatch limit is a fail; a reported model other than the pin is unevaluated.
|
|
639
|
+
*/
|
|
640
|
+
export async function runEvalAttempt(store, input, deps) {
|
|
641
|
+
const now = deps.now ?? (() => new Date());
|
|
642
|
+
const { one } = input;
|
|
643
|
+
const started = startEvalAttempt(store, { campaign: input.campaign, caseId: one.id, caseVersion: one.version, n: input.n, replaces: input.replaces, modelPinned: one.pin.model, now: now() });
|
|
644
|
+
if (!started.ok)
|
|
645
|
+
return { attempt: null, why: started.why };
|
|
646
|
+
const attempt = started.attempt;
|
|
647
|
+
const begun = Date.now();
|
|
648
|
+
let worldDir = null;
|
|
649
|
+
let ran = null;
|
|
650
|
+
let failure = null;
|
|
651
|
+
let spawned = false;
|
|
652
|
+
// The world's state folder is named as Toolroll's own, so every agent fence in the world denies it whole: the
|
|
653
|
+
// database, the worker's token and the dispatch gate (agent-fence.ts agentFence, a dedicated state folder).
|
|
654
|
+
const paths = (dir) => ({ stateDir: join(dir, "toolroll"), gateDir: join(dir, "toolroll", "eval-gate"), databaseFile: join(dir, "toolroll", "orders.db") });
|
|
655
|
+
try {
|
|
656
|
+
mkdirSync(deps.workRoot, { recursive: true, mode: 0o700 });
|
|
657
|
+
worldDir = realpathSync(mkdtempSync(join(deps.workRoot, `${one.id}-${input.n}-`)));
|
|
658
|
+
markAttemptWorld(store, attempt, worldDir, null);
|
|
659
|
+
recordWorldProcesses(store, attempt, { state: "not-started", witnesses: [] });
|
|
660
|
+
const { stateDir, gateDir, databaseFile } = paths(worldDir);
|
|
661
|
+
mkdirSync(stateDir, { mode: 0o700 });
|
|
662
|
+
mkdirSync(gateDir, { mode: 0o700 });
|
|
663
|
+
openEvalGate(gateDir, { v: 2, max: one.maxDispatches });
|
|
664
|
+
const spec = deps.command({ campaign: input.campaign, caseId: one.id, n: input.n, worldDir, databaseFile, gateDir });
|
|
665
|
+
ran = await runWorldProcess({ ...spec, env: { ...spec.env, [EVAL_GATE_ENV]: gateDir } }, worldDir, one.timeoutMinutes * 60_000, deps.settleMs ?? 30_000, pgid => { spawned = true; markAttemptWorld(store, attempt, worldDir, pgid); }, processes => recordWorldProcesses(store, attempt, processes));
|
|
666
|
+
if (ran.stopped)
|
|
667
|
+
deps.onTreeGone?.();
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
failure = `the eval runner failed: ${error instanceof Error ? error.message.slice(0, 200) : String(error)}`;
|
|
671
|
+
}
|
|
672
|
+
// Whatever happened, what the world recorded is read before anything is decided or removed.
|
|
673
|
+
const where = worldDir === null ? null : paths(worldDir);
|
|
674
|
+
// A lingering tree has been stopped by now: its records are read all the same, so known spending is kept.
|
|
675
|
+
const world = where === null ? null : openWorld(where.databaseFile);
|
|
676
|
+
let result;
|
|
677
|
+
const collected = where === null
|
|
678
|
+
? collectWorld(null, join(deps.workRoot, "no-world"), one.maxDispatches, "", now())
|
|
679
|
+
: collectWorld(world, where.gateDir, one.maxDispatches, join(where.stateDir, "evidence"), now());
|
|
680
|
+
try {
|
|
681
|
+
const wall = () => Date.now() - begun;
|
|
682
|
+
if (failure !== null)
|
|
683
|
+
result = resultOf("error", failure, collected, wall());
|
|
684
|
+
else if (ran === null)
|
|
685
|
+
result = resultOf("error", "the eval runner failed", collected, wall());
|
|
686
|
+
else if (ran.lingering)
|
|
687
|
+
result = resultOf("error", ran.why, collected, wall());
|
|
688
|
+
else if (world === null) {
|
|
689
|
+
result = resultOf("error", ran.report?.stage === "world-start" ? `the world failed before the agent started: ${ran.report.why ?? "no reason given"}` : "the world's records can't be read", collected, wall());
|
|
690
|
+
}
|
|
691
|
+
else if (collected.unaccounted) {
|
|
692
|
+
result = resultOf("error", "a provider dispatch and the world's records don't agree (one went around the gate, or left no record), so its spending is unknown", collected, wall());
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
const startedRuns = Number(world.handle.prepare("SELECT COUNT(*) AS n FROM run WHERE provider_started_at IS NOT NULL").get()?.["n"] ?? 0);
|
|
696
|
+
const refused = Number(world.handle.prepare("SELECT COUNT(*) AS n FROM run WHERE outcome = 'refused' AND provider_started_at IS NULL").get()?.["n"] ?? 0);
|
|
697
|
+
if (collected.invocations.length === 0 && startedRuns === 0) {
|
|
698
|
+
// Nothing reached a provider: the world failed before the agent started (its child said so), or the provider
|
|
699
|
+
// refused (a plane-side record); anything else is the agent's (a fail).
|
|
700
|
+
const why = ran.report?.stage === "world-start" ? `the world failed before the agent started: ${ran.report.why ?? "no reason given"}`
|
|
701
|
+
: refused > 0 ? "the provider refused or was unavailable before the agent started" : null;
|
|
702
|
+
result = why !== null ? resultOf("error", why, collected, wall()) : resultOf("fail", ran.timedOut ? "timed out before the agent started" : "the agent never started", collected, wall());
|
|
703
|
+
}
|
|
704
|
+
else if (collected.overLimit) {
|
|
705
|
+
result = resultOf("fail", `it asked for more than its limit of ${one.maxDispatches} provider dispatches`, collected, wall());
|
|
706
|
+
}
|
|
707
|
+
else if (ran.timedOut) {
|
|
708
|
+
result = resultOf("fail", `it ran past its ${one.timeoutMinutes}-minute limit`, collected, wall());
|
|
709
|
+
}
|
|
710
|
+
else if (ran.code !== 0) {
|
|
711
|
+
result = resultOf("fail", "the world's process crashed", collected, wall());
|
|
712
|
+
}
|
|
713
|
+
else if (collected.modelReported !== one.pin.model) {
|
|
714
|
+
result = resultOf("unevaluated", `the provider reported ${collected.modelReported ?? "no model"}, not the pinned ${one.pin.model}`, collected, wall());
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
const planeState = store.databaseFile();
|
|
718
|
+
try {
|
|
719
|
+
const graded = await grade(world, worldDir, one, deps, planeState === null ? [] : [dirname(planeState)]);
|
|
720
|
+
result = resultOf(graded.outcome, graded.why, collected, wall());
|
|
721
|
+
}
|
|
722
|
+
catch (error) {
|
|
723
|
+
result = resultOf("error", `the grading failed: ${error instanceof Error ? error.message.slice(0, 200) : String(error)}`, collected, wall());
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
catch (error) {
|
|
729
|
+
result = resultOf("error", `the world's records couldn't be processed: ${error instanceof Error ? error.message.slice(0, 200) : String(error)}`, collected, Date.now() - begun);
|
|
730
|
+
}
|
|
731
|
+
finally {
|
|
732
|
+
world?.close();
|
|
733
|
+
}
|
|
734
|
+
// PR #263 review: a world whose process tree isn't proven stopped (its ancestry unknown, a process that outlived
|
|
735
|
+
// SIGKILL, or a runner failure after the spawn) may still be dispatching. What it spent is unknown, never the part
|
|
736
|
+
// its records show, and the campaign ends incomplete.
|
|
737
|
+
let stopCampaign = null;
|
|
738
|
+
if ((ran !== null && !ran.stopped) || (ran === null && spawned)) {
|
|
739
|
+
result = { ...result, apiMicrousd: null, subscriptionMicrousd: null, usageUnknown: true,
|
|
740
|
+
invocations: [...result.invocations, { kind: "unrecorded", worldRef: "tree:unstopped", recorded: false, amount: UNKNOWN, modelReported: null }] };
|
|
741
|
+
stopCampaign = `${one.id} #${input.n}'s world couldn't be proven stopped, so what it spent is unknown`;
|
|
742
|
+
}
|
|
743
|
+
recordEvalAttempt(store, attempt, result, now());
|
|
744
|
+
// Only once it is recorded is the world removed.
|
|
745
|
+
if (worldDir !== null && ran?.stopped === true && deps.keepWorlds !== true)
|
|
746
|
+
rmSync(worldDir, { recursive: true, force: true });
|
|
747
|
+
return { attempt, result, stopCampaign };
|
|
748
|
+
}
|
|
749
|
+
/** Grade from what the world's installed code recorded: the built commit's files through the oracle (build), or the
|
|
750
|
+
* question a run parked with (ask). The agent's own words are never read as a verdict. */
|
|
751
|
+
async function grade(world, worldDir, one, deps, deny) {
|
|
752
|
+
if ("options" in one.grade) {
|
|
753
|
+
const asked = world.handle.prepare("SELECT d.options FROM decision d JOIN run r ON r.id = d.run WHERE r.role = 'builder' ORDER BY d.id LIMIT 1").get();
|
|
754
|
+
if (asked === undefined)
|
|
755
|
+
return { outcome: "fail", why: "the build didn't stop to ask" };
|
|
756
|
+
const built = world.handle.prepare("SELECT COUNT(*) AS n FROM run WHERE role = 'builder' AND outcome = 'built' AND id < (SELECT MIN(run) FROM decision)").get();
|
|
757
|
+
if (Number(built?.["n"] ?? 0) > 0)
|
|
758
|
+
return { outcome: "fail", why: "it built before asking" };
|
|
759
|
+
const options = String(asked["options"]);
|
|
760
|
+
const missing = one.grade.options.filter(pattern => !new RegExp(pattern, "i").test(options));
|
|
761
|
+
return missing.length === 0 ? { outcome: "pass", why: null } : { outcome: "fail", why: "its question didn't offer the choices the case needs" };
|
|
762
|
+
}
|
|
763
|
+
const built = world.handle.prepare("SELECT head_revision FROM run WHERE role IN ('builder','repair') AND outcome = 'built' AND head_revision IS NOT NULL ORDER BY id DESC LIMIT 1").get();
|
|
764
|
+
if (built === undefined)
|
|
765
|
+
return { outcome: "fail", why: "no build finished" };
|
|
766
|
+
const inputDir = realpathSync(mkdtempSync(join(worldDir, "graded-")));
|
|
767
|
+
const extracted = extractGraded(join(worldDir, "repo"), String(built["head_revision"]), one.grade.files, inputDir);
|
|
768
|
+
if (!extracted.ok)
|
|
769
|
+
return { outcome: "fail", why: extracted.why };
|
|
770
|
+
const verdict = await (deps.oracle ?? runOracle)({ caseDir: one.dir, inputDir, timeoutMs: 60_000, deny });
|
|
771
|
+
return { outcome: verdict.outcome, why: verdict.why };
|
|
772
|
+
}
|
|
773
|
+
/** Run an admitted campaign to its end: every sealed attempt (EVAL_CONCURRENCY at once), one replacement for an
|
|
774
|
+
* attempt the plane recorded an error for (at most EVAL_MAX_REPLACEMENTS a campaign), then complete — or incomplete
|
|
775
|
+
* when the installed build or eval set changed (checked before every attempt and again at the end), a deploy is
|
|
776
|
+
* draining, or the runner itself failed. */
|
|
777
|
+
export async function runEvalCampaign(store, campaign, cases, deps) {
|
|
778
|
+
const now = deps.now ?? (() => new Date());
|
|
779
|
+
const row = store.handle.prepare("SELECT k, build_digest, manifest_digest, state FROM eval_campaign WHERE id = ?").get(campaign);
|
|
780
|
+
if (row === undefined || row["state"] !== "running")
|
|
781
|
+
throw new Error(`campaign ${campaign} isn't running`);
|
|
782
|
+
const k = Number(row["k"]), sealedBuild = String(row["build_digest"]), sealedSet = String(row["manifest_digest"]);
|
|
783
|
+
const setNow = () => { const set = (deps.evalSet ?? loadEvalSet)(); return set.ok ? set.manifestDigest : null; };
|
|
784
|
+
const changed = () => deps.buildDigest() !== sealedBuild ? "the installed build changed during the campaign"
|
|
785
|
+
: setNow() !== sealedSet ? "the installed eval set changed during the campaign"
|
|
786
|
+
: updateAdmissionPaused(store.handle) ? "a deploy began draining during the campaign" : null;
|
|
787
|
+
let stop = null;
|
|
788
|
+
let replacements = 0;
|
|
789
|
+
const gate = limiter(deps.concurrency ?? EVAL_CONCURRENCY);
|
|
790
|
+
const attempt = async (one, n, replaces) => {
|
|
791
|
+
if (stop !== null)
|
|
792
|
+
return;
|
|
793
|
+
const moved = changed();
|
|
794
|
+
if (moved !== null) {
|
|
795
|
+
stop = moved;
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const ran = await runEvalAttempt(store, { campaign, one, n, replaces }, deps);
|
|
799
|
+
if (ran.attempt === null) {
|
|
800
|
+
stop ??= ran.why;
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (ran.stopCampaign !== null) {
|
|
804
|
+
stop ??= ran.stopCampaign;
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (ran.result.outcome === "error" && replacements < EVAL_MAX_REPLACEMENTS && replaces === 0) {
|
|
808
|
+
replacements++;
|
|
809
|
+
await attempt(one, n, ran.attempt);
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
try {
|
|
813
|
+
await Promise.all(cases.flatMap(one => Array.from({ length: k }, (_, index) => gate(async () => {
|
|
814
|
+
try {
|
|
815
|
+
await attempt(one, index + 1, 0);
|
|
816
|
+
}
|
|
817
|
+
catch (error) {
|
|
818
|
+
stop ??= `the eval runner failed: ${error instanceof Error ? error.message.slice(0, 200) : String(error)}`;
|
|
819
|
+
}
|
|
820
|
+
}))));
|
|
821
|
+
// The identity again at the end: a change during the last attempts leaves the campaign incomplete too.
|
|
822
|
+
stop ??= changed();
|
|
823
|
+
}
|
|
824
|
+
catch (error) {
|
|
825
|
+
stop ??= `the eval runner failed: ${error instanceof Error ? error.message.slice(0, 200) : String(error)}`;
|
|
826
|
+
}
|
|
827
|
+
const state = stop === null ? "complete" : "incomplete";
|
|
828
|
+
finishEvalCampaign(store, campaign, state, stop, now());
|
|
829
|
+
return { state, why: stop };
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Recover campaigns whose runner stopped (Codex round 1 #12): a running campaign whose runner was this computer's and
|
|
833
|
+
* whose process is gone. Each unfinished attempt is recorded as an error with whatever its world recorded (its spending
|
|
834
|
+
* unknown where the records can't say), once its world's process group is proven gone; then the campaign ends
|
|
835
|
+
* incomplete. A campaign whose runner is alive, ran on another computer, or still has a world running stays as it is.
|
|
836
|
+
*/
|
|
837
|
+
export function recoverEvalCampaigns(store, now, options = {}) {
|
|
838
|
+
const host = options.host ?? hostname();
|
|
839
|
+
const alive = options.alive ?? runnerMayBeAlive;
|
|
840
|
+
const out = [];
|
|
841
|
+
for (const campaign of store.handle.prepare("SELECT id, runner_host, runner_pid, runner_started FROM eval_campaign WHERE state = 'running' ORDER BY admitted_at").all()) {
|
|
842
|
+
const id = String(campaign["id"]);
|
|
843
|
+
if (String(campaign["runner_host"]) !== host) {
|
|
844
|
+
out.push({ id, recovered: false, why: `its runner is on ${String(campaign["runner_host"])}` });
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
if (alive(Number(campaign["runner_pid"]), typeof campaign["runner_started"] === "string" ? campaign["runner_started"] : null)) {
|
|
848
|
+
out.push({ id, recovered: false, why: "its runner is still running" });
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
const open = store.handle.prepare("SELECT id, world_dir, world_pgid, world_processes_json, case_id FROM eval_attempt WHERE campaign = ? AND outcome IS NULL").all(id);
|
|
852
|
+
const busy = open.find(one => one["world_pgid"] !== null && processMayBeAlive(Number(one["world_pgid"]), true));
|
|
853
|
+
if (busy !== undefined) {
|
|
854
|
+
out.push({ id, recovered: false, why: `attempt ${String(busy["id"])}'s world (process group ${String(busy["world_pgid"])}) is still running` });
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
const unproven = open.find(one => {
|
|
858
|
+
if (one["world_dir"] === null)
|
|
859
|
+
return false; // No world was ever made.
|
|
860
|
+
try {
|
|
861
|
+
const saved = JSON.parse(String(one["world_processes_json"]));
|
|
862
|
+
if (saved === null || !Array.isArray(saved.witnesses))
|
|
863
|
+
return true;
|
|
864
|
+
if (saved.state === "not-started" || saved.state === "stopped")
|
|
865
|
+
return false;
|
|
866
|
+
if (saved.state !== "watching" || saved.witnesses.length === 0)
|
|
867
|
+
return true;
|
|
868
|
+
return saved.witnesses.some(witness => !Array.isArray(witness) || witness.length !== 2
|
|
869
|
+
|| !Number.isSafeInteger(witness[0]) || witness[0] <= 0 || typeof witness[1] !== "boolean"
|
|
870
|
+
|| processMayBeAlive(witness[0], witness[1]));
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
return true;
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
if (unproven !== undefined) {
|
|
877
|
+
out.push({ id, recovered: false, why: `attempt ${String(unproven["id"])}'s process tree isn't proven stopped` });
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
for (const one of open) {
|
|
881
|
+
const dir = one["world_dir"] === null ? null : String(one["world_dir"]);
|
|
882
|
+
const world = dir === null ? null : openWorld(join(dir, "toolroll", "orders.db"));
|
|
883
|
+
try {
|
|
884
|
+
// No world folder recorded: the attempt never got as far as making one (its gate reads as unreadable: unknown).
|
|
885
|
+
const collected = collectWorld(world, dir === null ? "" : join(dir, "toolroll", "eval-gate"), Number.MAX_SAFE_INTEGER, dir === null ? "" : join(dir, "toolroll", "evidence"), now);
|
|
886
|
+
recordEvalAttempt(store, Number(one["id"]), resultOf("error", "interrupted: its runner stopped before the attempt finished", collected, 0), now);
|
|
887
|
+
}
|
|
888
|
+
finally {
|
|
889
|
+
world?.close();
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
finishEvalCampaign(store, id, "incomplete", "its runner stopped before the campaign finished", now);
|
|
893
|
+
out.push({ id, recovered: true, why: `${open.length} unfinished attempt${open.length === 1 ? "" : "s"} recorded as interrupted` });
|
|
894
|
+
}
|
|
895
|
+
return out;
|
|
896
|
+
}
|
|
897
|
+
/** A process's start time as the system reports it (`ps -o lstart`, whole seconds, in UTC and the C locale), or null
|
|
898
|
+
* when it can't be read: the identity a saved pid is compared by, so a later process that reuses the number is never
|
|
899
|
+
* taken for the one recorded. */
|
|
900
|
+
export function processStartOf(pid) {
|
|
901
|
+
if (process.platform === "win32" || !Number.isSafeInteger(pid) || pid <= 0)
|
|
902
|
+
return null;
|
|
903
|
+
try {
|
|
904
|
+
const raw = execFileSync("/bin/ps", ["-p", String(pid), "-o", "lstart="], {
|
|
905
|
+
encoding: "utf8", env: { PATH: "/usr/bin:/bin", LC_ALL: "C", TZ: "UTC" }, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1024, timeout: 2_000, killSignal: "SIGKILL",
|
|
906
|
+
}).trim().replace(/ +/g, " ");
|
|
907
|
+
return /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{1,2} \d{2}:\d{2}:\d{2} \d{4}$/.test(raw) ? raw : null;
|
|
908
|
+
}
|
|
909
|
+
catch {
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
/** Whether a campaign's runner may still be running (PR #263 review): its pid is held, and by the same process — its
|
|
914
|
+
* start time, where one was recorded, matches. A pid now held by a process that started at another time is someone
|
|
915
|
+
* else's. A start that can't be read now counts as alive (a person ends such a campaign: `evals recover --abandon`). */
|
|
916
|
+
export function runnerMayBeAlive(pid, started) {
|
|
917
|
+
if (!processMayBeAlive(pid, false))
|
|
918
|
+
return false;
|
|
919
|
+
const was = started === null ? null : startMs(started);
|
|
920
|
+
const now = processStartOf(pid);
|
|
921
|
+
const is = now === null ? null : startMs(now);
|
|
922
|
+
// Within two seconds is the same process: a system that derives the start from its boot time can shift it by a
|
|
923
|
+
// second, and a pid can't be reused while the process holding it lives.
|
|
924
|
+
return was === null || is === null || Math.abs(is - was) <= 2_000;
|
|
925
|
+
}
|
|
926
|
+
/** A `ps -o lstart` reading (UTC, C locale) as epoch milliseconds, or null when it isn't one exactly. */
|
|
927
|
+
function startMs(raw) {
|
|
928
|
+
const match = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{1,2}) (\d{2}:\d{2}:\d{2}) (\d{4})$/.exec(raw);
|
|
929
|
+
if (match === null)
|
|
930
|
+
return null;
|
|
931
|
+
const utc = `${match[1]}, ${match[3].padStart(2, "0")} ${match[2]} ${match[5]} ${match[4]} GMT`;
|
|
932
|
+
const ms = Date.parse(utc);
|
|
933
|
+
return Number.isFinite(ms) && new Date(ms).toUTCString() === utc ? ms : null;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* End a stuck campaign by a person's word (PR #263 review): a campaign recovery can't settle (its runner can't be
|
|
937
|
+
* proven gone, or a world's processes can't). A person proved now with their password (a lead token or remembered
|
|
938
|
+
* login is no proof); in one transaction every unfinished attempt is recorded as an error with unknown spending, the
|
|
939
|
+
* campaign's spending is marked unknown, and it ends incomplete. No process is signalled and no world is read or
|
|
940
|
+
* removed: whatever is still running is left to the person.
|
|
941
|
+
*/
|
|
942
|
+
export function abandonEvalCampaign(store, input) {
|
|
943
|
+
return store.transact(() => {
|
|
944
|
+
const proved = authenticateApprover(store, input.as, input.token, null);
|
|
945
|
+
if (!proved.ok || !personProof(proved.method))
|
|
946
|
+
return { ok: false, reason: "person-only", message: "Only an approver, proved as a person with their password, abandons an eval campaign." };
|
|
947
|
+
const campaign = store.handle.prepare("SELECT state FROM eval_campaign WHERE id = ?").get(input.id);
|
|
948
|
+
if (campaign === undefined || campaign["state"] !== "running")
|
|
949
|
+
return { ok: false, reason: "not-running", message: `No running campaign is named ${input.id}.` };
|
|
950
|
+
const open = store.handle.prepare("SELECT id FROM eval_attempt WHERE campaign = ? AND outcome IS NULL ORDER BY id").all(input.id).map(row => Number(row["id"]));
|
|
951
|
+
for (const attempt of open) {
|
|
952
|
+
recordEvalAttempt(store, attempt, { outcome: "error", why: `abandoned by ${input.as} before it finished`, modelReported: null, dispatches: null,
|
|
953
|
+
apiMicrousd: null, subscriptionMicrousd: null, usageUnknown: true, turns: null, tokensIn: null, cacheRead: null, cacheWrite: null, tokensOut: null, toolCalls: null, wallMs: 0,
|
|
954
|
+
invocations: [{ kind: "unrecorded", worldRef: "abandoned", recorded: false, amount: UNKNOWN, modelReported: null }] }, input.now);
|
|
955
|
+
}
|
|
956
|
+
store.handle.prepare("UPDATE eval_campaign SET spending_unknown = 1 WHERE id = ?").run(input.id);
|
|
957
|
+
finishEvalCampaign(store, input.id, "incomplete", `abandoned by ${input.as}; what it spent is unknown`, input.now);
|
|
958
|
+
store.recordAction({ at: input.now.toISOString(), actor: input.as, repo: null, taskId: null, runId: null, action: "eval campaign abandoned", outcome: "incomplete", source: "request",
|
|
959
|
+
detail: `campaign ${input.id}, ${open.length} unfinished attempt${open.length === 1 ? "" : "s"}` });
|
|
960
|
+
return { ok: true, attempts: open.length };
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
/** A random hex id, for the world's throwaway approver password. */
|
|
964
|
+
export const worldSecret = () => randomBytes(24).toString("hex");
|