opencode-longrun-harness 1.2.22
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/LICENSE +21 -0
- package/README.md +390 -0
- package/docs/V1.2.20_EVIDENCE.md +114 -0
- package/docs/V1.2.21_EVIDENCE.md +68 -0
- package/docs/V1.2.22_EVIDENCE.md +52 -0
- package/harness/commissioning/README.md +16 -0
- package/harness/commissioning/inspect-copied-run.mjs +25 -0
- package/harness/commissioning/verify-copied-case.mjs +35 -0
- package/harness/plugin/longrun.js +677 -0
- package/harness/src/cli.mjs +40 -0
- package/harness/src/controller.js +1413 -0
- package/harness/src/evidence.mjs +135 -0
- package/harness/src/execution.mjs +217 -0
- package/harness/src/executor.mjs +21 -0
- package/harness/src/install.mjs +435 -0
- package/harness/src/maintenance.mjs +257 -0
- package/harness/src/memory.mjs +472 -0
- package/harness/test/candidates.test.mjs +73 -0
- package/harness/test/checkpoint.test.mjs +65 -0
- package/harness/test/controller.test.mjs +230 -0
- package/harness/test/evidence.test.mjs +57 -0
- package/harness/test/fixtures/durable-host.mjs +27 -0
- package/harness/test/fixtures/example-app-run.json +1375 -0
- package/harness/test/fixtures/notes-budget-exhausted-run.json +2070 -0
- package/harness/test/fixtures/notes-premature-complete-run.json +1496 -0
- package/harness/test/fixtures/notes-recovery-run.json +622 -0
- package/harness/test/fixtures/presets-readout-run.json +825 -0
- package/harness/test/fixtures/routing-worker.mjs +35 -0
- package/harness/test/fixtures/vitest-failed-receipt.json +33 -0
- package/harness/test/helper.mjs +41 -0
- package/harness/test/install.test.mjs +117 -0
- package/harness/test/lifecycle.test.mjs +102 -0
- package/harness/test/maintenance.test.mjs +204 -0
- package/harness/test/memory.test.mjs +145 -0
- package/harness/test/negative-control.test.mjs +91 -0
- package/harness/test/plugin.test.mjs +169 -0
- package/harness/test/recovery-runner.test.mjs +435 -0
- package/harness/test/recovery.test.mjs +68 -0
- package/harness/test/repair-mechanics.test.mjs +122 -0
- package/harness/test/toolbehavior.test.mjs +75 -0
- package/harness/test/v121-commissioning.test.mjs +177 -0
- package/harness/test/v1210-deadline.test.mjs +134 -0
- package/harness/test/v1211-pause.test.mjs +81 -0
- package/harness/test/v1212-maintenance-pause.test.mjs +76 -0
- package/harness/test/v1213-readout.test.mjs +82 -0
- package/harness/test/v1214-durable.test.mjs +121 -0
- package/harness/test/v1215-guidance.test.mjs +57 -0
- package/harness/test/v1216-test-summary.test.mjs +39 -0
- package/harness/test/v1217-discovery.test.mjs +73 -0
- package/harness/test/v1218-completion-review.test.mjs +203 -0
- package/harness/test/v1219-budget-pause.test.mjs +134 -0
- package/harness/test/v122-lifecycle-resolver.test.mjs +218 -0
- package/harness/test/v1220-budget-amendment.test.mjs +343 -0
- package/harness/test/v1221-negative-fixture-anchor.test.mjs +65 -0
- package/harness/test/v1222-default-evidence-class.test.mjs +75 -0
- package/harness/test/v123-plugin-e2e.test.mjs +120 -0
- package/harness/test/v123-receipt-model.test.mjs +185 -0
- package/harness/test/v124-canonical.test.mjs +147 -0
- package/harness/test/v124-installed.test.mjs +48 -0
- package/harness/test/v125-stability.test.mjs +183 -0
- package/harness/test/v126-execution.test.mjs +183 -0
- package/harness/test/v127-reconciliation.test.mjs +139 -0
- package/harness/test/v128-compaction.test.mjs +156 -0
- package/harness/test/v129-routing.test.mjs +165 -0
- package/harness/tools/audit-receipts.mjs +121 -0
- package/harness/tools/recovery-runner.mjs +499 -0
- package/package.json +49 -0
|
@@ -0,0 +1,1413 @@
|
|
|
1
|
+
// Long-run Harness — deterministic controller (shared by plugin, tools, CLI).
|
|
2
|
+
// Dependency-free: node builtins only (works without bun; importable by Bun or Node ESM).
|
|
3
|
+
// No network, no external packages. This file is copied verbatim into the built install.
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import * as EV from "./evidence.mjs";
|
|
9
|
+
import * as MEM from "./memory.mjs";
|
|
10
|
+
import * as EXEC from "./execution.mjs";
|
|
11
|
+
|
|
12
|
+
// ---- Run-lifecycle schema (authoritative, shared by plugin + tools + CLI + tests) ----------
|
|
13
|
+
// The native longrun tool exposes EXACTLY these actions; no ellipsis, no hidden guessing.
|
|
14
|
+
export const LIFECYCLE_SCHEMA_VERSION = "1.2.22";
|
|
15
|
+
export const RUN_ACTIONS = [
|
|
16
|
+
"help", "start", "status", "receipts", "next", "checkpoint", "verify", "pause",
|
|
17
|
+
"resume", "complete", "cancel", "reconcile", "memory_init", "memory_refresh", "memory_status",
|
|
18
|
+
];
|
|
19
|
+
export const ACTION_PARAMS = {
|
|
20
|
+
help: { required: [], optional: ["session"] },
|
|
21
|
+
start: { required: ["request", "criteria"], optional: ["hardGates", "candidateBudget", "timeBudgetHours", "deadlineHours", "toolActionCap", "sameFailureThreshold", "noProgressThreshold", "autoContinue", "checkCatalogue"] },
|
|
22
|
+
status: { required: [], optional: ["run"] },
|
|
23
|
+
receipts: { required: [], optional: ["run", "checkId", "receiptId", "offset", "limit"] },
|
|
24
|
+
next: { required: [], optional: ["run"] },
|
|
25
|
+
checkpoint: { required: [], optional: ["run", "progress"] },
|
|
26
|
+
verify: { required: ["checkId"], optional: ["evidenceClass", "runId"] },
|
|
27
|
+
pause: { required: [], optional: ["run"] },
|
|
28
|
+
resume: { required: [], optional: ["run"] },
|
|
29
|
+
reconcile: { required: [], optional: ["runId"] },
|
|
30
|
+
complete: { required: [], optional: ["run"] },
|
|
31
|
+
cancel: { required: [], optional: ["run", "reason"] },
|
|
32
|
+
memory_init: { required: [], optional: ["maxDepth", "dryRun", "regenerate"] },
|
|
33
|
+
memory_refresh: { required: [], optional: ["maxDepth"] },
|
|
34
|
+
memory_status: { required: [], optional: [] },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const STATES = [
|
|
38
|
+
"READY", "IMPLEMENTING", "VERIFYING", "REPAIRING", "NEEDS_REPLAN",
|
|
39
|
+
"COMPACTING", "RECOVERY_REQUIRED", "PAUSED", "BLOCKED", "COMPLETE",
|
|
40
|
+
"CANCELLED",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// ---- Canonical run resolution (§1, v1.2.2): ONE definition of "active run", shared by the
|
|
44
|
+
// native `longrun` tool AND native `longrun_verify`. State -> code is EXPLICIT; never collapse
|
|
45
|
+
// everything into a single generic "no_active_run". A non-terminal run's state decides whether a
|
|
46
|
+
// verification may run; terminal (COMPLETE/CANCELLED) and absent runs return distinct structured
|
|
47
|
+
// codes. The tool supplies the candidate run records (resolved from session binding + canonical
|
|
48
|
+
// project/worktree identity + an explicit runId); the controller owns the rules.
|
|
49
|
+
|
|
50
|
+
// States in which a declared check MAY be executed + recorded.
|
|
51
|
+
export const RUN_VERIFY_STATES = ["IMPLEMENTING", "VERIFYING", "REPAIRING", "NEEDS_REPLAN"];
|
|
52
|
+
// Terminal states: never a verification target, and never block a fresh `start`.
|
|
53
|
+
export const RUN_TERMINAL_STATES = ["COMPLETE", "CANCELLED"];
|
|
54
|
+
// Resolvable-but-not-eligible states (a verification must first be resumed/reconciled).
|
|
55
|
+
export const RUN_STALL_STATES = ["READY", "COMPACTING", "RECOVERY_REQUIRED", "BLOCKED", "PAUSED"];
|
|
56
|
+
|
|
57
|
+
export function isVerifyEligible(status) { return RUN_VERIFY_STATES.includes(status); }
|
|
58
|
+
|
|
59
|
+
// Caller holds the run writer lock. Shared by native control and installed
|
|
60
|
+
// maintenance so an emergency pause cannot resurrect a terminal run or miss
|
|
61
|
+
// the generation change observed by an already-running declared check.
|
|
62
|
+
export function pauseRun(run) {
|
|
63
|
+
if (RUN_TERMINAL_STATES.includes(run.status)) return { error: `RUN_${run.status}`, state: run.status };
|
|
64
|
+
run.status = "PAUSED";
|
|
65
|
+
run.autoEnabled = false;
|
|
66
|
+
run.controlGeneration = (run.controlGeneration || 0) + 1;
|
|
67
|
+
return { ok: true, state: "PAUSED", cancelledContinuations: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Classify a single run record for verification. Returns {eligible, code, state}.
|
|
71
|
+
export function runVerifyCategory(run) {
|
|
72
|
+
if (!run) return { eligible: false, code: "NO_RUN", state: null };
|
|
73
|
+
const s = run.status;
|
|
74
|
+
if (RUN_VERIFY_STATES.includes(s)) return { eligible: true, code: "ELIGIBLE", state: s };
|
|
75
|
+
if (s === "PAUSED") return { eligible: false, code: "RUN_PAUSED", state: s };
|
|
76
|
+
if (s === "COMPLETE") return { eligible: false, code: "RUN_COMPLETE", state: s };
|
|
77
|
+
if (s === "CANCELLED") return { eligible: false, code: "RUN_CANCELLED", state: s };
|
|
78
|
+
return { eligible: false, code: "RUN_STALLED", state: s };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// STATUS/display selection: the single authoritative current run for a project. Prefer the most
|
|
82
|
+
// recent NON-terminal run; otherwise surface the most recent terminal one (so status can read
|
|
83
|
+
// COMPLETE/CANCELLED). `entries` = chronological [{run}]. Returns the entry or null.
|
|
84
|
+
export function pickCurrentRun(entries) {
|
|
85
|
+
const list = (entries || []).filter((e) => e && e.run);
|
|
86
|
+
const nonTerm = list.filter((e) => !RUN_TERMINAL_STATES.includes(e.run.status));
|
|
87
|
+
if (nonTerm.length) return nonTerm[nonTerm.length - 1];
|
|
88
|
+
if (list.length) return list[list.length - 1];
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// VERIFY resolution against a single authoritative run. Never silently verifies an arbitrary run
|
|
93
|
+
// and never guesses when resolution is ambiguous. `entries` are already project-isolated by the
|
|
94
|
+
// caller. explicitRunId restricts to that id; if it is absent for this project it is NO_RUN (not a
|
|
95
|
+
// cross-project fallback).
|
|
96
|
+
export function resolveVerification({ entries = [], explicitRunId = null } = {}) {
|
|
97
|
+
const withRun = (entries || []).filter((e) => e && e.run);
|
|
98
|
+
if (explicitRunId) {
|
|
99
|
+
const found = withRun.find((e) => e.run.runId === explicitRunId);
|
|
100
|
+
if (!found) return { ok: false, code: "NO_RUN", detail: `run ${explicitRunId} not resolvable in this project/worktree` };
|
|
101
|
+
const cat = runVerifyCategory(found.run);
|
|
102
|
+
if (cat.eligible) return { ok: true, entry: found, run: found.run, runId: found.run.runId };
|
|
103
|
+
return { ok: false, code: cat.code, runId: found.run.runId, state: cat.state, detail: `run ${found.run.runId} is ${cat.state}; not eligible for verification` };
|
|
104
|
+
}
|
|
105
|
+
const active = withRun.filter((e) => !RUN_TERMINAL_STATES.includes(e.run.status));
|
|
106
|
+
if (active.length === 0) return { ok: false, code: "NO_RUN", detail: "no active run in this project (terminal-only or none); a prompt alone is not a run" };
|
|
107
|
+
if (active.length > 1) return { ok: false, code: "AMBIGUOUS_RUN", ids: active.map((e) => e.run.runId), detail: "multiple non-terminal runs; pass an explicit runId" };
|
|
108
|
+
const cat = runVerifyCategory(active[0].run);
|
|
109
|
+
if (cat.eligible) return { ok: true, entry: active[0], run: active[0].run, runId: active[0].run.runId };
|
|
110
|
+
return { ok: false, code: cat.code, runId: active[0].run.runId, state: cat.state, detail: `run ${active[0].run.runId} is ${cat.state}; not eligible for verification` };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Lifecycle verification (§6 + §8): records a receipt + recomputes criterion status/loss WITHOUT
|
|
114
|
+
// counting a source candidate. `applyVerification` (candidate accounting) is UNCHANGED for real
|
|
115
|
+
// candidate evaluation. A lifecycle verify with unchanged source must leave the candidate count
|
|
116
|
+
// at 0, so it is routed here with diagnosticOnly + canChangeAcceptance:false.
|
|
117
|
+
export function applyLifecycleVerification(run, opts = {}) {
|
|
118
|
+
return applyVerification(run, { ...opts, diagnosticOnly: true, canChangeAcceptance: false });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const CHECK_STATUSES = [
|
|
122
|
+
"PASS", "FAIL", "ERROR", "TIMEOUT", "SKIPPED", "BLOCKED", "NOT_RUN", "STALE",
|
|
123
|
+
];
|
|
124
|
+
// Only a valid PASS satisfies a required check.
|
|
125
|
+
export function statusSatisfies(status) { return status === "PASS"; }
|
|
126
|
+
|
|
127
|
+
// Excluded: caches, VCS, and the controller's OWN outputs (so recording evidence never
|
|
128
|
+
// self-invalidates). Matched by path segment (dirs) or by basename / suffix (files).
|
|
129
|
+
const LEGACY_EXCLUDES = [
|
|
130
|
+
".git", "node_modules", ".opencode", "longrun-harness", ".longrun",
|
|
131
|
+
"**/RESUME.md", "**/*.longrun-receipt.json",
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
// Schema 2 excludes known generated outputs. Tests, source, manifests and configuration remain
|
|
135
|
+
// inputs. Legacy hashes are computed separately, never rewritten into historical receipts.
|
|
136
|
+
export const SOURCE_FINGERPRINT_SCHEMA = 2;
|
|
137
|
+
const DEFAULT_EXCLUDES = [...LEGACY_EXCLUDES,
|
|
138
|
+
"dist", "coverage", "test-results", "playwright-report", ".cache", ".DS_Store",
|
|
139
|
+
"**/CHECKPOINT.md", "**/*.tsbuildinfo",
|
|
140
|
+
];
|
|
141
|
+
function generatedEvidence(p) {
|
|
142
|
+
return /^artifacts\/.*\.(png|jpe?g|webp|mp4|webm|zip|log|json)$/i.test(p);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function sha256(s) { return crypto.createHash("sha256").update(s).digest("hex"); }
|
|
146
|
+
function exists(p) { try { return fs.statSync(p).isFile(); } catch { return false; } }
|
|
147
|
+
|
|
148
|
+
function rel(p, root) { return path.relative(root, p).split(path.sep).join("/"); }
|
|
149
|
+
function isExcluded(relPath, excludes) {
|
|
150
|
+
const parts = relPath.split("/");
|
|
151
|
+
const last = parts[parts.length - 1];
|
|
152
|
+
for (const e of excludes) {
|
|
153
|
+
// a bare name (no slash) excludes the path if ANY segment equals it (dir) or basename equals it
|
|
154
|
+
if (!e.startsWith("**/")) {
|
|
155
|
+
if (parts.includes(e) || last === e) return true;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const tail = e.slice(3); // strip "**/"
|
|
159
|
+
if (tail.startsWith("*.")) { if (last.endsWith(tail.slice(1))) return true; }
|
|
160
|
+
else if (last === tail) return true;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function git(root, args) {
|
|
166
|
+
const r = spawnSync("git", ["-C", root, ...args], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
|
|
167
|
+
return { ok: r.status === 0, out: (r.stdout || "").toString(), code: r.status };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- Identity (§4): key by canonical repo/worktree identity AND run id.
|
|
171
|
+
// Git: worktree root + HEAD. Non-git: keyed by ABSOLUTE path (never group all non-git together).
|
|
172
|
+
export function projectIdentity(root) {
|
|
173
|
+
const abs = path.resolve(root);
|
|
174
|
+
const g = git(abs, ["rev-parse", "--show-toplevel"]);
|
|
175
|
+
if (g.ok && g.out.trim()) {
|
|
176
|
+
const top = path.resolve(g.out.trim());
|
|
177
|
+
const head = git(top, ["rev-parse", "HEAD"]);
|
|
178
|
+
const inside = git(top, ["rev-parse", "--is-inside-work-tree"]);
|
|
179
|
+
if (inside.ok && inside.out.trim() === "true") {
|
|
180
|
+
return { kind: "git", id: `git:${top}`, root: top, head: head.ok ? head.out.trim() : null };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Non-git / git-dir-less: stable id from absolute path so two dirs never share state.
|
|
184
|
+
return { kind: "dir", id: `dir:${abs}`, root: abs, head: null };
|
|
185
|
+
}
|
|
186
|
+
export function stateKey(identity, runId) {
|
|
187
|
+
return sha256(`${identity.id}\u0000${runId}`).slice(0, 32);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function routingFailure(detail, code = "ROUTING_STORE_ERROR") {
|
|
191
|
+
return Object.assign(new Error(detail), { code });
|
|
192
|
+
}
|
|
193
|
+
export async function withStoreRoutingLock(dir, callback) {
|
|
194
|
+
if (!dir) throw routingFailure("state directory unavailable");
|
|
195
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
196
|
+
const file = path.join(dir, "ROUTING.lock"), token = crypto.randomUUID();
|
|
197
|
+
let acquired = false;
|
|
198
|
+
for (let attempt = 0; attempt < 200; attempt++) {
|
|
199
|
+
try {
|
|
200
|
+
const fd = fs.openSync(file, "wx");
|
|
201
|
+
try { fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token, at: Date.now() })); acquired = true; }
|
|
202
|
+
finally { fs.closeSync(fd); }
|
|
203
|
+
break;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error.code !== "EEXIST") throw routingFailure(`routing lock unavailable (${error.code || error.name})`);
|
|
206
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (!acquired) throw routingFailure("another host owns the routing transaction or its lock needs recovery; no lifecycle mutation was started. Retry after that host finishes; preserve an abandoned lock for inspected recovery.", "ROUTING_BUSY");
|
|
210
|
+
try {
|
|
211
|
+
return await callback();
|
|
212
|
+
} finally {
|
|
213
|
+
try { if (JSON.parse(fs.readFileSync(file, "utf8")).token === token) fs.unlinkSync(file); } catch {}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---- Append-only store with an append-only event log + locks (§4).
|
|
218
|
+
export class Store {
|
|
219
|
+
constructor(dir) { this.dir = dir; }
|
|
220
|
+
keyPath(key) {
|
|
221
|
+
const p = path.join(this.dir, "state", key);
|
|
222
|
+
fs.mkdirSync(p, { recursive: true });
|
|
223
|
+
return p;
|
|
224
|
+
}
|
|
225
|
+
_file(key, name) { return path.join(this.keyPath(key), name); }
|
|
226
|
+
readJSON(key, name) {
|
|
227
|
+
try { return JSON.parse(fs.readFileSync(this._file(key, name), "utf8")); } catch { return null; }
|
|
228
|
+
}
|
|
229
|
+
// Canonical lifecycle reads distinguish absent records from damaged storage.
|
|
230
|
+
// Diagnostic readJSON's tolerant fallback is inappropriate for mutations.
|
|
231
|
+
readRun(key) {
|
|
232
|
+
let text;
|
|
233
|
+
try { text = fs.readFileSync(path.join(this.dir, "state", key, "run.json"), "utf8"); }
|
|
234
|
+
catch (error) { return { error: error.code === "ENOENT" ? "NO_RUN" : "STATE_READ_ERROR", detail: error.code || error.message }; }
|
|
235
|
+
try {
|
|
236
|
+
const run = JSON.parse(text);
|
|
237
|
+
if (!run || typeof run !== "object" || Array.isArray(run)) throw new Error("expected a canonical run object");
|
|
238
|
+
return { run };
|
|
239
|
+
} catch (error) { return { error: "STATE_CORRUPT", detail: error.message }; }
|
|
240
|
+
}
|
|
241
|
+
// Atomic write: tmp + rename. Never a destructive reset.
|
|
242
|
+
writeJSON(key, name, value) {
|
|
243
|
+
const final = this._file(key, name);
|
|
244
|
+
const tmp = `${final}.${process.pid}.tmp`;
|
|
245
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
|
|
246
|
+
fs.renameSync(tmp, final);
|
|
247
|
+
return final;
|
|
248
|
+
}
|
|
249
|
+
// Append-only event log with dedup. Returns true if newly appended, false if duplicate.
|
|
250
|
+
appendEvent(key, event) {
|
|
251
|
+
const log = this._file(key, "events.log");
|
|
252
|
+
let seen = [];
|
|
253
|
+
try { seen = fs.readFileSync(log, "utf8").split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l).dedupeKey; } catch { return null; } }); } catch {}
|
|
254
|
+
if (event.dedupeKey && seen.includes(event.dedupeKey)) return false;
|
|
255
|
+
fs.appendFileSync(log, JSON.stringify(event) + "\n");
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
// Non-blocking writer lock (acquire/release), never held across model/test/subprocess I/O.
|
|
259
|
+
tryLock(key, token) {
|
|
260
|
+
const lock = path.join(this.keyPath(key), "WRITER.lock");
|
|
261
|
+
try {
|
|
262
|
+
if (fs.existsSync(lock)) {
|
|
263
|
+
const cur = fs.readFileSync(lock, "utf8");
|
|
264
|
+
let info = {}; try { info = JSON.parse(cur); } catch {}
|
|
265
|
+
// stale if pid gone
|
|
266
|
+
if (info.pid && !procAlive(info.pid)) { try { fs.rmSync(lock); } catch {} }
|
|
267
|
+
}
|
|
268
|
+
const fd = fs.openSync(lock, "wx");
|
|
269
|
+
fs.writeSync(fd, JSON.stringify({ token, pid: process.pid, at: Date.now() }));
|
|
270
|
+
fs.closeSync(fd);
|
|
271
|
+
return true;
|
|
272
|
+
} catch (e) { if (e && e.code === "EEXIST") return false; throw e; }
|
|
273
|
+
}
|
|
274
|
+
releaseLock(key) { try { fs.rmSync(path.join(this.keyPath(key), "WRITER.lock")); } catch {} }
|
|
275
|
+
whoHoldsLock(key) { try { return JSON.parse(fs.readFileSync(path.join(this.keyPath(key), "WRITER.lock"), "utf8")); } catch { return null; } }
|
|
276
|
+
// Re-read under the short writer lock, so a checkpoint/pause cannot be overwritten
|
|
277
|
+
// by a verifier's pre-subprocess snapshot. Callbacks must be synchronous.
|
|
278
|
+
mutate(key, callback) {
|
|
279
|
+
const token = crypto.randomUUID();
|
|
280
|
+
if (!this.tryLock(key, token)) return { error: "STATE_BUSY", detail: "another writer is committing; retry the same operation" };
|
|
281
|
+
try {
|
|
282
|
+
const found = this.readRun(key);
|
|
283
|
+
if (found.error) return found;
|
|
284
|
+
const { run } = found;
|
|
285
|
+
const result = callback(run);
|
|
286
|
+
if (!result?.error) this.writeJSON(key, "run.json", run);
|
|
287
|
+
return result;
|
|
288
|
+
} finally { this.releaseLock(key); }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function procAlive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } }
|
|
292
|
+
|
|
293
|
+
// ---- Source fingerprint (§7): tracked + untracked + deletions/renames.
|
|
294
|
+
// Returns {hash, manifest}. Deletions are recorded as `!DELETED:<rel>`.
|
|
295
|
+
export function sourceFingerprint(root, opts = {}) {
|
|
296
|
+
const legacy = opts.schemaVersion === 1;
|
|
297
|
+
const excludes = [...(legacy ? LEGACY_EXCLUDES : DEFAULT_EXCLUDES), ...(opts.extraExcludes || [])];
|
|
298
|
+
const excluded = (p) => isExcluded(p, excludes) || (!legacy && generatedEvidence(p));
|
|
299
|
+
const rootAbs = path.resolve(root);
|
|
300
|
+
const entries = [];
|
|
301
|
+
const id = projectIdentity(rootAbs);
|
|
302
|
+
if (id.kind === "git" && id.head) {
|
|
303
|
+
const tracked = git(id.root, ["ls-files"]);
|
|
304
|
+
const untracked = git(id.root, ["ls-files", "--others", "--exclude-standard"]);
|
|
305
|
+
const trackedFiles = tracked.ok ? tracked.out.split("\n").filter(Boolean).map((f) => path.join(id.root, f)) : [];
|
|
306
|
+
const untrackedFiles = untracked.ok ? untracked.out.split("\n").filter(Boolean).map((f) => path.join(id.root, f)) : [];
|
|
307
|
+
for (const f of [...trackedFiles, ...untrackedFiles]) {
|
|
308
|
+
const r = rel(f, id.root);
|
|
309
|
+
if (excluded(r)) continue;
|
|
310
|
+
if (exists(f)) entries.push(`${r}:${sha256(fs.readFileSync(f))}`);
|
|
311
|
+
else entries.push(`!DELETED:${r}`);
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
// Non-git: bounded deterministic walk, exclude listed patterns + hidden caches.
|
|
315
|
+
const stack = [rootAbs]; const visited = new Set();
|
|
316
|
+
let budget = 20000;
|
|
317
|
+
while (stack.length && budget-- > 0) {
|
|
318
|
+
const d = stack.pop();
|
|
319
|
+
let items; try { items = fs.readdirSync(d, { withFileTypes: true }); } catch { continue; }
|
|
320
|
+
for (const it of items) {
|
|
321
|
+
const full = path.join(d, it.name);
|
|
322
|
+
const r = rel(full, rootAbs);
|
|
323
|
+
if (excluded(r)) continue;
|
|
324
|
+
if (it.isDirectory()) { if (!visited.has(full)) { visited.add(full); stack.push(full); } }
|
|
325
|
+
else if (it.isFile()) { if (exists(full)) entries.push(`${r}:${sha256(fs.readFileSync(full))}`); }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
entries.sort();
|
|
330
|
+
return { hash: sha256(entries.join("\n")), schemaVersion: legacy ? 1 : SOURCE_FINGERPRINT_SCHEMA, count: entries.length, kind: id.kind, head: id.head, ...(!legacy ? { legacyHash: sourceFingerprint(root, { ...opts, schemaVersion: 1 }).hash } : {}) };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---- Contract + loss (§5)
|
|
334
|
+
// contract = { criteria:[{id, required, checks:[checkId], weight, status, evidenceFingerprint}],
|
|
335
|
+
// gates:[{id, required, status}], evaluator?:{...}, revision }
|
|
336
|
+
export function defaultLoss(contract) {
|
|
337
|
+
const req = (contract.criteria || []).filter((c) => c.required);
|
|
338
|
+
if (req.length === 0) return { error: "empty_required_set" };
|
|
339
|
+
const denom = req.reduce((s, c) => s + (c.weight ?? 1), 0);
|
|
340
|
+
if (!(denom > 0)) return { error: "zero_denominator" };
|
|
341
|
+
// Missing/stale/unverified evidence counts as unsatisfied (numerator).
|
|
342
|
+
const num = req.reduce((s, c) => s + (statusSatisfies(c.status) ? 0 : (c.weight ?? 1)), 0);
|
|
343
|
+
const loss = num / denom;
|
|
344
|
+
if (!Number.isFinite(loss)) return { error: "non_finite" };
|
|
345
|
+
return { loss, target: 0, num, denom };
|
|
346
|
+
}
|
|
347
|
+
export function evaluateEvaluatorResult(result) {
|
|
348
|
+
// Invalid / missing / non-finite / out-of-domain => evaluator error, never a good score.
|
|
349
|
+
if (!result || typeof result !== "object") return { error: "invalid_evaluator_output" };
|
|
350
|
+
const v = result.value;
|
|
351
|
+
if (typeof v !== "number" || !Number.isFinite(v) || Number.isNaN(v)) return { error: "non_finite_or_missing_value" };
|
|
352
|
+
if (typeof result.direction === "number") { /* domain bound supplied by evaluator */ }
|
|
353
|
+
return { ok: true, value: v, schemaValid: !!result.schemaValid };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Contract-only callers (without a receipt store or check catalogue) can evaluate explicit
|
|
357
|
+
// declarations. Tracked runs NEVER use cached criterion/gate statuses as evidence.
|
|
358
|
+
function declarationOnly(run) { return !Array.isArray(run.receipts) && !run.checkCatalogue; }
|
|
359
|
+
function currentHash(fp) { return typeof fp === "object" && fp ? fp.hash : fp; }
|
|
360
|
+
|
|
361
|
+
// Operator review is independent of check success. It is an audited workflow
|
|
362
|
+
// boundary, not an OS sandbox against actors with arbitrary state-file access.
|
|
363
|
+
export function completionReviewBasis(run, currentFingerprint = run.sourceFingerprint) {
|
|
364
|
+
// A recorded budget amendment changes the effective limits, so it is part of the basis. Runs with
|
|
365
|
+
// no amendments hash EXACTLY as before (the extra key is omitted), so historical reviews are not
|
|
366
|
+
// invalidated merely by upgrading the harness.
|
|
367
|
+
const amendments = Array.isArray(run.budgetAmendments) && run.budgetAmendments.length ? { budgetAmendments: run.budgetAmendments } : {};
|
|
368
|
+
return sha256(JSON.stringify({ runId: run.runId, directory: run.directory,
|
|
369
|
+
originalRequest: run.originalRequest, createdAt: run.createdAt, budget: run.budget,
|
|
370
|
+
...amendments,
|
|
371
|
+
contract: run.contract, contractHash: run.contractHash, evaluatorHash: run.evaluatorHash,
|
|
372
|
+
checkCatalogue: run.checkCatalogue, sourceFingerprint: currentHash(currentFingerprint),
|
|
373
|
+
receipts: run.receipts, evidence: run.evidence, candidates: run.state?.candidates,
|
|
374
|
+
verificationMs: run.execution?.verificationMs, commandAttempts: run.execution?.commandAttempts }));
|
|
375
|
+
}
|
|
376
|
+
export function completionReviewStatus(run, currentFingerprint = run.sourceFingerprint) {
|
|
377
|
+
if (declarationOnly(run)) return { required: false, status: "NOT_APPLICABLE", basis: null };
|
|
378
|
+
const basis = completionReviewBasis(run, currentFingerprint);
|
|
379
|
+
const latest = Array.isArray(run.completionReviews) ? run.completionReviews.at(-1) : null;
|
|
380
|
+
const valid = latest?.schemaVersion === 1 && latest?.source === "operator_cli" &&
|
|
381
|
+
typeof latest.id === "string" && typeof latest.reason === "string" &&
|
|
382
|
+
["accept", "reject"].includes(latest.verdict) && Number.isFinite(latest.at);
|
|
383
|
+
const status = !valid ? "REQUIRED" : latest.basis !== basis ? "STALE" : latest.verdict === "accept" ? "ACCEPTED" : "REJECTED";
|
|
384
|
+
return { required: true, status, basis, reviewId: valid ? latest.id : null,
|
|
385
|
+
reason: valid ? latest.reason : null,
|
|
386
|
+
guidance: "Green checks alone do not authorize completion. Pause for an independent operator review. The installed maintenance review command records it; model checkpoints and complete arguments cannot grant approval." };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Caller holds the writer lock. All refusals precede mutation/archive writes.
|
|
390
|
+
// Archive is required before rejecting a recorded COMPLETE; normal pause/resume
|
|
391
|
+
// remain unable to reopen terminal records.
|
|
392
|
+
export function recordCompletionReview(run, { verdict, reason, expectedBasis, reviewId,
|
|
393
|
+
currentFingerprint, archive, now = Date.now() } = {}) {
|
|
394
|
+
if (!["accept", "reject"].includes(verdict) || typeof reason !== "string" || !reason.trim() || reason.length > 4000 ||
|
|
395
|
+
typeof reviewId !== "string" || !/^[a-zA-Z0-9_-]{8,80}$/.test(reviewId) ||
|
|
396
|
+
typeof expectedBasis !== "string" || !/^[a-f0-9]{64}$/.test(expectedBasis) || !currentHash(currentFingerprint))
|
|
397
|
+
return { error: "INVALID_COMPLETION_REVIEW" };
|
|
398
|
+
if (run.completionReviews !== undefined && !Array.isArray(run.completionReviews)) return { error: "INVALID_REVIEW_HISTORY" };
|
|
399
|
+
const history = run.completionReviews || [];
|
|
400
|
+
if (history.some(r => !r || typeof r !== "object" || r.schemaVersion !== 1 || typeof r.id !== "string")) return { error: "INVALID_REVIEW_HISTORY" };
|
|
401
|
+
if (run.status === "CANCELLED") return { error: "RUN_CANCELLED", state: run.status };
|
|
402
|
+
if (run.execution?.inFlight) return { error: "VERIFY_IN_FLIGHT" };
|
|
403
|
+
const prior = history.find(r => r.id === reviewId);
|
|
404
|
+
if (prior) {
|
|
405
|
+
if (prior.basis !== expectedBasis || prior.verdict !== verdict || prior.reason !== reason.trim()) return { error: "REVIEW_ID_CONFLICT" };
|
|
406
|
+
if (completionReviewBasis(run, currentFingerprint) !== expectedBasis) return { error: "REVIEW_BASIS_CHANGED" };
|
|
407
|
+
return { ok: true, alreadyRecorded: true, reviewId, state: run.status, completionReview: completionReviewStatus(run, currentFingerprint) };
|
|
408
|
+
}
|
|
409
|
+
if (run.status !== "PAUSED" && !(verdict === "reject" && run.status === "COMPLETE"))
|
|
410
|
+
return { error: "REVIEW_REQUIRES_PAUSE", state: run.status };
|
|
411
|
+
const basis = completionReviewBasis(run, currentFingerprint);
|
|
412
|
+
if (basis !== expectedBasis) return { error: "REVIEW_BASIS_CHANGED", currentBasis: basis };
|
|
413
|
+
if (verdict === "accept" && !deriveRunView(run, { currentFingerprint }).declaredChecksReady)
|
|
414
|
+
return { error: "REVIEW_CHECKS_NOT_READY" };
|
|
415
|
+
const previousState = run.status;
|
|
416
|
+
const before = JSON.stringify(run, null, 2);
|
|
417
|
+
let archivedSnapshot = null;
|
|
418
|
+
if (previousState === "COMPLETE") {
|
|
419
|
+
if (typeof archive !== "function") return { error: "REVIEW_ARCHIVE_REQUIRED" };
|
|
420
|
+
try { archivedSnapshot = archive(JSON.parse(before), reviewId); }
|
|
421
|
+
catch (e) { return { error: "REVIEW_ARCHIVE_FAILED", detail: e.code || e.message }; }
|
|
422
|
+
if (typeof archivedSnapshot !== "string" || !archivedSnapshot) return { error: "REVIEW_ARCHIVE_FAILED" };
|
|
423
|
+
}
|
|
424
|
+
const record = { schemaVersion: 1, id: reviewId, at: now, source: "operator_cli", verdict,
|
|
425
|
+
reason: reason.trim(), basis, sourceFingerprint: currentHash(currentFingerprint),
|
|
426
|
+
previousState, priorRunHash: sha256(before), archivedSnapshot };
|
|
427
|
+
run.completionReviews = [...history, record];
|
|
428
|
+
run.status = "PAUSED"; run.autoEnabled = false;
|
|
429
|
+
run.controlGeneration = (run.controlGeneration || 0) + 1;
|
|
430
|
+
return { ok: true, state: run.status, reviewId, verdict, archivedSnapshot,
|
|
431
|
+
completionReview: completionReviewStatus(run, currentFingerprint) };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export async function operatorCompletionReview(store, key, args) {
|
|
435
|
+
return withStoreRoutingLock(store.dir, () => store.mutate(key, run => {
|
|
436
|
+
if (!args.directory || !args.runId || run.runId !== args.runId || !run.directory ||
|
|
437
|
+
projectIdentity(args.directory).root !== projectIdentity(run.directory).root)
|
|
438
|
+
return { error: "REVIEW_RUN_MISMATCH" };
|
|
439
|
+
// Rejecting COMPLETE reopens a project slot. Serialize against native start
|
|
440
|
+
// and refuse if a later, different task already occupies that slot.
|
|
441
|
+
if (run.status === "COMPLETE" && args.verdict === "reject") {
|
|
442
|
+
for (const name of fs.readdirSync(path.join(store.dir, "state"))) {
|
|
443
|
+
if (name === key || !/^[a-f0-9]{32}$/.test(name)) continue;
|
|
444
|
+
const other = store.readRun(name);
|
|
445
|
+
if (other.error) return other;
|
|
446
|
+
if (other.run.directory && projectIdentity(other.run.directory).root === projectIdentity(args.directory).root &&
|
|
447
|
+
!RUN_TERMINAL_STATES.includes(other.run.status))
|
|
448
|
+
return { error: "EXISTING_RUN", runId: other.run.runId, detail: "Another task already occupies this project; no terminal record was reopened." };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return recordCompletionReview(run, { ...args, currentFingerprint: sourceFingerprint(args.directory),
|
|
452
|
+
archive: (snapshot, id) => {
|
|
453
|
+
const file = path.join(store.keyPath(key), `completion-review-${id}.json`), bytes = JSON.stringify(snapshot, null, 2);
|
|
454
|
+
try { fs.writeFileSync(file, bytes, { flag: "wx" }); }
|
|
455
|
+
catch (e) { if (e.code !== "EEXIST" || fs.readFileSync(file, "utf8") !== bytes) throw e; }
|
|
456
|
+
return file;
|
|
457
|
+
} });
|
|
458
|
+
}));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---- v1.2.20 audited operator-only budget amendment -----------------------------------------
|
|
462
|
+
// A finite additional candidate allowance and/or a new ABSOLUTE deadline granted after the
|
|
463
|
+
// original limits were exhausted/expired. The original budget, deadline, usage, receipts, contract
|
|
464
|
+
// and failure history are preserved byte-for-byte; only an append-only amendment record plus a
|
|
465
|
+
// control-generation increment are added. It NEVER resumes a run and NEVER implies acceptance.
|
|
466
|
+
// Reachable ONLY through the installed maintenance CLI (not RUN_ACTIONS / ACTION_PARAMS), so the
|
|
467
|
+
// native model tool surface has no amendment action. Like the operator completion review this is an
|
|
468
|
+
// auditable workflow boundary, not an OS sandbox against arbitrary state-file access.
|
|
469
|
+
export const AMENDMENT_SCHEMA_VERSION = 1;
|
|
470
|
+
const AMENDMENT_MAX_ADDITIONAL = 1000;
|
|
471
|
+
const AMENDMENT_ID_RE = /^[a-zA-Z0-9_-]{8,80}$/;
|
|
472
|
+
const HEX64_RE = /^[a-f0-9]{64}$/;
|
|
473
|
+
|
|
474
|
+
// Original vs effective limits, for status/readouts and for the operator preparing a grant.
|
|
475
|
+
export function budgetAmendmentStatus(run, now = Date.now()) {
|
|
476
|
+
const original = run.budget || defaultBudget();
|
|
477
|
+
const baseIterations = Number.isFinite(original.iterations) ? original.iterations : defaultBudget().iterations;
|
|
478
|
+
const t = EXEC.timing(run, now);
|
|
479
|
+
const originalDeadlineAt = EXEC.originalTiming(run, now).deadlineAt;
|
|
480
|
+
const list = Array.isArray(run.budgetAmendments) ? run.budgetAmendments : [];
|
|
481
|
+
return {
|
|
482
|
+
schemaVersion: AMENDMENT_SCHEMA_VERSION,
|
|
483
|
+
count: list.length,
|
|
484
|
+
original: {
|
|
485
|
+
iterations: baseIterations, deadlineAt: originalDeadlineAt,
|
|
486
|
+
deadlineSeconds: Number.isFinite(original.deadlineSeconds) ? original.deadlineSeconds : null,
|
|
487
|
+
activeSeconds: Number.isFinite(original.activeSeconds) ? original.activeSeconds : null,
|
|
488
|
+
toolActionCap: Number.isFinite(original.toolActionCap) ? original.toolActionCap : null,
|
|
489
|
+
},
|
|
490
|
+
additionalCandidates: EXEC.effectiveIterations(run) - baseIterations,
|
|
491
|
+
effective: { iterations: EXEC.effectiveIterations(run), deadlineAt: t.deadlineAt },
|
|
492
|
+
amendments: list,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Caller holds the run writer lock. Every refusal precedes any mutation.
|
|
497
|
+
export function applyBudgetAmendment(run, { amendmentId, additionalCandidates, newDeadlineAt, authorization, reason,
|
|
498
|
+
expectedRevision, expectedBasis, now = Date.now() } = {}) {
|
|
499
|
+
if (typeof amendmentId !== "string" || !AMENDMENT_ID_RE.test(amendmentId))
|
|
500
|
+
return { error: "INVALID_AMENDMENT", detail: "amendmentId must be 8..80 characters of [A-Za-z0-9_-]" };
|
|
501
|
+
if (!Number.isInteger(additionalCandidates) || additionalCandidates < 1 || additionalCandidates > AMENDMENT_MAX_ADDITIONAL)
|
|
502
|
+
return { error: "INVALID_AMENDMENT", detail: `additionalCandidates must be an integer 1..${AMENDMENT_MAX_ADDITIONAL}` };
|
|
503
|
+
if (!Number.isFinite(newDeadlineAt) || Math.abs(newDeadlineAt) > 8640000000000000)
|
|
504
|
+
return { error: "INVALID_AMENDMENT", detail: "newDeadlineAt must be a valid absolute epoch-ms timestamp" };
|
|
505
|
+
if (typeof authorization !== "string" || !authorization.trim() || authorization.length > 4000)
|
|
506
|
+
return { error: "INVALID_AMENDMENT", detail: "explicit authorization text is required (<=4000 chars)" };
|
|
507
|
+
if (typeof reason !== "string" || !reason.trim() || reason.length > 4000)
|
|
508
|
+
return { error: "INVALID_AMENDMENT", detail: "a reason is required (<=4000 chars)" };
|
|
509
|
+
if (!Number.isInteger(expectedRevision))
|
|
510
|
+
return { error: "INVALID_AMENDMENT", detail: "expectedRevision (current controlGeneration) is required" };
|
|
511
|
+
if (typeof expectedBasis !== "string" || !HEX64_RE.test(expectedBasis))
|
|
512
|
+
return { error: "INVALID_AMENDMENT", detail: "expectedBasis (current canonical basis) is required" };
|
|
513
|
+
if (run.budgetAmendments !== undefined && !Array.isArray(run.budgetAmendments)) return { error: "INVALID_AMENDMENT_HISTORY" };
|
|
514
|
+
const history = run.budgetAmendments || [];
|
|
515
|
+
if (history.some(a => !a || typeof a !== "object" || a.schemaVersion !== AMENDMENT_SCHEMA_VERSION || typeof a.id !== "string"))
|
|
516
|
+
return { error: "INVALID_AMENDMENT_HISTORY" };
|
|
517
|
+
if (RUN_TERMINAL_STATES.includes(run.status)) return { error: `RUN_${run.status}`, state: run.status };
|
|
518
|
+
if (run.status !== "PAUSED") return { error: "AMENDMENT_REQUIRES_PAUSE", state: run.status };
|
|
519
|
+
if (run.execution?.inFlight) return { error: "VERIFY_IN_FLIGHT" };
|
|
520
|
+
const auth = authorization.trim(), why = reason.trim();
|
|
521
|
+
const prior = history.find(a => a.id === amendmentId);
|
|
522
|
+
if (prior) {
|
|
523
|
+
// An exact repeat is an idempotent no-op; the same id with different content is a conflict.
|
|
524
|
+
if (prior.additionalCandidates !== additionalCandidates || prior.newDeadlineAt !== newDeadlineAt ||
|
|
525
|
+
prior.authorization !== auth || prior.reason !== why)
|
|
526
|
+
return { error: "AMENDMENT_ID_CONFLICT", detail: "this amendmentId is already recorded with different content" };
|
|
527
|
+
return { ok: true, alreadyApplied: true, amendmentId, state: run.status, budgetLimit: budgetAmendmentStatus(run, now) };
|
|
528
|
+
}
|
|
529
|
+
const basis = completionReviewBasis(run, run.sourceFingerprint);
|
|
530
|
+
if (basis !== expectedBasis) return { error: "AMENDMENT_BASIS_CHANGED", currentBasis: basis };
|
|
531
|
+
const revision = run.controlGeneration || 0;
|
|
532
|
+
if (revision !== expectedRevision) return { error: "AMENDMENT_REVISION_CHANGED", currentRevision: revision };
|
|
533
|
+
const t = EXEC.timing(run, now);
|
|
534
|
+
const currentEffective = t.deadlineAt;
|
|
535
|
+
const originalDeadlineAt = EXEC.originalTiming(run, now).deadlineAt;
|
|
536
|
+
if (!(newDeadlineAt > now)) return { error: "AMENDMENT_DEADLINE_NOT_FUTURE", detail: new Date(newDeadlineAt).toISOString() };
|
|
537
|
+
if (currentEffective !== null && !(newDeadlineAt > currentEffective))
|
|
538
|
+
return { error: "AMENDMENT_DEADLINE_NOT_EXTENDING", currentDeadlineAt: currentEffective };
|
|
539
|
+
const record = {
|
|
540
|
+
schemaVersion: AMENDMENT_SCHEMA_VERSION, id: amendmentId, at: now, source: "operator_cli",
|
|
541
|
+
additionalCandidates, authorization: auth, reason: why,
|
|
542
|
+
originalBudget: {
|
|
543
|
+
iterations: Number.isFinite(run.budget?.iterations) ? run.budget.iterations : defaultBudget().iterations,
|
|
544
|
+
deadlineSeconds: run.budget?.deadlineSeconds ?? null, activeSeconds: run.budget?.activeSeconds ?? null,
|
|
545
|
+
toolActionCap: run.budget?.toolActionCap ?? null,
|
|
546
|
+
},
|
|
547
|
+
originalDeadlineAt, previousEffectiveCandidates: EXEC.effectiveIterations(run),
|
|
548
|
+
previousEffectiveDeadlineAt: currentEffective, newDeadlineAt,
|
|
549
|
+
effectiveCandidatesAfter: EXEC.effectiveIterations(run) + additionalCandidates,
|
|
550
|
+
expectedRevision: revision, priorRunHash: sha256(JSON.stringify(run, null, 2)),
|
|
551
|
+
};
|
|
552
|
+
run.budgetAmendments = [...history, record];
|
|
553
|
+
run.controlGeneration = revision + 1;
|
|
554
|
+
return { ok: true, state: run.status, amendmentId, amendment: record, budgetLimit: budgetAmendmentStatus(run, now) };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export async function operatorBudgetAmendment(store, key, args = {}) {
|
|
558
|
+
return withStoreRoutingLock(store.dir, () => store.mutate(key, run => {
|
|
559
|
+
if (!args.directory || !args.runId || run.runId !== args.runId || !run.directory ||
|
|
560
|
+
projectIdentity(args.directory).root !== projectIdentity(run.directory).root)
|
|
561
|
+
return { error: "AMENDMENT_RUN_MISMATCH" };
|
|
562
|
+
return applyBudgetAmendment(run, args);
|
|
563
|
+
}));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function effectiveLoss(run, currentFingerprint = run.sourceFingerprint) {
|
|
567
|
+
const criteria = (run.contract?.criteria || []).map(c => ({ ...c,
|
|
568
|
+
status: criterionSatisfied(run, c, currentFingerprint).satisfied ? "PASS" : "FAIL" }));
|
|
569
|
+
return defaultLoss({ ...run.contract, criteria });
|
|
570
|
+
}
|
|
571
|
+
export function effectiveRemaining(run, currentFingerprint = run.sourceFingerprint) {
|
|
572
|
+
return (run.contract?.criteria || []).filter(c => c.required && !criterionSatisfied(run, c, currentFingerprint).satisfied).map(c => c.id);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// The sole lifecycle projection. Pure: no receipt, contract, budget or legacy summary migration.
|
|
576
|
+
export function deriveRunView(run, { currentFingerprint = run.sourceFingerprint, projectMemoryStatus = null, now = Date.now() } = {}) {
|
|
577
|
+
const criteria = (run.contract?.criteria || []).map(c => ({ id: c.id, required: !!c.required,
|
|
578
|
+
checks: c.checks || [], needs: c.evidenceClass || null, ...criterionSatisfied(run, c, currentFingerprint) }));
|
|
579
|
+
const checks = [...new Set([...Object.keys(run.checkCatalogue || {}),
|
|
580
|
+
...(run.contract?.criteria || []).flatMap(c => c.checks || []),
|
|
581
|
+
...(run.contract?.gates || []).flatMap(g => g.checks?.length ? g.checks : [g.id])])]
|
|
582
|
+
.map(id => checkDiagnostics(run, id, currentFingerprint));
|
|
583
|
+
const gates = gateStatuses(run, currentFingerprint);
|
|
584
|
+
const hardGateBlockers = gates.filter(g => !g.satisfied);
|
|
585
|
+
const remaining = criteria.filter(c => c.required && !c.satisfied).map(c => c.id);
|
|
586
|
+
const ev = evidenceStatus(run, currentFingerprint), loss = effectiveLoss(run, currentFingerprint);
|
|
587
|
+
// Replay receipt prefixes using their recorded identities, under the CURRENT contract and
|
|
588
|
+
// evaluator. Cached lossBefore/lossAfter/best/current are not qualifying evidence.
|
|
589
|
+
const history = [];
|
|
590
|
+
const prefix = [];
|
|
591
|
+
for (const r of run.receipts || []) {
|
|
592
|
+
prefix.push(r);
|
|
593
|
+
if (!isProjectReceipt(r) || !r.sourceFingerprint) continue;
|
|
594
|
+
const historicalFp = { hash: r.sourceFingerprint, legacyHash: r.sourceFingerprint, schemaVersion: r.fingerprintSchemaVersion || 1 };
|
|
595
|
+
const l = effectiveLoss({ ...run, receipts: prefix }, historicalFp);
|
|
596
|
+
if (!l.error) history.push({ loss: l.loss, receiptId: receiptId(run, r) });
|
|
597
|
+
}
|
|
598
|
+
const currentLoss = loss.loss ?? null;
|
|
599
|
+
const values = [...history.map(x => x.loss), currentLoss].filter(Number.isFinite);
|
|
600
|
+
const bestLoss = values.length ? Math.min(...values) : null;
|
|
601
|
+
const targetLoss = run.contract?.lossTarget ?? 0;
|
|
602
|
+
let blockReason = null;
|
|
603
|
+
if (hardGateBlockers.length) blockReason = hardGateBlockers.every(g => g.kind === "STALE_EVIDENCE") ? "stale_evidence" : "hard_gates_failed";
|
|
604
|
+
else if (remaining.length) blockReason = "required_unverified";
|
|
605
|
+
else if (loss.error) blockReason = "loss_error";
|
|
606
|
+
else if (!(currentLoss <= targetLoss + 1e-9)) blockReason = "loss_above_target";
|
|
607
|
+
else if (run.faults?.length) blockReason = "controller_fault";
|
|
608
|
+
const declaredChecksReady = blockReason === null;
|
|
609
|
+
const completionReview = completionReviewStatus(run, currentFingerprint);
|
|
610
|
+
if (!blockReason && completionReview.required && completionReview.status !== "ACCEPTED")
|
|
611
|
+
blockReason = `completion_review_${completionReview.status.toLowerCase()}`;
|
|
612
|
+
const memory = projectMemoryStatus || { status: "UNKNOWN", reasons: ["project memory was not supplied"], perNode: {} };
|
|
613
|
+
return { state: run.status, runId: run.runId || null, currentFingerprint: currentHash(currentFingerprint) || null,
|
|
614
|
+
currentLoss, loss: currentLoss, bestLoss, targetLoss, lossError: loss.error || null,
|
|
615
|
+
criterionStates: criteria, checks, gates, hardGateStates: gates, hardGateBlockers,
|
|
616
|
+
remaining, outstanding: criteria.filter(c => c.required && !c.satisfied),
|
|
617
|
+
evidenceGaps: ev.gaps, failures: ev.failures, staleEvidence: checks.filter(c => c.staleReason),
|
|
618
|
+
completionBlocked: blockReason !== null, blockReason, declaredChecksReady, completionReview,
|
|
619
|
+
candidateCount: EV.candidateCount(run), candidates: `${EV.candidateCount(run)}/${EXEC.effectiveIterations(run)}`,
|
|
620
|
+
budgets: run.budget || defaultBudget(), budgetLimit: budgetAmendmentStatus(run, now),
|
|
621
|
+
controlRevision: run.controlGeneration || 0,
|
|
622
|
+
// Binds the CANONICAL RECORD as stored (its own last-verified source fingerprint), not the live
|
|
623
|
+
// working tree, so an operator can read the basis here and pass it to `amend` unchanged. A
|
|
624
|
+
// working-tree edit is orthogonal to a budget/deadline grant and stays unverified regardless.
|
|
625
|
+
amendmentBasis: declarationOnly(run) ? null : completionReviewBasis(run, run.sourceFingerprint),
|
|
626
|
+
timing: EXEC.timing(run, now), executionUsage: EXEC.usage(run), counters: { iterations: EV.candidateCount(run), activeMs: null,
|
|
627
|
+
verificationMs: EXEC.usage(run).verificationMs, commandAttempts: EXEC.usage(run).commandAttempts,
|
|
628
|
+
activeTimeNote: "total agent active time is not fully observed; verificationMs covers declared checks only",
|
|
629
|
+
noProgressStreak: run.state?.noProgressStreak || 0, sameFailureStreak: run.state?.sameFailureStreak || 0 },
|
|
630
|
+
memoryStatus: memory.status, memory, continuation: false, lifecycleGuidance: LIFECYCLE_GUIDANCE, agentProgress: run.agentProgress || null,
|
|
631
|
+
effectiveReceiptRefs: checks.filter(c => c.effectiveStatus === "PASS").map(c => c.effectiveReceiptId),
|
|
632
|
+
historicalReceiptCount: (run.receipts || []).length,
|
|
633
|
+
legacyCachedLoss: { loss: run.loss ?? null, current: run.state?.current?.loss ?? run.state?.current?.lossAfter ?? null,
|
|
634
|
+
best: run.state?.best?.loss ?? run.state?.best?.lossAfter ?? null },
|
|
635
|
+
bestLossBasis: "current projection and receipt-prefix replay under current contract/evaluator; cached summaries excluded" };
|
|
636
|
+
}
|
|
637
|
+
export function canComplete(run, opts = {}) {
|
|
638
|
+
const view = opts.view || deriveRunView(run, opts);
|
|
639
|
+
return { ...view, complete: !view.completionBlocked, reason: view.blockReason,
|
|
640
|
+
hardFails: view.hardGateBlockers.map(g => g.id), blocking: view.hardGateBlockers,
|
|
641
|
+
reqFails: view.remaining };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Routine tool readouts must not repeat command logs throughout the check table.
|
|
645
|
+
// Keep the canonical view/calculation intact; detailed receipts are read separately.
|
|
646
|
+
export function summarizeRunView(view) {
|
|
647
|
+
const compact = ({ historicalReceipts, effectiveReceipt, ...check }) => check;
|
|
648
|
+
return { ...view, checks: view.checks.map(compact), staleEvidence: view.staleEvidence.map(compact),
|
|
649
|
+
receiptDetails: { action: "receipts", runId: view.runId,
|
|
650
|
+
params: "Optional checkId, offset and limit (1..20) list receipt metadata; receiptId retrieves one receipt with its actual output tail.",
|
|
651
|
+
omittedFromStatus: ["historicalReceipts", "effectiveReceipt"], canonicalHistoryPreserved: true } };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// (v1.2.2) A second, duplicate copy of the canonical run resolver + lifecycle-verification block
|
|
655
|
+
// once lived here and shadowed the single canonical definition near STATES above. Removed: there is
|
|
656
|
+
// now exactly ONE definition of "active run", shared by the native `longrun` tool and `longrun_verify`.
|
|
657
|
+
|
|
658
|
+
// ---- Contract completeness at START (§5, v1.2.1): a required criterion must be SATISFIABLE. ----
|
|
659
|
+
// A required criterion can only ever reach PASS via a receipt from a DECLARED check. A criterion
|
|
660
|
+
// that declares no checks, or checks absent from checkCatalogue, is doomed to a permanent
|
|
661
|
+
// EVIDENCE_GAP (the v1.2.0 commissioning bug: LC-001 had evidenceClass STATIC but no mapped
|
|
662
|
+
// check, so loss could never reach zero). We refuse to create such a run: no fabricated PASS, no
|
|
663
|
+
// criterion that can never be verified. Returns {ok, problems:[{criterionId, missingChecks, reason}]}.
|
|
664
|
+
export function validateStartContract({ criteria = [], checkCatalogue = {} } = {}) {
|
|
665
|
+
const cat = checkCatalogue || {};
|
|
666
|
+
const problems = [];
|
|
667
|
+
for (const c of criteria) {
|
|
668
|
+
if (c.required === false) continue;
|
|
669
|
+
const id = c.id || "(unnamed)";
|
|
670
|
+
const checks = Array.isArray(c.checks) ? c.checks : [];
|
|
671
|
+
if (checks.length === 0) { problems.push({ criterionId: id, missingChecks: ["<none-declared>"], reason: "required criterion has no mapped check" }); continue; }
|
|
672
|
+
const missing = checks.filter((k) => !Array.isArray(cat[k]?.command));
|
|
673
|
+
if (missing.length) problems.push({ criterionId: id, missingChecks: missing, reason: "mapped check(s) absent from checkCatalogue" });
|
|
674
|
+
}
|
|
675
|
+
return { ok: problems.length === 0, problems };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
// ---- Receipts (§7)
|
|
680
|
+
// Only a valid PASS satisfies; ZERO discovered tests cannot satisfy a test-coverage requirement;
|
|
681
|
+
// console optimism is ignored — exit code + discovered counts decide.
|
|
682
|
+
// `fpScope` records WHICH root the source fingerprint was measured against: "project" (the real
|
|
683
|
+
// tracked worktree) or a foreign scope ("fixture"/"copy"/"subprocess" — an isolated temp copy, a
|
|
684
|
+
// negative-control fixture, or a subprocess working dir). Foreign-scope receipts are NEVER allowed
|
|
685
|
+
// to satisfy OR invalidate a project check (they only prove a verifier is live / is sabotaged).
|
|
686
|
+
export function makeReceipt({ checkId, command, exitCode, output = "", testCount, startedAt, finishedAt, versions = {}, contractHash, evaluatorHash, sourceFingerprint, requirementKind, evidenceClass, proxyOnly, fpScope, fingerprintSchemaVersion }) {
|
|
687
|
+
let status = "PASS";
|
|
688
|
+
if (exitCode === "TIMEOUT") status = "TIMEOUT";
|
|
689
|
+
else if (exitCode === "BLOCKED") status = "BLOCKED";
|
|
690
|
+
else if (exitCode === "ERROR") status = "ERROR";
|
|
691
|
+
else if (exitCode !== 0) status = "FAIL";
|
|
692
|
+
// Zero discovered tests cannot satisfy a TEST-COVERAGE requirement. This gate applies ONLY to a
|
|
693
|
+
// declared test requirement; a NON-TEST hard gate (build/typecheck, kind "cmd") is satisfied by a
|
|
694
|
+
// clean exit and must never be forced to produce a test receipt.
|
|
695
|
+
else if (requirementKind === "test" && (typeof testCount !== "number" || testCount === 0)) status = "NOT_RUN";
|
|
696
|
+
const cls = evidenceClass || (proxyOnly ? "STATIC" : undefined);
|
|
697
|
+
return {
|
|
698
|
+
checkId, command, status, exitCode,
|
|
699
|
+
testCount: typeof testCount === "number" ? testCount : null,
|
|
700
|
+
startedAt, finishedAt,
|
|
701
|
+
versions, contractHash, evaluatorHash,
|
|
702
|
+
sourceFingerprint, fingerprintSchemaVersion, fpScope: fpScope || "project",
|
|
703
|
+
evidenceClass: cls, class: cls, proxyOnly: !!proxyOnly || EV.PROXY_ONLY.has(cls),
|
|
704
|
+
// a receipt is only valid if recorded against a still-current fingerprint
|
|
705
|
+
_validateAgainst: (currentFp) => currentFp === sourceFingerprint ? status : "STALE",
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
export function resolveReceiptStatus(receipt, currentFingerprint) {
|
|
709
|
+
return currentFingerprint === receipt.sourceFingerprint ? receipt.status : "STALE";
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ---- Authoritative effective-receipt model (§1/§2, v1.2.3) ----------------------------------
|
|
713
|
+
// ONE definition of "what does this check currently prove", shared by status, the verify readout,
|
|
714
|
+
// replay, completion detection and hard-gate calculation. Rules:
|
|
715
|
+
// * Only PROJECT-scope receipts decide a project check. A fixture/copy/subprocess-scope result
|
|
716
|
+
// never satisfies AND never invalidates the real project (a sabotage run against a copied
|
|
717
|
+
// workspace cannot turn the project's build red, and a healthy-fixture negative control is not
|
|
718
|
+
// positive evidence).
|
|
719
|
+
// * Of the project receipts, the AUTHORITATIVE one is the most recent (by finishedAt, then by
|
|
720
|
+
// insertion order). This is the anti-masking rule: a NEWER failure is not hidden behind an older
|
|
721
|
+
// pass, and a NEWER restore-pass is not hidden behind an older failure.
|
|
722
|
+
// * A PASS only satisfies on CURRENT source. A pass measured against a different (stale) project
|
|
723
|
+
// fingerprint is STALE, never trusted; an old PASS is never kept "just because it says PASS".
|
|
724
|
+
function isProjectReceipt(r) {
|
|
725
|
+
return r && (r.fpScope == null || r.fpScope === "project") && r.mode !== "negative" && r.kind !== "negative_control";
|
|
726
|
+
}
|
|
727
|
+
export function projectReceiptsFor(run, checkId) {
|
|
728
|
+
return (run?.receipts || []).filter(r => r.checkId === checkId && isProjectReceipt(r));
|
|
729
|
+
}
|
|
730
|
+
function receiptId(run, r) {
|
|
731
|
+
return r.receiptId || r.id || `receipt-${(run.receipts || []).indexOf(r) + 1}-${sha256(JSON.stringify(r)).slice(0, 12)}`;
|
|
732
|
+
}
|
|
733
|
+
function receiptEligibility(run, r, fp) {
|
|
734
|
+
if (!isProjectReceipt(r)) return "NON_PROJECT_EVIDENCE";
|
|
735
|
+
if (!r.sourceFingerprint) return "LEGACY_STALE_MISSING_FINGERPRINT";
|
|
736
|
+
if (run.contractHash && r.contractHash !== run.contractHash) return r.contractHash ? "CONTRACT_MISMATCH" : "LEGACY_STALE_MISSING_CONTRACT_HASH";
|
|
737
|
+
if (run.evaluatorHash && r.evaluatorHash !== run.evaluatorHash) return "EVALUATOR_MISMATCH";
|
|
738
|
+
if (r.fingerprintSchemaVersion && ![1, SOURCE_FINGERPRINT_SCHEMA].includes(r.fingerprintSchemaVersion)) return "FINGERPRINT_SCHEMA_INCOMPATIBLE";
|
|
739
|
+
const current = typeof fp === "object" && fp ? ((r.fingerprintSchemaVersion || 1) === 1 ? fp.legacyHash : fp.hash) : fp;
|
|
740
|
+
if (!current) return "CURRENT_FINGERPRINT_UNAVAILABLE";
|
|
741
|
+
if (r.sourceFingerprint !== current) return "SOURCE_FINGERPRINT_MISMATCH";
|
|
742
|
+
return r.status === "STALE" ? r.staleReason || "STALE_RECEIPT" : null;
|
|
743
|
+
}
|
|
744
|
+
export function effectiveReceipt(run, checkId, currentFingerprint = run.sourceFingerprint) {
|
|
745
|
+
const list = projectReceiptsFor(run, checkId);
|
|
746
|
+
if (!list.length) return null;
|
|
747
|
+
const best = list.reduce((a, b) => (b.finishedAt || 0) >= (a.finishedAt || 0) ? b : a);
|
|
748
|
+
const staleReason = receiptEligibility(run, best, currentFingerprint);
|
|
749
|
+
return { receipt: best, receiptId: receiptId(run, best), status: staleReason ? "STALE" : best.status, staleReason };
|
|
750
|
+
}
|
|
751
|
+
export function checkDiagnostics(run, checkId, currentFingerprint) {
|
|
752
|
+
const all = (run.receipts || []).filter(r => r.checkId === checkId);
|
|
753
|
+
const e = effectiveReceipt(run, checkId, currentFingerprint);
|
|
754
|
+
const describe = r => describeReceipt(run, r, currentFingerprint);
|
|
755
|
+
const historicalReceipts = all.slice(-20).map(describe);
|
|
756
|
+
return { checkId, effectiveStatus: e?.status || "NOT_RUN", effectiveReceiptId: e && !e.staleReason ? e.receiptId : null,
|
|
757
|
+
selectedReceiptId: e?.receiptId || null, effectiveReceipt: e && !e.staleReason ? describe(e.receipt) : null,
|
|
758
|
+
historicalReceiptCount: all.length, historicalReceipts, historicalReceiptsTruncated: all.length > 20,
|
|
759
|
+
staleReason: e?.staleReason || null, receiptFingerprint: e?.receipt.sourceFingerprint || null,
|
|
760
|
+
currentFingerprint: currentHash(currentFingerprint) || null,
|
|
761
|
+
comparisonFingerprint: typeof currentFingerprint === "object" && currentFingerprint ? ((e?.receipt.fingerprintSchemaVersion || 1) === 1 ? currentFingerprint.legacyHash : currentFingerprint.hash) : currentFingerprint || null,
|
|
762
|
+
blockingReason: e?.status === "PASS" ? null : e?.staleReason || e?.status || "NO_RECEIPT" };
|
|
763
|
+
}
|
|
764
|
+
function describeReceipt(run, r, currentFingerprint) {
|
|
765
|
+
return { receiptId: receiptId(run, r), historicalStatus: r.status,
|
|
766
|
+
command: r.command ?? null, argv: r.argv ?? null, exitCode: r.exitCode ?? null,
|
|
767
|
+
testCount: r.testCount ?? null,
|
|
768
|
+
testCountMeaning: "Legacy acceptance count; zero can mean a failed or unrecognized summary, not necessarily zero discovered tests.",
|
|
769
|
+
reportedTests: reportedTestSummary(r.outputTail),
|
|
770
|
+
startedAt: r.startedAt ?? null, finishedAt: r.finishedAt ?? null,
|
|
771
|
+
versions: r.versions ?? null, fpScope: r.fpScope ?? null,
|
|
772
|
+
outputTail: typeof r.outputTail === "string" ? r.outputTail.slice(-6000) : null,
|
|
773
|
+
terminationReason: r.terminationReason ?? null, executionError: r.executionError ?? null,
|
|
774
|
+
order: (run.receipts || []).indexOf(r) + 1, createdAt: r.finishedAt ?? r.startedAt ?? null,
|
|
775
|
+
evidenceClass: r.evidenceClass || r.class || null, receiptFingerprint: r.sourceFingerprint || null,
|
|
776
|
+
classification: receiptEligibility(run, r, currentFingerprint) || "ELIGIBLE",
|
|
777
|
+
missingField: !r.sourceFingerprint ? "sourceFingerprint" : null };
|
|
778
|
+
}
|
|
779
|
+
export function receiptReadout(run, { currentFingerprint = run.sourceFingerprint, checkId, receiptId: selected, offset = 0, limit = 10 } = {}) {
|
|
780
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 20)
|
|
781
|
+
return { error: "INVALID_RECEIPT_PAGE", detail: "offset must be a nonnegative integer; limit must be 1..20" };
|
|
782
|
+
const all = (run.receipts || []).filter(r => checkId === undefined || r.checkId === checkId);
|
|
783
|
+
if (selected !== undefined) {
|
|
784
|
+
const r = all.find(r => receiptId(run, r) === selected);
|
|
785
|
+
return r ? { ok: true, runId: run.runId, checkId: r.checkId, receipt: describeReceipt(run, r, currentFingerprint) }
|
|
786
|
+
: { error: "RECEIPT_NOT_FOUND", runId: run.runId, receiptId: selected };
|
|
787
|
+
}
|
|
788
|
+
return { ok: true, runId: run.runId, checkId: checkId ?? null, total: all.length, offset, limit,
|
|
789
|
+
nextOffset: offset + limit < all.length ? offset + limit : null,
|
|
790
|
+
receipts: all.slice(offset, offset + limit).map(r => {
|
|
791
|
+
const { outputTail, ...metadata } = describeReceipt(run, r, currentFingerprint);
|
|
792
|
+
return { checkId: r.checkId, ...metadata, outputAvailable: typeof r.outputTail === "string" };
|
|
793
|
+
}), detail: "Metadata only; request action=receipts with receiptId for the recorded output tail. Historical status does not imply current eligibility." };
|
|
794
|
+
}
|
|
795
|
+
export function effectiveStatus(run, checkId, currentFingerprint) {
|
|
796
|
+
const e = effectiveReceipt(run, checkId, currentFingerprint);
|
|
797
|
+
return e ? e.status : "NOT_RUN";
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// ---- Hard-gate evaluation (v1.2.3): recompute, never trust a cached gate status --------------
|
|
801
|
+
// A required gate maps to check(s): its explicit `checks` list, else — if the gate's own id is a
|
|
802
|
+
// declared check with receipts — [gate.id]. If it maps to NOTHING we fall back to the cached
|
|
803
|
+
// `status` (back-compat for hand-declared gates like "test-gate"/"render-gate" that carry no check
|
|
804
|
+
// and are satisfied by a human/external declaration). Otherwise the gate is satisfied only when
|
|
805
|
+
// EVERY mapped check's AUTHORITATIVE effective status is PASS on current source. A gate therefore
|
|
806
|
+
// needs its OWN passing (cmd) receipt — no fabricated test receipt, and no reliance on the loss
|
|
807
|
+
// number — and a passing build can complete a test-only run.
|
|
808
|
+
export function evaluateGate(run, gate, currentFingerprint) {
|
|
809
|
+
const receipts = (run && run.receipts) || [];
|
|
810
|
+
const checks = (Array.isArray(gate.checks) && gate.checks.length)
|
|
811
|
+
? gate.checks.slice()
|
|
812
|
+
: ((run.checkCatalogue?.[gate.id] || receipts.some((r) => r && r.checkId === gate.id)) ? [gate.id] : []);
|
|
813
|
+
if (checks.length === 0) {
|
|
814
|
+
return { id: gate.id, checks: [], status: declarationOnly(run) ? gate.status || "FAIL" : "NOT_RUN", satisfied: declarationOnly(run) && statusSatisfies(gate.status), kind: "NO_MAPPED_CHECK", detail: declarationOnly(run) ? "contract-only declaration" : "gate declares no mapped check; recorded status is not evidence" };
|
|
815
|
+
}
|
|
816
|
+
const eff = checks.map((id) => ({ id, status: effectiveStatus(run, id, currentFingerprint) }));
|
|
817
|
+
const hard = eff.filter((e) => ["FAIL", "ERROR", "TIMEOUT", "BLOCKED"].includes(e.status));
|
|
818
|
+
if (hard.length) return { id: gate.id, checks, status: "FAIL", satisfied: false, kind: "HARD_GATE_FAILED", detail: hard };
|
|
819
|
+
const unverified = eff.filter((e) => e.status === "NOT_RUN" || e.status === undefined);
|
|
820
|
+
if (unverified.length) return { id: gate.id, checks, status: "NOT_RUN", satisfied: false, kind: "HARD_GATE_UNVERIFIED", detail: unverified };
|
|
821
|
+
const stale = eff.filter((e) => e.status === "STALE");
|
|
822
|
+
if (stale.length) return { id: gate.id, checks, status: "STALE", satisfied: false, kind: "STALE_EVIDENCE", detail: stale };
|
|
823
|
+
const pass = eff.filter((e) => e.status === "PASS");
|
|
824
|
+
const ok = pass.length === checks.length;
|
|
825
|
+
return { id: gate.id, checks, status: ok ? "PASS" : "FAIL", satisfied: ok, kind: ok ? "SATISFIED" : "HARD_GATE_FAILED", detail: eff };
|
|
826
|
+
}
|
|
827
|
+
// The recomputed gate table for a run (used by status, the verify readout AND completion).
|
|
828
|
+
export function gateStatuses(run, currentFingerprint) {
|
|
829
|
+
const fp = currentFingerprint != null ? currentFingerprint : (run && run.sourceFingerprint) || null;
|
|
830
|
+
return (run && run.contract && run.contract.gates || []).filter((g) => g.required).map((g) => evaluateGate(run, g, fp));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ---- Test-count parsing (v1.2.3): supply discovered counts for genuine test runners ----------
|
|
834
|
+
// The controller must never rely on console optimism, but for a DECLARED test requirement it needs
|
|
835
|
+
// the discovered count to decide PASS vs a zero-test NOT_RUN. Best-effort extraction from common
|
|
836
|
+
// runner output (node:test "tests N", vitest "Tests N passed", playwright "N passed"). If a
|
|
837
|
+
// runner reports zero discovered tests the result is 0 (=> NOT_RUN). Returns null when no count is
|
|
838
|
+
// discoverable (caller then treats a clean exit as a plain cmd PASS, not a coverage requirement).
|
|
839
|
+
export function parseTestCounts(text) {
|
|
840
|
+
if (!text) return null;
|
|
841
|
+
const t = String(text);
|
|
842
|
+
let m = /(?:Tests\s+)(\d+)\s+failed/i.exec(t); if (m) return +m[1] > 0 ? 0 : (/Tests\s+\d+\s+passed/i.test(t) ? (+(/Tests\s+(\d+)\s+passed/i.exec(t)[1])) : null);
|
|
843
|
+
m = /Tests\s+(\d+)\s+passed/i.exec(t); if (m) return +m[1];
|
|
844
|
+
m = /ℹ\s+tests?\s+(\d+)/i.exec(t); if (m) return +m[1];
|
|
845
|
+
m = /(\d+)\s+(?:tests?|specs?)\s+(?:passed|ok)/i.exec(t); if (m) return +m[1];
|
|
846
|
+
m = /RESULT\s+pass=(\d+)/i.exec(t); if (m) return +m[1];
|
|
847
|
+
m = /(\d+)\s+pass(?:ed|ing)?\b/i.exec(t); if (m) return +m[1];
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Read-only runner-reported diagnostics, never an acceptance input. Historical receipts are
|
|
852
|
+
// not rewritten. Only a complete, internally consistent Vitest summary is recognized; absent
|
|
853
|
+
// categories stay unknown, and multiple summaries may be separate commands, so are declined.
|
|
854
|
+
function reportedTestSummary(outputTail) {
|
|
855
|
+
if (typeof outputTail !== "string") return null;
|
|
856
|
+
const text = outputTail.slice(-6000).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
|
|
857
|
+
const lines = text.split(/\r?\n/).map(line => line.trim()).filter(line => /^Tests\s/.test(line));
|
|
858
|
+
if (lines.length !== 1) return null;
|
|
859
|
+
const match = /^Tests\s+(.+?)\s+\((\d+)\)$/.exec(lines[0]);
|
|
860
|
+
if (!match) return null;
|
|
861
|
+
const total = Number(match[2]);
|
|
862
|
+
if (!Number.isSafeInteger(total)) return null;
|
|
863
|
+
const counts = { passed: null, failed: null, skipped: null, todo: null };
|
|
864
|
+
for (const part of match[1].split("|")) {
|
|
865
|
+
const item = /^(\d+)\s+(passed|failed|skipped|todo)$/.exec(part.trim());
|
|
866
|
+
if (!item || counts[item[2]] !== null || !Number.isSafeInteger(Number(item[1]))) return null;
|
|
867
|
+
counts[item[2]] = Number(item[1]);
|
|
868
|
+
}
|
|
869
|
+
if (Object.values(counts).reduce((sum, n) => sum + (n ?? 0), 0) !== total) return null;
|
|
870
|
+
return { source: "recorded_output_tail", runner: "vitest", total, ...counts, summary: lines[0] };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// ---- Structured tool-input normalization (v1.2.3) --------------------------------------------
|
|
874
|
+
// MTPLX sometimes sends a structured field as a JSON STRING and numeric/boolean params as strings.
|
|
875
|
+
// This is the SINGLE authoritative place that recognises those params (no per-action ad-hoc patch).
|
|
876
|
+
// It NEVER invents data: a non-string/non-JSON value is returned untouched; a malformed JSON string
|
|
877
|
+
// yields a safe fallback (empty for arrays/objects), so a bad value can never masquerade as a run.
|
|
878
|
+
function coerceList(v) { if (Array.isArray(v)) return v; if (typeof v === "string") { const t = v.trim(); if (t.startsWith("[") || t.startsWith("{")) { try { const p = JSON.parse(t); return Array.isArray(p) ? p : (p && typeof p === "object" ? [p] : []); } catch { return []; } } } return []; }
|
|
879
|
+
function coerceObj(v) { if (v && typeof v === "object" && !Array.isArray(v)) return v; if (typeof v === "string") { const t = v.trim(); if (t.startsWith("{")) { try { const p = JSON.parse(t); return (p && typeof p === "object" && !Array.isArray(p)) ? p : {}; } catch { return {}; } } } return {}; }
|
|
880
|
+
function num(v, fallback) { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : fallback; }
|
|
881
|
+
function bool(v) { return v === true || v === "true"; }
|
|
882
|
+
export function normalizeStartArgs(args = {}) {
|
|
883
|
+
const out = { ...args };
|
|
884
|
+
if (args.criteria !== undefined) out.criteria = coerceList(args.criteria);
|
|
885
|
+
if (args.checkCatalogue !== undefined) out.checkCatalogue = coerceObj(args.checkCatalogue);
|
|
886
|
+
if (args.hardGates !== undefined) out.hardGates = coerceList(args.hardGates);
|
|
887
|
+
if (args.candidateBudget !== undefined) out.candidateBudget = num(args.candidateBudget, undefined);
|
|
888
|
+
if (args.timeBudgetHours !== undefined) out.timeBudgetHours = num(args.timeBudgetHours, undefined);
|
|
889
|
+
if (args.sameFailureThreshold !== undefined) out.sameFailureThreshold = num(args.sameFailureThreshold, undefined);
|
|
890
|
+
if (args.noProgressThreshold !== undefined) out.noProgressThreshold = num(args.noProgressThreshold, undefined);
|
|
891
|
+
if (args.deadlineHours !== undefined) out.deadlineHours = num(args.deadlineHours, undefined);
|
|
892
|
+
if (args.toolActionCap !== undefined) out.toolActionCap = num(args.toolActionCap, undefined);
|
|
893
|
+
if (args.autoContinue !== undefined) out.autoContinue = bool(args.autoContinue);
|
|
894
|
+
return out;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
export function validateBudgetArgs(args = {}) {
|
|
898
|
+
for (const name of ["candidateBudget", "timeBudgetHours", "deadlineHours", "toolActionCap", "sameFailureThreshold", "noProgressThreshold"]) {
|
|
899
|
+
if (args[name] === undefined) continue;
|
|
900
|
+
const n = Number(args[name]);
|
|
901
|
+
const integer = !["timeBudgetHours", "deadlineHours"].includes(name);
|
|
902
|
+
if (!(["number", "string"].includes(typeof args[name])) || !Number.isFinite(n) || n <= 0 || n > Number.MAX_SAFE_INTEGER || integer && !Number.isSafeInteger(n))
|
|
903
|
+
return { error: "INVALID_BUDGET", detail: `${name} must be a finite positive ${integer ? "integer" : "number"}; no run created` };
|
|
904
|
+
}
|
|
905
|
+
return { ok: true };
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// ---- v1.2.22 default evidence class ---------------------------------------------------------
|
|
909
|
+
// A declared check that a criterion maps to already declares the evidence strength that criterion
|
|
910
|
+
// needs. When the caller omits the optional per-call evidenceClass, derive it from the run's OWN
|
|
911
|
+
// contract so a whole-suite round cannot silently record classless receipts and block every
|
|
912
|
+
// criterion as UNKNOWN_CLASS despite green checks (observed twice: annotations lr-00000000a1b2 and
|
|
913
|
+
// another run lr-00000000c3d4). An explicit argument always wins; an ambiguous mapping (two criteria
|
|
914
|
+
// demanding different classes for the same check) or an unmapped check yields null so the caller
|
|
915
|
+
// must be explicit rather than guessed at.
|
|
916
|
+
export function defaultEvidenceClass(run, checkId, explicit) {
|
|
917
|
+
if (typeof explicit === "string" && explicit) return explicit;
|
|
918
|
+
const classes = new Set();
|
|
919
|
+
for (const criterion of run?.contract?.criteria || []) {
|
|
920
|
+
if (!Array.isArray(criterion.checks) || !criterion.checks.includes(checkId)) continue;
|
|
921
|
+
if (typeof criterion.evidenceClass === "string" && criterion.evidenceClass) classes.add(criterion.evidenceClass);
|
|
922
|
+
}
|
|
923
|
+
return classes.size === 1 ? [...classes][0] : null;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// ---- Evidence-strength gate (§7): a criterion is satisfied only when a PASS receipt exists
|
|
927
|
+
// whose evidence class is strong enough. A weaker/proxy class is a gap, not a pass.
|
|
928
|
+
export function criterionSatisfied(run, criterion, currentFingerprint = run.sourceFingerprint) {
|
|
929
|
+
if (declarationOnly(run)) return { satisfied: statusSatisfies(criterion.status), status: criterion.status, kind: "DECLARATION_ONLY" };
|
|
930
|
+
const checks = criterion.checks || [];
|
|
931
|
+
const effective = checks.map(id => effectiveReceipt(run, id, currentFingerprint));
|
|
932
|
+
if (!checks.length || effective.some(e => !e)) return { satisfied: false, status: "NOT_RUN", kind: "NO_RECEIPT" };
|
|
933
|
+
const blocked = effective.find(e => e.status !== "PASS");
|
|
934
|
+
if (blocked) return { satisfied: false, status: blocked.status, kind: blocked.staleReason || "NO_PASS" };
|
|
935
|
+
const adequate = effective.find(e => EV.classSatisfies(e.receipt.proxyOnly ? "object_exists" : e.receipt.evidenceClass || e.receipt.class, criterion).satisfied);
|
|
936
|
+
if (adequate) return { satisfied: true, status: "PASS", kind: "SATISFIED", receiptId: adequate.receiptId };
|
|
937
|
+
const gap = EV.classSatisfies(effective.at(-1).receipt.evidenceClass, criterion);
|
|
938
|
+
return { satisfied: false, status: "BLOCKED", kind: gap.kind || "EVIDENCE_GAP", note: gap.note };
|
|
939
|
+
}
|
|
940
|
+
export function evidenceStatus(run, currentFingerprint = run.sourceFingerprint) {
|
|
941
|
+
const failures = [], gaps = [];
|
|
942
|
+
for (const c of run.contract?.criteria || []) {
|
|
943
|
+
if (!c.required) continue;
|
|
944
|
+
const result = criterionSatisfied(run, c, currentFingerprint);
|
|
945
|
+
if (result.satisfied) continue;
|
|
946
|
+
if (["FAIL", "ERROR", "TIMEOUT"].includes(result.status)) failures.push({ criterion: c.id, status: result.status });
|
|
947
|
+
else gaps.push({ criterion: c.id, checks: c.checks || [], missing: [...new Set([c.evidenceClass, ...(c.requiresEvidence || [])].filter(Boolean))], note: result.kind });
|
|
948
|
+
}
|
|
949
|
+
return { failures, gaps };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Append receipt history; cached contract statuses and legacy summaries are preserved verbatim.
|
|
953
|
+
export function applyVerification(run, { receipt, canChangeAcceptance = true, diagnosticOnly = false, hypothesis, elapsedMs = 0, currentFingerprint }) {
|
|
954
|
+
const fp = currentFingerprint || receipt?.sourceFingerprint || run.sourceFingerprint;
|
|
955
|
+
const before = deriveRunView(run, { currentFingerprint: fp });
|
|
956
|
+
run.receipts = run.receipts || []; run.receipts.push({ ...receipt, _validateAgainst: undefined });
|
|
957
|
+
const after = deriveRunView(run, { currentFingerprint: fp });
|
|
958
|
+
const changed = after.criterionStates.filter((c, i) => c.status !== before.criterionStates[i]?.status).map(c => c.id);
|
|
959
|
+
const foreign = !isProjectReceipt(receipt);
|
|
960
|
+
const cand = EV.considerCandidate(run, { fingerprint: receipt?.sourceFingerprint || run.sourceFingerprint,
|
|
961
|
+
fingerprintAliases: typeof fp === "object" ? [fp.legacyHash].filter(Boolean) : [],
|
|
962
|
+
evaluated: true, diagnosticOnly: diagnosticOnly || foreign, statusChanged: changed.length > 0, canChangeAcceptance,
|
|
963
|
+
receiptIds: [receiptId(run, run.receipts.at(-1))], criteriaChanged: changed, lossBefore: before.currentLoss,
|
|
964
|
+
lossAfter: after.currentLoss, result: receipt?.status, hypothesis, elapsedMs });
|
|
965
|
+
if (!foreign) run.sourceFingerprint = receipt?.sourceFingerprint || run.sourceFingerprint;
|
|
966
|
+
return { candidate: cand.candidate, counted: cand.counted, changed, lossBefore: before.currentLoss, lossAfter: after.currentLoss, gaps: after.evidenceGaps };
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Runs only a previously reserved command, then atomically writes genuine evidence.
|
|
970
|
+
// Its caller is a finite separate process so host death cannot erase completed I/O.
|
|
971
|
+
export async function executeReservedCheck(job, { ownerLost, aborted } = {}) {
|
|
972
|
+
const { stateDir, runKey, token, runId, checkId, cwd, projectRoot, fixture,
|
|
973
|
+
fpBefore, fpScope, targetFingerprint, productionBefore, evidenceClass, deadlineAt } = job;
|
|
974
|
+
const store = new Store(stateDir), found = store.readRun(runKey);
|
|
975
|
+
if (found.error) throw new Error(found.error);
|
|
976
|
+
const run = found.run, lease = run.execution?.inFlight;
|
|
977
|
+
// Older runs kept the declared catalogue in their session binding. The native
|
|
978
|
+
// reservation captures that resolved declaration without replacing the contract.
|
|
979
|
+
const check = run.checkCatalogue?.[checkId] || lease?.declaredCheck;
|
|
980
|
+
if (!lease || lease.token !== token || lease.executorPid !== process.pid || run.runId !== runId || lease.checkId !== checkId || !Array.isArray(check?.command))
|
|
981
|
+
throw new Error('EXECUTOR_RESERVATION_MISMATCH');
|
|
982
|
+
const negative = lease.mode === 'negative';
|
|
983
|
+
let stateWriteFailed = false;
|
|
984
|
+
const shouldStop = () => {
|
|
985
|
+
if (stateWriteFailed) return 'state_write_failed';
|
|
986
|
+
const current = store.readRun(runKey);
|
|
987
|
+
if (current.error) return 'state_read_failed';
|
|
988
|
+
const latest = current.run;
|
|
989
|
+
if (latest.execution?.inFlight?.token !== token) return 'reservation_changed';
|
|
990
|
+
if (latest.status === 'PAUSED') return 'run_paused';
|
|
991
|
+
if (RUN_TERMINAL_STATES.includes(latest.status)) return 'run_terminal';
|
|
992
|
+
if (latest.status === 'RECOVERY_REQUIRED') return 'recovery_required';
|
|
993
|
+
if ((latest.controlGeneration || 0) !== lease.generation) return 'control_generation_changed';
|
|
994
|
+
if (ownerLost?.()) return 'owner_lost';
|
|
995
|
+
if (aborted?.()) return 'aborted';
|
|
996
|
+
if (deadlineAt !== null && Date.now() >= deadlineAt) return 'deadline_budget';
|
|
997
|
+
return null;
|
|
998
|
+
};
|
|
999
|
+
const timeout = { ...job.timeout };
|
|
1000
|
+
if (deadlineAt !== null && deadlineAt - Date.now() < timeout.timeoutMs) {
|
|
1001
|
+
timeout.timeoutMs = Math.max(1, deadlineAt - Date.now()); timeout.timeoutReason = 'deadline_budget';
|
|
1002
|
+
}
|
|
1003
|
+
const r = await EXEC.runCommand(check.command, { cwd: negative ? fixture : cwd, env: process.env, ...timeout, shouldStop,
|
|
1004
|
+
onSpawn: pid => {
|
|
1005
|
+
const saved = store.mutate(runKey, latest => {
|
|
1006
|
+
if (latest.execution?.inFlight?.token !== token) return { error: 'RESERVATION_CHANGED' };
|
|
1007
|
+
latest.execution.inFlight.childPid = pid || null; return { ok: true };
|
|
1008
|
+
});
|
|
1009
|
+
stateWriteFailed = !!saved.error;
|
|
1010
|
+
},
|
|
1011
|
+
});
|
|
1012
|
+
const fp = sourceFingerprint(cwd);
|
|
1013
|
+
const timedOut = ['check_timeout', 'active_time_budget', 'deadline_budget'].includes(r.terminationReason);
|
|
1014
|
+
const invalid = r.error || r.signal || r.terminationReason || r.status === null;
|
|
1015
|
+
const exitCode = timedOut ? 'TIMEOUT' : invalid ? 'ERROR' : r.status;
|
|
1016
|
+
const output = r.stdout + r.stderr, outputTail = output.length > 6000 ? '...' + output.slice(-6000) : output;
|
|
1017
|
+
let receipt, negativeControl;
|
|
1018
|
+
if (negative) {
|
|
1019
|
+
const productionAfter = sourceFingerprint(projectRoot).hash;
|
|
1020
|
+
const observed = timedOut ? 'TIMEOUT' : invalid ? 'ERROR' : r.status === 0 ? 'PASS' : 'FAIL';
|
|
1021
|
+
negativeControl = EV.makeNegativeControl({ checkId, fixture, targetFingerprint, observed, ok: observed === 'FAIL',
|
|
1022
|
+
mutatedProduction: productionBefore !== productionAfter, command: check.command, exitCode: r.status,
|
|
1023
|
+
signal: r.signal, error: r.error || (r.terminationReason && !timedOut ? r.terminationReason : null),
|
|
1024
|
+
startedAt: r.startedAt, finishedAt: r.finishedAt, outputTail,
|
|
1025
|
+
productionFingerprintBefore: productionBefore, productionFingerprintAfter: productionAfter });
|
|
1026
|
+
negativeControl.terminationReason = r.terminationReason;
|
|
1027
|
+
} else {
|
|
1028
|
+
receipt = makeReceipt({ checkId, command: check.command.join(' '), exitCode, output,
|
|
1029
|
+
testCount: check.kind === 'test' ? (parseTestCounts(output) ?? 0) : undefined,
|
|
1030
|
+
requirementKind: check.kind || 'cmd', startedAt: r.startedAt, finishedAt: r.finishedAt,
|
|
1031
|
+
versions: { node: process.version }, contractHash: run.contractHash, evaluatorHash: run.evaluatorHash,
|
|
1032
|
+
sourceFingerprint: fpBefore.hash, fingerprintSchemaVersion: SOURCE_FINGERPRINT_SCHEMA,
|
|
1033
|
+
evidenceClass: evidenceClass && evidenceClass !== 'proxy' ? evidenceClass : undefined,
|
|
1034
|
+
proxyOnly: evidenceClass === 'proxy' || check.proxyOnly, fpScope });
|
|
1035
|
+
Object.assign(receipt, { argv: check.command, outputTail, terminationReason: r.terminationReason, executionError: r.error, executionToken: token });
|
|
1036
|
+
if (fp.hash !== fpBefore.hash) { receipt.status = 'STALE'; receipt.staleReason = 'SOURCE_CHANGED_DURING_CHECK'; }
|
|
1037
|
+
}
|
|
1038
|
+
const journal = { token, runId, checkId, receipt, negativeControl,
|
|
1039
|
+
result: { ...r, stdout: undefined, stderr: undefined }, fingerprintAfter: fp };
|
|
1040
|
+
store.writeJSON(runKey, `execution-${token}.json`, journal);
|
|
1041
|
+
return journal;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Validate a saved executor result before recovering it. This does not manufacture
|
|
1045
|
+
// missing evidence, rerun a command, or confer trust on an arbitrary state file.
|
|
1046
|
+
export function validateExecutionRecord(run, journal) {
|
|
1047
|
+
const lease = run.execution?.inFlight, r = journal?.result;
|
|
1048
|
+
const bad = detail => ({ error: "INVALID_EXECUTION_RECORD", detail });
|
|
1049
|
+
if (!lease || !journal || journal.token !== lease.token || journal.checkId !== lease.checkId ||
|
|
1050
|
+
(journal.runId !== undefined && journal.runId !== run.runId)) return bad("record does not match the reserved run/check/token");
|
|
1051
|
+
const check = run.checkCatalogue?.[lease.checkId] || lease.declaredCheck, negative = lease.mode === "negative";
|
|
1052
|
+
const record = negative ? journal.negativeControl : journal.receipt;
|
|
1053
|
+
if (!check || !r || !record || (negative ? journal.receipt : journal.negativeControl)) return bad("missing or conflicting command evidence");
|
|
1054
|
+
if (r.cleanupComplete !== true) return { error: "EXECUTION_CLEANUP_UNCONFIRMED", detail: "saved result does not confirm owned process cleanup" };
|
|
1055
|
+
if (EXEC.ownedWorkAlive(r.pid) || EXEC.ownedWorkAlive(lease.childPid)) return { error: "VERIFY_IN_FLIGHT" };
|
|
1056
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
1057
|
+
const hash = x => typeof x === "string" && /^[a-f0-9]{64}$/.test(x);
|
|
1058
|
+
if (!Number.isFinite(r.startedAt) || !Number.isFinite(r.finishedAt) || r.finishedAt < r.startedAt ||
|
|
1059
|
+
r.startedAt < lease.startedAt || r.finishedAt > Date.now() ||
|
|
1060
|
+
record.startedAt !== r.startedAt || record.finishedAt !== r.finishedAt ||
|
|
1061
|
+
record.checkId !== lease.checkId || !hash(journal.fingerprintAfter?.hash) ||
|
|
1062
|
+
(r.pid !== null && (!Number.isInteger(r.pid) || r.pid <= 0)) ||
|
|
1063
|
+
(lease.childPid != null && lease.childPid !== r.pid)) return bad("invalid timing, process or fingerprint evidence");
|
|
1064
|
+
if (!(r.status === null || Number.isInteger(r.status) && (r.status >= 0 || r.error)) ||
|
|
1065
|
+
(r.pid === null && !r.error && !r.terminationReason) ||
|
|
1066
|
+
![r.signal, r.error, r.terminationReason].every(x => x === null || typeof x === "string")) return bad("invalid process result");
|
|
1067
|
+
const timeout = ["check_timeout", "active_time_budget", "deadline_budget"].includes(r.terminationReason);
|
|
1068
|
+
const invalid = r.error || r.signal || r.terminationReason || r.status === null;
|
|
1069
|
+
const exitCode = timeout ? "TIMEOUT" : invalid ? "ERROR" : r.status;
|
|
1070
|
+
if (negative) {
|
|
1071
|
+
const observed = timeout ? "TIMEOUT" : invalid ? "ERROR" : r.status === 0 ? "PASS" : "FAIL";
|
|
1072
|
+
const changed = record.productionFingerprintBefore !== record.productionFingerprintAfter;
|
|
1073
|
+
const expected = EV.makeNegativeControl({ ...record, observed, ok: observed === "FAIL", mutatedProduction: changed });
|
|
1074
|
+
if (!same(record.command, check.command) || record.exitCode !== r.status || record.observed !== observed ||
|
|
1075
|
+
record.signal !== r.signal || record.terminationReason !== r.terminationReason ||
|
|
1076
|
+
record.error !== (r.error || (r.terminationReason && !timeout ? r.terminationReason : null)) ||
|
|
1077
|
+
!hash(record.targetFingerprint) || !hash(record.productionFingerprintBefore) || !hash(record.productionFingerprintAfter) ||
|
|
1078
|
+
record.expected !== "FAIL" || record.mutatedProduction !== changed || record.ok !== expected.ok || record.valid !== expected.valid ||
|
|
1079
|
+
record.executionToken !== undefined && record.executionToken !== lease.token) return bad("negative-control record disagrees with execution");
|
|
1080
|
+
} else {
|
|
1081
|
+
const status = makeReceipt({ exitCode, requirementKind: check.kind || "cmd", testCount: record.testCount }).status;
|
|
1082
|
+
const changed = journal.fingerprintAfter.hash !== record.sourceFingerprint;
|
|
1083
|
+
if (!same(record.argv, check.command) || record.command !== check.command.join(" ") ||
|
|
1084
|
+
record.executionToken !== lease.token || record.exitCode !== exitCode || record.executionError !== r.error ||
|
|
1085
|
+
record.terminationReason !== r.terminationReason || !hash(record.sourceFingerprint) ||
|
|
1086
|
+
record.fingerprintSchemaVersion !== SOURCE_FINGERPRINT_SCHEMA ||
|
|
1087
|
+
record.contractHash !== run.contractHash || record.evaluatorHash !== run.evaluatorHash ||
|
|
1088
|
+
!["project", "copy"].includes(record.fpScope) || record.status !== (changed ? "STALE" : status) ||
|
|
1089
|
+
changed && record.staleReason !== "SOURCE_CHANGED_DURING_CHECK") return bad("receipt disagrees with execution or acceptance mapping");
|
|
1090
|
+
}
|
|
1091
|
+
return { ok: true };
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Caller holds Store.mutate's writer lock. Both the original executor and an
|
|
1095
|
+
// explicit reconciliation use this single commit path, so attempts/time/results
|
|
1096
|
+
// cannot be counted twice even when the executor retries after recovery.
|
|
1097
|
+
export function commitExecutionResult(run, journal, { currentFingerprint = journal.fingerprintAfter } = {}) {
|
|
1098
|
+
const { token, receipt, negativeControl, result: r } = journal;
|
|
1099
|
+
const existing = [...(run.receipts || []), ...(run.evidence || [])].find(x => x.executionToken === token);
|
|
1100
|
+
if (existing) return { ok: true, alreadyRecorded: true, apply: { candidate: { counted: false }, gaps: [] }, candidateCount: candidateCount(run), usage: EXEC.usage(run) };
|
|
1101
|
+
if (run.execution?.inFlight?.token !== token) return { error: "RESERVATION_CHANGED" };
|
|
1102
|
+
const usage = EXEC.initialize(run);
|
|
1103
|
+
usage.verificationMs += Math.max(0, r.finishedAt - r.startedAt);
|
|
1104
|
+
usage.inFlight = r.cleanupComplete === false ? { ...usage.inFlight, recoveryRequired: "child_cleanup_failed" } : null;
|
|
1105
|
+
let apply;
|
|
1106
|
+
if (negativeControl) { run.evidence = run.evidence || []; run.evidence.push({ ...negativeControl, executionToken: token }); }
|
|
1107
|
+
else apply = applyVerification(run, { receipt, canChangeAcceptance: true, diagnosticOnly: !r.pid,
|
|
1108
|
+
hypothesis: run.checkCatalogue?.[journal.checkId]?.hypothesis, elapsedMs: r.finishedAt - r.startedAt, currentFingerprint });
|
|
1109
|
+
return { ok: true, apply, candidateCount: candidateCount(run), usage: EXEC.usage(run) };
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// ---- Single-flight scheduling + generation guard (§8)
|
|
1113
|
+
// Event dedup, per-run single-flight, current-session generation/message guard.
|
|
1114
|
+
export class Scheduler {
|
|
1115
|
+
constructor(store) { this.store = store; this.inFlight = new Set(); }
|
|
1116
|
+
// Returns {dispatch:boolean, reason} . Guards: idle-only is never authorisation; ignore
|
|
1117
|
+
// helper/child sessions; dedupe; generation guard; single-flight.
|
|
1118
|
+
// (Default deployment keeps AUTO disabled in the plugin; this is the safe core + fixtures.)
|
|
1119
|
+
requestContinuation({ runKey, eventId, sessionID, generation, isHelper, state, autoEnabled, pendingGeneration, messageAuthorised }) {
|
|
1120
|
+
if (!autoEnabled) return { dispatch: false, reason: "auto_disabled" };
|
|
1121
|
+
if (isHelper) return { dispatch: false, reason: "helper_session" };
|
|
1122
|
+
if (state !== "IMPLEMENTING" && state !== "VERIFYING" && state !== "REPAIRING") return { dispatch: false, reason: "state_not_continuable" };
|
|
1123
|
+
if (!messageAuthorised) return { dispatch: false, reason: "idle_not_authorisation" };
|
|
1124
|
+
if (generation != null && pendingGeneration != null && generation !== pendingGeneration) return { dispatch: false, reason: "generation_guard" };
|
|
1125
|
+
const dk = `cont:${eventId || ""}:${sessionID}:${generation ?? ""}`;
|
|
1126
|
+
if (!this.store.appendEvent(runKey, { type: "continuation", dedupeKey: dk, at: Date.now() })) return { dispatch: false, reason: "duplicate_event" };
|
|
1127
|
+
if (this.inFlight.has(runKey)) return { dispatch: false, reason: "single_flight" };
|
|
1128
|
+
this.inFlight.add(runKey);
|
|
1129
|
+
return { dispatch: true, reason: "scheduled" };
|
|
1130
|
+
}
|
|
1131
|
+
completeDispatch(runKey) { this.inFlight.delete(runKey); }
|
|
1132
|
+
cancelAll() { this.inFlight.clear(); }
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// ---- Budgets (§8) + counters that persist (store-backed by caller).
|
|
1136
|
+
export function defaultBudget() {
|
|
1137
|
+
// autoDispatchCap bounds automatic continuation dispatches. Automatic continuation is
|
|
1138
|
+
// DISABLED unless a run explicitly opts in (see isAutoAllowed). 12/6 are commissioning caps.
|
|
1139
|
+
return { iterations: 40, activeSeconds: 4 * 3600, deadlineSeconds: 8 * 3600, sameFailureLimit: 3, noProgressLimit: 5, autoDispatchCap: 40, toolActionCap: 200 };
|
|
1140
|
+
}
|
|
1141
|
+
// Commissioning defaults are tighter; used only for an explicitly authorised disposable run.
|
|
1142
|
+
export function commissioningBudget() {
|
|
1143
|
+
return { iterations: 12, activeSeconds: 4 * 3600, deadlineSeconds: 8 * 3600, sameFailureLimit: 3, noProgressLimit: 5, autoDispatchCap: 6, toolActionCap: 200 };
|
|
1144
|
+
}
|
|
1145
|
+
// Automatic continuation gate: DEFAULT OFF. Only a run that BOTH opted in AND is flagged
|
|
1146
|
+
// commissioning may dispatch automatically; ordinary chat / inactive project can never.
|
|
1147
|
+
export function isAutoAllowed(run) {
|
|
1148
|
+
if (!run) return false;
|
|
1149
|
+
if (!run.autoEnabled) return false;
|
|
1150
|
+
if (run.runMode !== "commissioning") return false;
|
|
1151
|
+
const s = run.state || {};
|
|
1152
|
+
const cap = (run.budget && run.budget.autoDispatchCap) ?? 40;
|
|
1153
|
+
if ((s.autoDispatches || 0) >= cap) return false;
|
|
1154
|
+
return true;
|
|
1155
|
+
}
|
|
1156
|
+
export function budgetState(budget, counters, nowMs, startedAtMs) {
|
|
1157
|
+
const spent = {};
|
|
1158
|
+
spent.iterations = counters.iterations >= budget.iterations;
|
|
1159
|
+
spent.activeSeconds = (counters.activeMs || 0) / 1000 >= budget.activeSeconds;
|
|
1160
|
+
spent.deadline = (nowMs - startedAtMs) / 1000 >= budget.deadlineSeconds;
|
|
1161
|
+
spent.sameFailure = (counters.sameFailureStreak || 0) >= budget.sameFailureLimit;
|
|
1162
|
+
spent.noProgress = (counters.noProgressStreak || 0) >= budget.noProgressLimit;
|
|
1163
|
+
spent.autoTurns = (counters.autoTurns || 0) >= (budget.autoTurnCap ?? 40);
|
|
1164
|
+
spent.autoDispatch = (counters.autoDispatches || 0) >= (budget.autoDispatchCap ?? 40);
|
|
1165
|
+
spent.toolActions = (counters.toolActions || 0) >= (budget.toolActionCap ?? 200);
|
|
1166
|
+
return { exhausted: Object.values(spent).some(Boolean), spent };
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Same materially equivalent failure 3x => require new evidence + replan (NEEDS_REPLAN).
|
|
1170
|
+
// 5 evaluated candidates without meaningful progress => one bounded replan then pause.
|
|
1171
|
+
export function afterEvaluation(run, { progress, fingerprint, failureSignature }) {
|
|
1172
|
+
const s = run.status;
|
|
1173
|
+
const st = run.state;
|
|
1174
|
+
if (!progress) {
|
|
1175
|
+
st.noProgressStreak = (st.noProgressStreak || 0) + 1;
|
|
1176
|
+
} else {
|
|
1177
|
+
st.noProgressStreak = 0;
|
|
1178
|
+
}
|
|
1179
|
+
if (failureSignature && failureSignature === st.lastFailureSignature) st.sameFailureStreak = (st.sameFailureStreak || 0) + 1;
|
|
1180
|
+
else { st.sameFailureStreak = failureSignature ? 1 : 0; st.lastFailureSignature = failureSignature || null; }
|
|
1181
|
+
st.iterations = (st.iterations || 0) + 1;
|
|
1182
|
+
const budget = run.budget || defaultBudget();
|
|
1183
|
+
if (st.sameFailureStreak >= budget.sameFailureLimit) return { next: "NEEDS_REPLAN", reason: "same_failure_3x" };
|
|
1184
|
+
if (st.noProgressStreak >= budget.noProgressLimit) {
|
|
1185
|
+
if (!st.replanned) { st.replanned = true; return { next: "NEEDS_REPLAN", reason: "no_progress_replan" }; }
|
|
1186
|
+
return { next: "PAUSED", reason: "still_stalled" };
|
|
1187
|
+
}
|
|
1188
|
+
return { next: s, reason: "continue" };
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// ---- Best vs current experiment tracking (§8). Keep best verified candidate distinct.
|
|
1192
|
+
export function recordCandidate(run, cand) {
|
|
1193
|
+
const st = run.state;
|
|
1194
|
+
st.candidates = st.candidates || [];
|
|
1195
|
+
st.candidates.push(cand);
|
|
1196
|
+
if (cand.verified && (!st.best || cand.loss <= st.best.loss)) st.best = cand;
|
|
1197
|
+
st.current = cand;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// ---- Resume authorization (§4,§8): fabricated assistant text or text in a repo file must NOT resume.
|
|
1201
|
+
// Only a user-authorised CLI/session entry may transition PAUSED/cancelled -> active.
|
|
1202
|
+
export function canResume(run, origin) {
|
|
1203
|
+
if (origin === "user_cli" || origin === "user_session_command") return { ok: true };
|
|
1204
|
+
// A repo file, assistant prose, or a stray event cannot resume a cancelled/paused run.
|
|
1205
|
+
if (origin === "assistant_text" || origin === "repo_file_text" || origin === "event") return { ok: false, reason: "not_user_authorised" };
|
|
1206
|
+
return { ok: false, reason: "unknown_origin" };
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Advisory working context has its own whitelist and history. It cannot write status,
|
|
1210
|
+
// acceptance, receipts, budgets or counters. Legacy runs are unchanged until explicitly saved.
|
|
1211
|
+
export const LIFECYCLE_GUIDANCE = "Continuation OFF disables automatic scheduling; it does not mean the run is PAUSED. Only the canonical state reports lifecycle. checkpoint saves advisory context without pausing. When authorized work is finished or must stop, explicitly call pause(runId), then confirm state with status(runId). toolActionCap counts declared-check attempts, not help/status/checkpoint calls or all host tools.";
|
|
1212
|
+
const PROGRESS_SCALAR_LIMITS = { currentSlice: 240, nextAction: 1000 };
|
|
1213
|
+
const PROGRESS_LIST_FIELDS = ["decisions", "failedHypotheses", "memoryNodes", "artifacts"];
|
|
1214
|
+
export function progressSchema() {
|
|
1215
|
+
return {
|
|
1216
|
+
accepts: "object or JSON-encoded object; at least one supported field",
|
|
1217
|
+
fields: {
|
|
1218
|
+
...Object.fromEntries(Object.entries(PROGRESS_SCALAR_LIMITS).map(([key, maxLength]) => [key, { type: "string", maxLength }])),
|
|
1219
|
+
...Object.fromEntries(PROGRESS_LIST_FIELDS.map(key => [key, { type: "array", maxItems: 12, itemType: "string", itemMaxLength: 500 }])),
|
|
1220
|
+
},
|
|
1221
|
+
maxCombinedCharacters: 6000, maxSerializedCharacters: 24000,
|
|
1222
|
+
memoryNodes: "relative project paths without parent traversal",
|
|
1223
|
+
merge: "Partial fields merge; arrays replace. Advisory only; no status, receipts, budgets or acceptance fields.",
|
|
1224
|
+
example: { currentSlice: "Describe the actual work", nextAction: "Describe the remaining action" },
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
export function saveAgentProgress(run, progress, { sessionID = null, fingerprint = null, at = Date.now() } = {}) {
|
|
1228
|
+
const scalarLimits = PROGRESS_SCALAR_LIMITS;
|
|
1229
|
+
const listFields = PROGRESS_LIST_FIELDS;
|
|
1230
|
+
// Called under the canonical writer lock by the native path. No mutation has
|
|
1231
|
+
// occurred on these returns; report the state actually inspected, not prose.
|
|
1232
|
+
const invalid = detail => ({ error: "INVALID_PROGRESS", detail, runId: run.runId || null,
|
|
1233
|
+
state: run.status, continuation: false, unchanged: true, progressSchema: progressSchema(),
|
|
1234
|
+
lifecycleGuidance: LIFECYCLE_GUIDANCE,
|
|
1235
|
+
correction: "Correct only progress using the supported fields. Do not rerun checks, rewrite state files or reset the run to repair this payload." });
|
|
1236
|
+
if (typeof progress === "string") {
|
|
1237
|
+
if (progress.length > 24000) return invalid("serialized progress is too large");
|
|
1238
|
+
try { progress = JSON.parse(progress); } catch { return invalid("progress must be a JSON object or a valid JSON-encoded object"); }
|
|
1239
|
+
}
|
|
1240
|
+
if (!progress || typeof progress !== "object" || Array.isArray(progress)) return invalid("progress must be an object");
|
|
1241
|
+
if (!Object.keys(progress).length) return invalid("provide at least one progress field");
|
|
1242
|
+
for (const [key, value] of Object.entries(progress)) {
|
|
1243
|
+
if (Object.hasOwn(scalarLimits, key)) {
|
|
1244
|
+
if (typeof value !== "string" || value.length > scalarLimits[key]) return invalid(`${key} must be a string of at most ${scalarLimits[key]} characters`);
|
|
1245
|
+
} else if (listFields.includes(key)) {
|
|
1246
|
+
if (!Array.isArray(value) || value.length > 12 || value.some(x => typeof x !== "string" || x.length > 500)) return invalid(`${key} needs at most 12 strings of at most 500 characters`);
|
|
1247
|
+
if (key === "memoryNodes" && value.some(x => !x || path.isAbsolute(x) || x.split(/[\\/]/).includes(".."))) return invalid("memoryNodes must be relative project paths without parent traversal");
|
|
1248
|
+
} else return invalid(`unsupported progress field: ${key}`);
|
|
1249
|
+
}
|
|
1250
|
+
const fields = { ...(run.agentProgress?.fields || {}), ...structuredClone(progress) };
|
|
1251
|
+
if (JSON.stringify(fields).length > 6000) return invalid("combined progress exceeds 6000 characters; keep it concise and link artifacts");
|
|
1252
|
+
const record = { schemaVersion: 1, revision: (run.agentProgress?.revision || 0) + 1, at, sessionID, sourceFingerprint: fingerprint, fields };
|
|
1253
|
+
run.agentProgressHistory = [...(run.agentProgressHistory || []), record];
|
|
1254
|
+
run.agentProgress = record;
|
|
1255
|
+
return { ok: true, progress: record };
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// ---- Recovery packet (§9): machine facts plus explicitly advisory working context.
|
|
1259
|
+
export function buildRecoveryPacket(run, limitWords = 1500, view = deriveRunView(run)) {
|
|
1260
|
+
const c = run.contract || {};
|
|
1261
|
+
const st = run.state || {};
|
|
1262
|
+
const remaining = view.remaining;
|
|
1263
|
+
const gaps = view.evidenceGaps;
|
|
1264
|
+
const candCount = EV.candidateCount(run);
|
|
1265
|
+
const candBudget = EXEC.effectiveIterations(run);
|
|
1266
|
+
const originalIterations = Number.isFinite(run.budget?.iterations) ? run.budget.iterations : defaultBudget().iterations;
|
|
1267
|
+
const allowance = candBudget - originalIterations;
|
|
1268
|
+
const lines = [];
|
|
1269
|
+
lines.push(`STATE: ${run.status}`);
|
|
1270
|
+
const timing = view.timing || EXEC.timing(run);
|
|
1271
|
+
lines.push(`OBSERVED AT: ${new Date(timing.observedAt).toISOString()} (snapshot; time continues during inference and compaction)`);
|
|
1272
|
+
lines.push(`DEADLINE: ${timing.deadlineAt === null ? "UNKNOWN (original timestamp or duration missing/invalid; not reconstructed)" : new Date(timing.deadlineAt).toISOString()} (absolute wall clock; never reset)`);
|
|
1273
|
+
lines.push(`DEADLINE REMAINING: ${timing.remainingMs === null ? "UNKNOWN" : `${timing.remainingMs} ms at observation`}`);
|
|
1274
|
+
if (timing.expired) lines.push("DEADLINE EXPIRED: no further implementation or checks; checkpoint the incomplete outcome and pause. Do not reset budgets, bypass via another tool/session or create a replacement run.");
|
|
1275
|
+
lines.push(`CONTRACT HASH: ${run.contractHash || "?"} EVALUATOR HASH: ${run.evaluatorHash || "?"}`);
|
|
1276
|
+
lines.push(`SOURCE FINGERPRINT: ${view.currentFingerprint || "(unknown)"}${run.sourceFingerprintStale ? " [STALE: source changed since last verified]" : ""}`);
|
|
1277
|
+
lines.push(`REMAINING CRITERIA: ${remaining.length ? remaining.join(", ") : "(none)"}`);
|
|
1278
|
+
lines.push(`EVIDENCE GAPS: ${gaps.length ? gaps.map((g) => `${g.criterion}<-${g.missing.join("+")}`).join(", ") : "(none)"}`);
|
|
1279
|
+
lines.push(`CANDIDATES: ${candCount}/${candBudget}${allowance > 0 ? ` (original ${originalIterations} + operator-authorized ${allowance})` : ""} (exact, persisted; never reset by a new conversation)`);
|
|
1280
|
+
if ((run.budgetAmendments || []).length) lines.push(`OPERATOR BUDGET AMENDMENTS: ${run.budgetAmendments.length} recorded. Original deadline ${timing.originalDeadlineAt === null ? "UNKNOWN" : new Date(timing.originalDeadlineAt).toISOString()}; effective deadline ${timing.deadlineAt === null ? "UNKNOWN" : new Date(timing.deadlineAt).toISOString()}. Original limits/usage/history preserved; any prior completion approval is invalidated; a grant never resumes the run.`);
|
|
1281
|
+
lines.push(`VERIFIED EVIDENCE REFS: ${view.effectiveReceiptRefs.join(", ") || "(none currently eligible)"}`);
|
|
1282
|
+
lines.push(`HISTORICAL RECEIPTS: ${view.historicalReceiptCount} (retained; inspect status.checks for eligibility)`);
|
|
1283
|
+
lines.push(`HISTORICAL EVIDENCE REFS: ${view.checks.map(c => c.selectedReceiptId).filter(Boolean).join(", ") || "(none recorded)"}`);
|
|
1284
|
+
lines.push(`BEST LOSS: ${view.bestLoss ?? "n/a"} CURRENT LOSS: ${view.currentLoss ?? "n/a"}`);
|
|
1285
|
+
lines.push(`TARGET LOSS: ${view.targetLoss} COMPLETION BLOCKED: ${view.completionBlocked} REASON: ${view.blockReason || "none"}`);
|
|
1286
|
+
if (view.completionReview?.required) lines.push(`COMPLETION REVIEW: ${view.completionReview.status}. Declared checks ready: ${view.declaredChecksReady}. Pause for independent operator review; never self-approve through a shell command or checkpoint. A zero declared-check loss is not full-scope acceptance.`);
|
|
1287
|
+
lines.push(`HARD GATE BLOCKERS: ${view.hardGateBlockers.map(g => g.id).join(", ") || "(none)"}`);
|
|
1288
|
+
lines.push(`MEMORY STATUS: ${view.memoryStatus}; ${view.memory.reasons.join("; ")}`);
|
|
1289
|
+
const progress = run.agentProgress?.fields || st;
|
|
1290
|
+
lines.push(`AGENT PROGRESS (advisory; not verification evidence): ${run.agentProgress ? `revision ${run.agentProgress.revision}, saved ${new Date(run.agentProgress.at).toISOString()}; source ${run.agentProgress.sourceFingerprint || "unknown"}` : "legacy fields or not yet saved; use checkpoint(progress=...)"}`);
|
|
1291
|
+
lines.push(`CURRENT SLICE: ${progress.currentSlice || "(none)"}`);
|
|
1292
|
+
lines.push(`DECISIONS/INVARIANTS: ${(progress.decisions || []).join("; ") || "(none)"}`);
|
|
1293
|
+
lines.push(`MEMORY NODES (reload lazily, do NOT dump all): ${[...new Set([...(view.memory.reloadableNodes || []), ...(progress.memoryNodes || run.memoryNodes || [])])].join(", ") || "(root only)"}`);
|
|
1294
|
+
lines.push(`UNSUCCESSFUL APPROACHES: ${(progress.failedHypotheses || []).join("; ") || "(none)"}`);
|
|
1295
|
+
lines.push(`NEXT ACTION: ${progress.nextAction || "(recompute)"}`);
|
|
1296
|
+
lines.push(`ARTIFACT REFERENCES (inspect; not proof by themselves): ${(progress.artifacts || []).join(", ") || "(none)"}`);
|
|
1297
|
+
lines.push(`BUDGETS (persisted): iters=${st.iterations || 0} noProgress=${st.noProgressStreak || 0} sameFailure=${st.sameFailureStreak || 0} (legacy counters; not execution authority)`);
|
|
1298
|
+
const execution = EXEC.usage(run);
|
|
1299
|
+
lines.push(`CHECK EXECUTION: candidates=${view.candidateCount}; commands=${execution.commandAttempts}; verificationMs=${execution.verificationMs}; historicalUsageUnknown=${execution.historicalUsageUnknown}; total agent time/actions unmeasured. In-flight=${execution.inFlight?.token || "none"}.`);
|
|
1300
|
+
// Put the authoritative reference/status and working context before a potentially very
|
|
1301
|
+
// long original request. Preserve the full request in the record, never relabel an excerpt
|
|
1302
|
+
// as verbatim or silently discard the next action behind a request-sized prefix.
|
|
1303
|
+
lines.push(`ORIGINAL GOAL (verbatim): ${run.originalRequest || "(none)"}`);
|
|
1304
|
+
const out = lines.join("\n");
|
|
1305
|
+
const words = out.split(/\s+/).filter(Boolean);
|
|
1306
|
+
if (words.length > limitWords) {
|
|
1307
|
+
const marked = out.replace("ORIGINAL GOAL (verbatim):", "ORIGINAL GOAL EXCERPT (full verbatim text remains in run.json):");
|
|
1308
|
+
const marker = "[truncated; inspect run.json for full context]";
|
|
1309
|
+
const available = Math.max(0, limitWords - marker.split(/\s+/).length);
|
|
1310
|
+
const packet = marked.split(/\s+/).filter(Boolean).slice(0, available).join(" ") + "\n" + marker;
|
|
1311
|
+
return { packet, words: packet.split(/\s+/).filter(Boolean).length, truncated: true, view };
|
|
1312
|
+
}
|
|
1313
|
+
return { packet: out, words: words.length, truncated: false, view };
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
export const __test = { sha256, isExcluded };
|
|
1317
|
+
|
|
1318
|
+
// ---- Re-exports so plugin + CLI can use ONE import surface (controller) -------------------
|
|
1319
|
+
export const memory = MEM;
|
|
1320
|
+
export const evidence = EV;
|
|
1321
|
+
export const execution = EXEC;
|
|
1322
|
+
|
|
1323
|
+
// A fixture must be physically separate from the project. Resolve aliases and inspect
|
|
1324
|
+
// dependencies too: a copied directory with node_modules pointing back is not isolated.
|
|
1325
|
+
// This is a preflight integrity check, not an OS sandbox for arbitrary catalogue commands.
|
|
1326
|
+
export function validateNegativeFixture(fixture, projectDirectories = [], { entryBudget = 200000 } = {}) {
|
|
1327
|
+
const reject = detail => ({ ok: false, error: "FIXTURE_NOT_ISOLATED", detail });
|
|
1328
|
+
const inside = (root, target) => { const rel = path.relative(root, target); return rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel)); };
|
|
1329
|
+
let root;
|
|
1330
|
+
try {
|
|
1331
|
+
root = fs.realpathSync(fixture);
|
|
1332
|
+
if (!fs.statSync(root).isDirectory()) return reject("fixture must be a directory");
|
|
1333
|
+
for (const dir of projectDirectories.filter(Boolean)) {
|
|
1334
|
+
const project = fs.realpathSync(dir);
|
|
1335
|
+
// A filesystem root (e.g. a host that reports context.worktree="/") contains every possible
|
|
1336
|
+
// path, so it conveys NO isolation information. Treating it as a project directory rejected
|
|
1337
|
+
// every fixture, including one outside $HOME. Real nested/overlapping directories are still
|
|
1338
|
+
// refused below; the refusal now names the offending directory.
|
|
1339
|
+
if (path.parse(project).root === project) continue;
|
|
1340
|
+
if (inside(project, root)) return reject(`fixture and production project overlap: fixture ${root} is inside project directory ${project}`);
|
|
1341
|
+
if (inside(root, project)) return reject(`fixture and production project overlap: fixture ${root} contains project directory ${project}`);
|
|
1342
|
+
}
|
|
1343
|
+
const stack = [root];
|
|
1344
|
+
let count = 0;
|
|
1345
|
+
while (stack.length) {
|
|
1346
|
+
const directory = stack.pop();
|
|
1347
|
+
for (const item of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
1348
|
+
if (++count > entryBudget) return reject("fixture isolation scan exceeded its entry budget");
|
|
1349
|
+
const file = path.join(directory, item.name);
|
|
1350
|
+
const stat = fs.lstatSync(file);
|
|
1351
|
+
if (stat.isSymbolicLink()) {
|
|
1352
|
+
const target = fs.realpathSync(file);
|
|
1353
|
+
if (!inside(root, target)) return reject(`fixture symlink escapes its root: ${path.relative(root, file)}`);
|
|
1354
|
+
} else if (stat.isDirectory()) stack.push(file);
|
|
1355
|
+
else if (stat.isFile() && stat.nlink > 1) return reject(`fixture contains a shared hard link: ${path.relative(root, file)}`);
|
|
1356
|
+
else if (!stat.isFile()) return reject(`fixture contains a special file: ${path.relative(root, file)}`);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return { ok: true, fixture: root, inspectedEntries: count };
|
|
1360
|
+
} catch (error) { return reject(`cannot establish fixture isolation: ${error.code || error.message}`); }
|
|
1361
|
+
}
|
|
1362
|
+
export const candidateCount = (run) => EV.candidateCount(run);
|
|
1363
|
+
export const classSatisfies = (a, b) => EV.classSatisfies(a, b);
|
|
1364
|
+
|
|
1365
|
+
// ---- Authoritative run construction (§5): `start` must create a REAL run ------------------
|
|
1366
|
+
// Builds a stable contract from declared criteria (with evidence classes + gates) and an initial
|
|
1367
|
+
// run record. It does NOT fabricate a PASS anywhere; initial loss reflects unverified criteria.
|
|
1368
|
+
export function makeContract({ criteria = [], hardGates = [], lossTarget = 0 } = {}) {
|
|
1369
|
+
const crit = criteria.map((c, i) => {
|
|
1370
|
+
const id = c.id || `c${i + 1}`;
|
|
1371
|
+
return { id, required: c.required !== false, weight: c.weight ?? 1, checks: c.checks || [], evidenceClass: c.evidenceClass || null, visual: !!c.visual, status: "FAIL" };
|
|
1372
|
+
});
|
|
1373
|
+
const gates = hardGates.map((g, i) => typeof g === "string" ? { id: g, required: true, status: "FAIL" } : { ...g, id: g.id || `g${i + 1}`, required: g.required !== false, status: g.status || "FAIL" });
|
|
1374
|
+
return { criteria: crit, gates, lossTarget };
|
|
1375
|
+
}
|
|
1376
|
+
export function startRun({ request, contract, budgets, sourceFingerprint, memoryStatus, continuation = false, runId, directory, checkCatalogue = {} }) {
|
|
1377
|
+
const loss = defaultLoss(contract);
|
|
1378
|
+
if (loss.error) return { error: loss.error, hint: "a run needs >=1 required criterion with a positive weight" };
|
|
1379
|
+
const run = {
|
|
1380
|
+
runId: runId || ("lr-" + crypto.randomBytes(6).toString("hex")),
|
|
1381
|
+
status: "IMPLEMENTING",
|
|
1382
|
+
originalRequest: request || "",
|
|
1383
|
+
contract, contractHash: sha256(JSON.stringify(contract)),
|
|
1384
|
+
evaluatorHash: null,
|
|
1385
|
+
sourceFingerprint: sourceFingerprint || null,
|
|
1386
|
+
loss: loss.loss,
|
|
1387
|
+
budget: budgets || defaultBudget(),
|
|
1388
|
+
autoEnabled: !!continuation, runMode: "tracked",
|
|
1389
|
+
memoryStatus: memoryStatus || "UNKNOWN",
|
|
1390
|
+
state: { iterations: 0, noProgressStreak: 0, sameFailureStreak: 0, candidates: [], lastEvalFingerprint: sourceFingerprint || null, memoryNodes: ["AGENTS.md"] },
|
|
1391
|
+
receipts: [], faults: [],
|
|
1392
|
+
execution: { schemaVersion: 1, since: Date.now(), historicalUsageUnknown: false, verificationMs: 0, commandAttempts: 0, inFlight: null },
|
|
1393
|
+
directory: directory || null, checkCatalogue,
|
|
1394
|
+
createdAt: Date.now(),
|
|
1395
|
+
};
|
|
1396
|
+
return { run, initialLoss: loss.loss, contractHash: run.contractHash };
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// ---- Ledger / schema health for `doctor` ---------------------------------------------------
|
|
1400
|
+
export function runHealth(run) {
|
|
1401
|
+
if (!run) return { ok: true, state: "NO_RUN", candidateLedger: "empty", evidence: "n/a" };
|
|
1402
|
+
const st = run.state || {};
|
|
1403
|
+
const ledger = (st.candidates || []);
|
|
1404
|
+
const badCandidate = ledger.find((c) => c.counted && (c.fingerprint == null || c.lossBefore == null && c.lossAfter != null));
|
|
1405
|
+
const ev = evidenceStatus(run);
|
|
1406
|
+
return {
|
|
1407
|
+
ok: !badCandidate,
|
|
1408
|
+
state: run.status,
|
|
1409
|
+
candidateLedger: `${EV.candidateCount(run)} counted / ${ledger.length} recorded`,
|
|
1410
|
+
evidence: `${ev.gaps.length} gaps; ${ev.failures.length} failures`,
|
|
1411
|
+
issues: [badCandidate ? "candidate ledger inconsistency" : null, ...ev.gaps.map((g) => `gap ${g.criterion}`)].filter(Boolean),
|
|
1412
|
+
};
|
|
1413
|
+
}
|