faberun 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/launch.mjs +20 -3
- package/src/cli/plan.mjs +56 -5
- package/src/cli.mjs +5 -1
- package/src/contract/definition-of-done.mjs +55 -0
- package/src/contract/index.mjs +1 -1
- package/src/contract/runtime.mjs +15 -5
- package/src/contract/worker-result.mjs +27 -11
- package/src/engine/live-preflight.mjs +4 -1
- package/src/engine/prompts.mjs +17 -1
- package/src/engine/scheduler.mjs +8 -1
- package/src/plan/freeze.mjs +50 -13
- package/src/plan/pipeline.mjs +123 -20
- package/src/plan/progress.mjs +101 -0
- package/src/plan/proof-check.mjs +13 -1
- package/src/plan/proof-scope.mjs +1 -1
- package/src/plan/repo-facts.mjs +64 -11
- package/src/plan/resolve.mjs +6 -4
- package/src/plan/rounds.mjs +37 -13
- package/src/plan/sizing.mjs +20 -4
- package/src/plan/spec.mjs +82 -6
- package/src/plan/template.mjs +182 -37
- package/src/repo/scope-closure.mjs +22 -0
- package/src/repo/worktree-prune.mjs +13 -5
- package/src/repo/worktree.mjs +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli/launch.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { bootstrapAckPath, bootstrapAttemptPath, bootstrapPath, cleanupBootstrap
|
|
|
16
16
|
import { bootstrapFailureMatchesChild, bootstrapMatchesChild, processStartToken, readLock, sameProcessStartToken, validBootstrapNonce } from "../run/lock.mjs";
|
|
17
17
|
import { cleanupBootstrapNonce, discardNodeLessRunDirectory } from "../engine/detach.mjs";
|
|
18
18
|
import { delay, errorCode } from "../util.mjs";
|
|
19
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { join, resolve } from "node:path";
|
|
22
22
|
import { randomUUID } from "node:crypto";
|
|
@@ -232,16 +232,33 @@ export function bootstrapRunDir(command, target) {
|
|
|
232
232
|
*/
|
|
233
233
|
export function writeBootstrapFailure(command, target, error) {
|
|
234
234
|
const runDir = bootstrapRunDir(command, target);
|
|
235
|
-
if (!runDir
|
|
235
|
+
if (!runDir) return;
|
|
236
236
|
const nonce = validBootstrapNonce(process.env.FABERUN_BOOTSTRAP_NONCE) ? process.env.FABERUN_BOOTSTRAP_NONCE : null;
|
|
237
237
|
const failure = { status: "failed", pid: process.pid, processStartToken: processStartToken(process.pid), runDir, nonce, at: new Date().toISOString(), error: error.message };
|
|
238
|
+
const detachedChild = nonce !== null && !process.argv.includes("--detach");
|
|
239
|
+
if (!existsSync(runDir)) {
|
|
240
|
+
// A refusal that happens before the run directory exists — a dirty tree, a
|
|
241
|
+
// base ref that does not resolve — is the one the launcher cannot
|
|
242
|
+
// otherwise learn: the child's stdio is discarded, so all it could say was
|
|
243
|
+
// "failed before readiness for pid N". Measured 2026-09-27 (AP10): a whole
|
|
244
|
+
// `plan` stage died that way over two uncommitted paths. Only a detached
|
|
245
|
+
// child creates the directory, and only to carry this record;
|
|
246
|
+
// `waitForBootstrap` removes a node-less one as it reads it.
|
|
247
|
+
if (!detachedChild) return;
|
|
248
|
+
try {
|
|
249
|
+
mkdirSync(runDir, { recursive: true });
|
|
250
|
+
} catch {
|
|
251
|
+
// The refusal being reported matters more than the directory that would
|
|
252
|
+
// have carried it; without one the launcher falls back to its own message.
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
238
256
|
// A `run` that names an existing run directory is refused before it writes
|
|
239
257
|
// anything, and its failure must not be stamped into that run's bootstrap
|
|
240
258
|
// record. The detached child is the exception: it wrote contract.json and
|
|
241
259
|
// then died before readiness, and the launcher has no other channel to
|
|
242
260
|
// learn the controller's error, so it must record that error before it
|
|
243
261
|
// exits.
|
|
244
|
-
const detachedChild = nonce !== null && !process.argv.includes("--detach");
|
|
245
262
|
if (command === "run" && !detachedChild && existsSync(join(runDir, "contract.json"))) return;
|
|
246
263
|
/** @type {BootstrapRecord|null} */
|
|
247
264
|
let current = null;
|
package/src/cli/plan.mjs
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* sequencing and every decision the pipeline makes.
|
|
6
6
|
*/
|
|
7
7
|
import { validateJudgeIndependence } from "../contract/judge-independence.mjs";
|
|
8
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
10
10
|
import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
|
|
11
11
|
import { classifyRunProgress } from "../campaign/chain.mjs";
|
|
12
12
|
import { runProgress } from "../engine/supervise.mjs";
|
|
@@ -17,6 +17,7 @@ import { colorLevel, statusToken } from "./brand.mjs";
|
|
|
17
17
|
import { delay } from "../util.mjs";
|
|
18
18
|
import { runPlanningPipeline } from "../plan/pipeline.mjs";
|
|
19
19
|
import { parseAnswerFlags, resolvePlanningPipeline } from "../plan/resolve.mjs";
|
|
20
|
+
import { abandonedPlanProgress } from "../plan/progress.mjs";
|
|
20
21
|
import { resolveReviewerList } from "../plan/reviewer.mjs";
|
|
21
22
|
import { campaignTree, runDirectory } from "../run/paths.mjs";
|
|
22
23
|
import { readCampaign } from "../campaign/record.mjs";
|
|
@@ -112,6 +113,46 @@ export function loadRuntimesCatalogue(path) {
|
|
|
112
113
|
return runtimes;
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The campaign's own checked-in planning inputs live beside the spec it
|
|
118
|
+
* already keeps in the repository: `docs/campaigns/<id>/plan-inputs/`. A file
|
|
119
|
+
* that exists there is used as if it had been passed on the command line,
|
|
120
|
+
* because that is what it is for; an absent one changes nothing.
|
|
121
|
+
*
|
|
122
|
+
* Measured 2026-09-27 (AP19): a campaign carried
|
|
123
|
+
* `plan-inputs/runtimes.json` with a raised output ceiling, `plan` fell back
|
|
124
|
+
* to the 8192 default without a word, and the operator found out only after
|
|
125
|
+
* burning rounds on a truncated draft.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} cwd @param {string} campaignId @param {string} file
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
function campaignInputPath(cwd, campaignId, file) {
|
|
131
|
+
return join(cwd, "docs", "campaigns", campaignId, "plan-inputs", file);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The catalogue a `plan` reads runtimes from, and the file it came from when
|
|
136
|
+
* that file is the campaign's own. `--runtimes` wins over the checked-in one:
|
|
137
|
+
* an operator passing a path is overriding, not asking.
|
|
138
|
+
*
|
|
139
|
+
* `source` is returned rather than printed here so a caller decides the
|
|
140
|
+
* wording; `planCli` says it out loud because silence is the whole defect. The
|
|
141
|
+
* path is resolved from the launch cwd, never from the plan's own target, so
|
|
142
|
+
* the same command from a subdirectory reads the same file the campaign does.
|
|
143
|
+
*
|
|
144
|
+
* @param {{cwd: string, campaignId: string, runtimes?: unknown}} options
|
|
145
|
+
* @returns {{runtimes: Record<string, import("../contract/index.mjs").ValidatedRuntime>, source: string|null}}
|
|
146
|
+
*/
|
|
147
|
+
export function resolvePlanRuntimes({ cwd, campaignId, runtimes }) {
|
|
148
|
+
if (typeof runtimes === "string" && runtimes) return { runtimes: loadRuntimesCatalogue(runtimes), source: null };
|
|
149
|
+
const campaignRuntimes = campaignInputPath(cwd, campaignId, "runtimes.json");
|
|
150
|
+
if (!existsSync(campaignRuntimes)) {
|
|
151
|
+
return { runtimes: /** @type {Record<string, import("../contract/index.mjs").ValidatedRuntime>} */ (DISCOVERY_RUNTIME_DEFINITIONS), source: null };
|
|
152
|
+
}
|
|
153
|
+
return { runtimes: loadRuntimesCatalogue(campaignRuntimes), source: campaignRuntimes };
|
|
154
|
+
}
|
|
155
|
+
|
|
115
156
|
/**
|
|
116
157
|
* A `--verification <path>` catalogue: a JSON object carrying either or both
|
|
117
158
|
* of the contract's own suite keys, `sharedVerification` and
|
|
@@ -160,9 +201,10 @@ export async function planCli(target, values) {
|
|
|
160
201
|
// R19: the planner's own reviewer list, never `runtimeDefaults.judge` --
|
|
161
202
|
// `--reviewers` wins over the machine's own `config.reviewers` default.
|
|
162
203
|
const reviewers = resolveReviewerList({ reviewers: parseReviewerList(values.reviewers) }, readUserConfig(process.env));
|
|
163
|
-
const runtimes =
|
|
164
|
-
|
|
165
|
-
|
|
204
|
+
const { runtimes, source: runtimesSource } = resolvePlanRuntimes({ cwd: process.cwd(), campaignId, runtimes: values.runtimes });
|
|
205
|
+
// Said out loud because silence is the whole defect: the default and the
|
|
206
|
+
// campaign's own catalogue look identical from the outside.
|
|
207
|
+
if (runtimesSource) process.stdout.write(`[plan] runtimes · ${relative(process.cwd(), runtimesSource)}\n`);
|
|
166
208
|
const verification = typeof values.verification === "string" && values.verification
|
|
167
209
|
? loadVerificationSuites(values.verification)
|
|
168
210
|
: {};
|
|
@@ -171,6 +213,15 @@ export async function planCli(target, values) {
|
|
|
171
213
|
? undefined
|
|
172
214
|
: validateJudgeIndependence(values["judge-independence"], "--judge-independence");
|
|
173
215
|
|
|
216
|
+
// A record with no live process behind it is the only trace a planning run
|
|
217
|
+
// that died without a word leaves. Measured 2026-09-26 (AP4): `--detach` pid
|
|
218
|
+
// 64270 vanished during repo-facts and the phase directory stayed empty, so
|
|
219
|
+
// the next launch is the one place the operator can still be told.
|
|
220
|
+
const abandoned = abandonedPlanProgress(process.cwd(), campaignId, phase);
|
|
221
|
+
if (abandoned) {
|
|
222
|
+
process.stderr.write(`[plan] a previous planning process (pid ${String(abandoned.pid ?? "?")}) stopped during ${String(abandoned.stage ?? "?")} at ${String(abandoned.at ?? "?")} and never reached the end\n`);
|
|
223
|
+
}
|
|
224
|
+
|
|
174
225
|
if (values.detach === true) {
|
|
175
226
|
const argv = ["plan", specPath, "--campaign", campaignId, "--phase", phase, "--review-rounds", String(reviewRounds)];
|
|
176
227
|
if (approveBelow !== undefined) argv.push("--approve-below", approveBelow);
|
package/src/cli.mjs
CHANGED
|
@@ -579,7 +579,11 @@ function advisoryToken() {
|
|
|
579
579
|
function prunedLine(pruned) {
|
|
580
580
|
if (!pruned.length) return "[prune] no finished, integrated run holds a worktree";
|
|
581
581
|
const removed = pruned.reduce((sum, run) => sum + run.removed, 0);
|
|
582
|
-
|
|
582
|
+
const branches = pruned.reduce((sum, run) => sum + run.branches.length, 0);
|
|
583
|
+
const line = removed === 0
|
|
584
|
+
? "[prune] an earlier prune already released every worktree of an integrated run"
|
|
585
|
+
: `[prune] released ${removed} worktree${removed === 1 ? "" : "s"} of ${pruned.length} integrated run${pruned.length === 1 ? "" : "s"} · each kept under refs/faberun-archive/`;
|
|
586
|
+
return branches === 0 ? line : `${line} · deleted ${branches} attempt branch${branches === 1 ? "" : "es"} already held by another ref`;
|
|
583
587
|
}
|
|
584
588
|
|
|
585
589
|
/**
|
|
@@ -279,6 +279,61 @@ export function assertInlineScriptProofsParse(nodes) {
|
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
/**
|
|
283
|
+
* The quote a command proof's text leaves open, or null when every quote it
|
|
284
|
+
* opens is closed. `kind: "command"` goes through `/bin/sh -c`, so a text with
|
|
285
|
+
* an odd number of `'` or `"` is refused by the shell before the command runs,
|
|
286
|
+
* with `unexpected EOF while looking for matching`. Measured 2026-09-27 (AP13
|
|
287
|
+
* of safe-to-hand-to-friend): the freeze accepted
|
|
288
|
+
* `node --test --test-name-pattern=a stranger's first campaign completes
|
|
289
|
+
* offline test/…` — an apostrophe inside an unquoted test title — the node did
|
|
290
|
+
* the work and passed its own test in 15 s, and both attempts were spent on a
|
|
291
|
+
* command `/bin/sh` never ran.
|
|
292
|
+
*
|
|
293
|
+
* This is a quote scanner, not a shell parser: it reports the two failures a
|
|
294
|
+
* proof can carry without expanding anything (an open quote, or nothing at
|
|
295
|
+
* all), and leaves every other shell construct to the shell. A `$'…'` string
|
|
296
|
+
* and a quote inside a here-document are the constructs it does not model.
|
|
297
|
+
*
|
|
298
|
+
* @param {string} commandText
|
|
299
|
+
* @returns {string|null}
|
|
300
|
+
*/
|
|
301
|
+
export function unclosedQuote(commandText) {
|
|
302
|
+
/** @type {string|null} */
|
|
303
|
+
let quote = null;
|
|
304
|
+
let escaped = false;
|
|
305
|
+
for (const character of commandText) {
|
|
306
|
+
if (escaped) { escaped = false; continue; }
|
|
307
|
+
// Outside double quotes a backslash escapes the next character; a
|
|
308
|
+
// backslash inside single quotes is literal, which POSIX also gives it.
|
|
309
|
+
if (quote !== "'" && character === "\\") { escaped = true; continue; }
|
|
310
|
+
if (quote === null && (character === "'" || character === '"')) { quote = character; continue; }
|
|
311
|
+
if (quote === character) quote = null;
|
|
312
|
+
}
|
|
313
|
+
return quote;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Refuse a contract whose command proof the shell cannot parse, naming the
|
|
318
|
+
* node, the item and the open quote. The freeze and the launch both call it:
|
|
319
|
+
* a command no attempt can run is the same waste as an inline script that
|
|
320
|
+
* cannot parse, and cheaper to refuse than to discover.
|
|
321
|
+
*
|
|
322
|
+
* @param {{id?: unknown, definitionOfDone?: {id?: unknown, proof?: {kind?: unknown, ref?: unknown}}[]}[]} nodes
|
|
323
|
+
* @returns {void}
|
|
324
|
+
*/
|
|
325
|
+
export function assertProofCommandsParse(nodes) {
|
|
326
|
+
for (const node of nodes) {
|
|
327
|
+
for (const item of node.definitionOfDone ?? []) {
|
|
328
|
+
if (item.proof?.kind !== "command" || typeof item.proof.ref !== "string") continue;
|
|
329
|
+
const quote = unclosedQuote(item.proof.ref);
|
|
330
|
+
if (quote === null) continue;
|
|
331
|
+
const nodeName = node.id === undefined ? "a node" : `node ${node.id}`;
|
|
332
|
+
throw new TypeError(`${nodeName}, Definition of Done item "${item.id}": the proof command leaves a ${quote} open, so /bin/sh refuses it before it runs ("unexpected EOF while looking for matching \`${quote}'"). Quote the value, as in --test-name-pattern="<title>".`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
282
337
|
/** @param {string} token @returns {boolean} */
|
|
283
338
|
function isNodeExecutable(token) {
|
|
284
339
|
const base = (token.split(/[\\/]/u).pop() ?? token).toLowerCase();
|
package/src/contract/index.mjs
CHANGED
|
@@ -153,7 +153,7 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
153
153
|
throw new TypeError("contract.runtimes must be an object");
|
|
154
154
|
}
|
|
155
155
|
const runtimes = /** @type {Record<string, ValidatedRuntime>} */ ({});
|
|
156
|
-
for (const [id, runtime] of Object.entries(rawRuntimes)) runtimes[id] = validateRuntime(id, runtime);
|
|
156
|
+
for (const [id, runtime] of Object.entries(rawRuntimes)) runtimes[id] = validateRuntime(id, runtime, { persisted });
|
|
157
157
|
// A runtime's fallback is validated against sibling runtimes once every
|
|
158
158
|
// runtime is known, so declaration order never matters.
|
|
159
159
|
for (const [id, runtime] of Object.entries(runtimes)) {
|
package/src/contract/runtime.mjs
CHANGED
|
@@ -93,9 +93,11 @@ export function routeRuntime(contract, node, role = "worker", event = {}) {
|
|
|
93
93
|
/**
|
|
94
94
|
* @param {string} id
|
|
95
95
|
* @param {unknown} runtime
|
|
96
|
+
* @param {{persisted?: boolean}} [options] `persisted` is a replay of a contract
|
|
97
|
+
* already accepted at launch; see the vendor rule below.
|
|
96
98
|
* @returns {ValidatedRuntime}
|
|
97
99
|
*/
|
|
98
|
-
export function validateRuntime(id, runtime) {
|
|
100
|
+
export function validateRuntime(id, runtime, options = {}) {
|
|
99
101
|
requireId(id, `runtime ${id}`);
|
|
100
102
|
assertObject(runtime, `runtime ${id}`);
|
|
101
103
|
rejectUnknown(runtime, RUNTIME_FIELDS, `runtime ${id}`);
|
|
@@ -103,16 +105,24 @@ export function validateRuntime(id, runtime) {
|
|
|
103
105
|
const typedRuntime = /** @type {{harness: string, model: string, vendor?: string, config?: Record<string, unknown>}} */ (runtime);
|
|
104
106
|
// The canonical provider derived from the route, the model's family, or the
|
|
105
107
|
// harness default outranks a declared `vendor`: a label that contradicts it
|
|
106
|
-
// is refused (R18), and the runtime's vendor becomes the derived
|
|
107
|
-
// rather than whatever text the contract declared. `replay` and
|
|
108
|
-
// derive none — for those the declared vendor stands, as it
|
|
108
|
+
// is refused (R18) at authoring, and the runtime's vendor becomes the derived
|
|
109
|
+
// provider rather than whatever text the contract declared. `replay` and
|
|
110
|
+
// `exec-jsonl` derive none — for those the declared vendor stands, as it
|
|
111
|
+
// always has. A persisted load takes the derived provider without refusing,
|
|
112
|
+
// so a run launched before R18 stays readable and cancelable.
|
|
109
113
|
const derived = canonicalProvider(typedRuntime);
|
|
110
114
|
const declared = typeof typedRuntime.vendor === "string" && typedRuntime.vendor.length ? typedRuntime.vendor : undefined;
|
|
111
115
|
let vendor;
|
|
112
116
|
if (derived) {
|
|
113
|
-
if (declared !== undefined && declared !== derived) {
|
|
117
|
+
if (declared !== undefined && declared !== derived && options.persisted !== true) {
|
|
114
118
|
throw new TypeError(`runtime ${id} declares vendor ${declared} but ${typedRuntime.harness} ${typedRuntime.model} is provider ${derived}`);
|
|
115
119
|
}
|
|
120
|
+
// A replay canonicalizes a contradicting label instead of refusing it.
|
|
121
|
+
// Measured 2026-09-27 (AP6): three runs launched before R18 carried
|
|
122
|
+
// `vendor: anthropic-sonnet` against the model claude-sonnet-5, and every
|
|
123
|
+
// reader of their persisted contract — `cancel`, `status`, `report` — threw
|
|
124
|
+
// on a rule those contracts predate, leaving the runs unsuspended forever.
|
|
125
|
+
// The rule still refuses the label where it can be acted on: at authoring.
|
|
116
126
|
vendor = derived;
|
|
117
127
|
} else {
|
|
118
128
|
vendor = resolveVendor(typedRuntime);
|
|
@@ -43,12 +43,24 @@ export const SANDBOX_BLOCKED_WRITE = "sandbox_blocked_write";
|
|
|
43
43
|
* fences it never wrote, and repeated the copy.
|
|
44
44
|
*/
|
|
45
45
|
export class WorkerResultSizeError extends TypeError {
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
/**
|
|
47
|
+
* `actual` is the measured byte count, never optional: measured 2026-09-28
|
|
48
|
+
* (AP18 of safe-to-hand-to-friend), a GLM worker returned an `artifacts[0]`
|
|
49
|
+
* of 19,810 bytes against the 16,384-byte ceiling and the node failed with
|
|
50
|
+
* "worker result.artifacts[0] exceeds 16384 bytes" -- the operator could not
|
|
51
|
+
* tell a 20 KiB artifact from a 1 MiB one, and the ceiling reads the same
|
|
52
|
+
* whether the worker overshot by 3 KiB or by ten times.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} field
|
|
55
|
+
* @param {number} limit
|
|
56
|
+
* @param {number} actual
|
|
57
|
+
*/
|
|
58
|
+
constructor(field, limit, actual) {
|
|
59
|
+
super(`${field} is ${actual} bytes, over the ${limit}-byte ceiling`);
|
|
49
60
|
this.name = "WorkerResultSizeError";
|
|
50
61
|
this.field = field;
|
|
51
62
|
this.limit = limit;
|
|
63
|
+
this.actual = actual;
|
|
52
64
|
}
|
|
53
65
|
}
|
|
54
66
|
|
|
@@ -73,7 +85,7 @@ export class WorkerResultSizeError extends TypeError {
|
|
|
73
85
|
*/
|
|
74
86
|
export function invalidResultRepair(cause) {
|
|
75
87
|
if (cause instanceof WorkerResultSizeError) {
|
|
76
|
-
return `the result breaks a size ceiling: ${cause.field}
|
|
88
|
+
return `the result breaks a size ceiling: ${cause.field} is ${cause.actual} bytes, over the ${cause.limit}-byte ceiling. Keep every field within its ceiling; a node that delivers through \`output\` sends \`artifacts\` as [] and never copies \`output\` into \`artifacts\`.`;
|
|
77
89
|
}
|
|
78
90
|
return "the entire final message must be exactly the required JSON object: no markdown fences, no prose before or after it. Return it as the only content of the final message.";
|
|
79
91
|
}
|
|
@@ -85,8 +97,9 @@ export function invalidResultRepair(cause) {
|
|
|
85
97
|
export function parseWorkerResult(value) {
|
|
86
98
|
if (typeof value !== "string") throw new TypeError("worker result must be JSON text");
|
|
87
99
|
const maxRawBytes = RESULT_LIMITS.bytes + RESULT_LIMITS.outputBytes;
|
|
88
|
-
|
|
89
|
-
|
|
100
|
+
const rawBytes = Buffer.byteLength(value, "utf8");
|
|
101
|
+
if (rawBytes > maxRawBytes) {
|
|
102
|
+
throw new WorkerResultSizeError("worker result", maxRawBytes, rawBytes);
|
|
90
103
|
}
|
|
91
104
|
let parsed;
|
|
92
105
|
try {
|
|
@@ -140,8 +153,9 @@ export function validateWorkerResult(value) {
|
|
|
140
153
|
let output;
|
|
141
154
|
if (Object.hasOwn(record, "output") && record.output !== undefined) {
|
|
142
155
|
assertObject(record.output, "worker result.output");
|
|
143
|
-
|
|
144
|
-
|
|
156
|
+
const outputBytes = Buffer.byteLength(JSON.stringify(record.output), "utf8");
|
|
157
|
+
if (outputBytes > RESULT_LIMITS.outputBytes) {
|
|
158
|
+
throw new WorkerResultSizeError("worker result.output", RESULT_LIMITS.outputBytes, outputBytes);
|
|
145
159
|
}
|
|
146
160
|
output = /** @type {Record<string, unknown>} */ (record.output);
|
|
147
161
|
}
|
|
@@ -155,8 +169,9 @@ export function validateWorkerResult(value) {
|
|
|
155
169
|
// `output` is bounded on its own above and kept outside this envelope cap:
|
|
156
170
|
// it is a discovery node's deliverable, not incidental prose competing with
|
|
157
171
|
// summary/verification for the same 32 KiB budget.
|
|
158
|
-
|
|
159
|
-
|
|
172
|
+
const envelopeBytes = Buffer.byteLength(JSON.stringify(envelope), "utf8");
|
|
173
|
+
if (envelopeBytes > RESULT_LIMITS.bytes) {
|
|
174
|
+
throw new WorkerResultSizeError("worker result", RESULT_LIMITS.bytes, envelopeBytes);
|
|
160
175
|
}
|
|
161
176
|
return output === undefined ? envelope : { ...envelope, output };
|
|
162
177
|
}
|
|
@@ -206,6 +221,7 @@ function requireList(value, label, maxItems, itemBytes) {
|
|
|
206
221
|
if (!Array.isArray(value) || value.length > maxItems) throw new TypeError(`${label} must be an array with at most ${maxItems} items`);
|
|
207
222
|
for (const [index, item] of value.entries()) {
|
|
208
223
|
if (typeof item !== "string") throw new TypeError(`${label}[${index}] must be a string`);
|
|
209
|
-
|
|
224
|
+
const bytes = Buffer.byteLength(item, "utf8");
|
|
225
|
+
if (bytes > itemBytes) throw new WorkerResultSizeError(`${label}[${index}]`, itemBytes, bytes);
|
|
210
226
|
}
|
|
211
227
|
}
|
|
@@ -23,7 +23,7 @@ import { spawn } from "node:child_process";
|
|
|
23
23
|
import { boundedGitSync } from "../repo/worktree.mjs";
|
|
24
24
|
import { tmpdir } from "node:os";
|
|
25
25
|
import { validateContract } from "../contract/index.mjs";
|
|
26
|
-
import { assertInlineScriptProofsParse } from "../contract/definition-of-done.mjs";
|
|
26
|
+
import { assertInlineScriptProofsParse, assertProofCommandsParse } from "../contract/definition-of-done.mjs";
|
|
27
27
|
import { validateNodeSnapshot } from "../contract/snapshot.mjs";
|
|
28
28
|
import { compactCost as formatCost } from "../util.mjs";
|
|
29
29
|
import { runsRoot } from "../run/paths.mjs";
|
|
@@ -97,6 +97,9 @@ export async function preflightContract(contractPath, options = {}) {
|
|
|
97
97
|
// first dispatch, so a body no attempt could ever make exit 0 refuses the
|
|
98
98
|
// launch here, naming its node and item, instead of after three paid tries.
|
|
99
99
|
assertInlineScriptProofsParse(contract.nodes);
|
|
100
|
+
// AP13: a proof command /bin/sh cannot parse never runs, so it refuses here
|
|
101
|
+
// for the same reason an inline script that does not parse does.
|
|
102
|
+
assertProofCommandsParse(contract.nodes);
|
|
100
103
|
return preflightRuntimes([...reachableRuntimes(contract).values()], { ...options, cwd: contract.cwd });
|
|
101
104
|
}
|
|
102
105
|
|
package/src/engine/prompts.mjs
CHANGED
|
@@ -267,6 +267,22 @@ export function judgePrompt(node, workerResult, context = {}) {
|
|
|
267
267
|
"Assess every Definition of Done item by its id and cite the id you are addressing in each finding.");
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
/**
|
|
271
|
+
* The one rule the judge has never been given, added after N5 of the
|
|
272
|
+
* `open-source-readiness` campaign (AP20). A requirement that reads "the
|
|
273
|
+
* guard refuses these paths, except the declared ones" enumerates its cases,
|
|
274
|
+
* and a delivery that holds the general shape without covering one of them
|
|
275
|
+
* passes a judge that only checks whether the shape holds. Measured
|
|
276
|
+
* 2026-09-27: the `guards-core` node shipped the guard without the default
|
|
277
|
+
* exclusions its requirement declared, the judge approved, and the operator
|
|
278
|
+
* had to intervene twice on the integration ref for the phase to close.
|
|
279
|
+
*
|
|
280
|
+
* The rule sits with the list it governs, so it is stated exactly when there
|
|
281
|
+
* are judgment items to arbitrate and never when there are none.
|
|
282
|
+
*/
|
|
283
|
+
const JUDGE_ENUMERATED_ITEM_RULE =
|
|
284
|
+
"When an item enumerates cases, classes or exclusions (\"A, B and C\", \"every X except Y\"), it is satisfied only when the delivery covers every one of them, checked one by one against the diff: the general shape holding is not coverage. A finding that names such an item must say which of them is missing, and an approval that checked none of them is a protocol failure.";
|
|
285
|
+
|
|
270
286
|
/**
|
|
271
287
|
* The Definition of Done checklist for the judge prompt: judgment items the
|
|
272
288
|
* judge must arbitrate, plus each deterministic item with the controller-run
|
|
@@ -283,7 +299,7 @@ function judgeDoDChecklist(items, deterministic) {
|
|
|
283
299
|
const parts = [];
|
|
284
300
|
const judgmentItems = items.filter((item) => item.judgment === true);
|
|
285
301
|
parts.push(judgmentItems.length
|
|
286
|
-
? `Judgment items — arbitrate only these:\n${judgmentItems.map((item) => `- [${item.id}] ${item.text} (judgment)`).join("\n")}`
|
|
302
|
+
? `Judgment items — arbitrate only these:\n${judgmentItems.map((item) => `- [${item.id}] ${item.text} (judgment)`).join("\n")}\n${JUDGE_ENUMERATED_ITEM_RULE}`
|
|
287
303
|
: "No judgment items require arbitration.");
|
|
288
304
|
const proofItems = items.filter((item) => item.proof !== undefined);
|
|
289
305
|
if (proofItems.length) {
|
package/src/engine/scheduler.mjs
CHANGED
|
@@ -759,7 +759,14 @@ export async function driveRun(contract, runDir, states, campaign, lock, sourceI
|
|
|
759
759
|
heartbeat.stop();
|
|
760
760
|
lock.release();
|
|
761
761
|
}
|
|
762
|
-
|
|
762
|
+
// Forced, and after the lock is released: the change-detected render here
|
|
763
|
+
// was a no-op whenever the last tick had already written the same node
|
|
764
|
+
// states, which is the usual case, so the stored status.json kept the
|
|
765
|
+
// `controller: active` the last tick wrote while the lock was still held.
|
|
766
|
+
// Measured 2026-09-27 (AP12): `safe-to-hand-to-a-friend-phase-2` finished
|
|
767
|
+
// with `state: "active"` and pid 43002, and a watcher waiting for the
|
|
768
|
+
// controller to leave spun for two hours on a pid that no longer existed.
|
|
769
|
+
renderStatusIfChanged(true, null);
|
|
763
770
|
renderCampaignHandoffSafely(campaign, runsDir, runDir);
|
|
764
771
|
writeFindingsArtifact(runDir, contract, states);
|
|
765
772
|
const failed = [...states.values()].filter((state) => state.status !== "done");
|
package/src/plan/freeze.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { assertObject, rejectUnknown, requirePacketHash, requireString } from ".
|
|
|
21
21
|
import { writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
|
|
22
22
|
import { VERIFICATION_LIMITS } from "../contract/verification.mjs";
|
|
23
23
|
import { validatePlanPhases } from "./template.mjs";
|
|
24
|
+
import { assertProofCommandsParse } from "../contract/definition-of-done.mjs";
|
|
24
25
|
import { NODE_VALUE_OPTIONS, assertFilteredProofsNameTheirTest } from "./proof-scope.mjs";
|
|
25
26
|
|
|
26
27
|
/** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
|
|
@@ -134,6 +135,33 @@ export function assertTimeoutsCoverMeasured(contract, facts) {
|
|
|
134
135
|
if (problems.size) throw new TypeError(`verification timeouts do not cover their measured durations: ${[...problems].join("; ")}`);
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Raise whichever of a command list's timeouts sit under
|
|
140
|
+
* `MEASURED_TIMEOUT_MARGIN` times their measured duration, recording each
|
|
141
|
+
* raise under `label`. Returns the list unchanged (by identity) when nothing
|
|
142
|
+
* needed raising, so the caller can tell whether to re-list.
|
|
143
|
+
*
|
|
144
|
+
* @param {import("../contract/index.mjs").VerificationCommand[]} commands
|
|
145
|
+
* @param {MeasuredFacts} facts
|
|
146
|
+
* @param {string} label
|
|
147
|
+
* @param {string[]} raised
|
|
148
|
+
* @returns {import("../contract/index.mjs").VerificationCommand[]}
|
|
149
|
+
*/
|
|
150
|
+
function raiseCommandTimeouts(commands, facts, label, raised) {
|
|
151
|
+
let changed = false;
|
|
152
|
+
const next = commands.map((command) => {
|
|
153
|
+
const measuredMs = measuredMsFor(command.argv, facts);
|
|
154
|
+
if (measuredMs === null) return command;
|
|
155
|
+
const requiredSec = Math.ceil((measuredMs * MEASURED_TIMEOUT_MARGIN) / 1_000);
|
|
156
|
+
const timeoutSec = command.timeoutSec ?? 120;
|
|
157
|
+
if (timeoutSec >= requiredSec || requiredSec > VERIFICATION_LIMITS.maxTimeoutSec) return command;
|
|
158
|
+
raised.push(`${label}: ${command.argv.join(" ")} ${timeoutSec}s -> ${requiredSec}s`);
|
|
159
|
+
changed = true;
|
|
160
|
+
return { ...command, timeoutSec: requiredSec };
|
|
161
|
+
});
|
|
162
|
+
return changed ? next : commands;
|
|
163
|
+
}
|
|
164
|
+
|
|
137
165
|
/**
|
|
138
166
|
* Raise every plan node's verification timeout that sits under
|
|
139
167
|
* `MEASURED_TIMEOUT_MARGIN` times its measured duration to exactly that
|
|
@@ -144,6 +172,11 @@ export function assertTimeoutsCoverMeasured(contract, facts) {
|
|
|
144
172
|
* plan with no critical from review, contested only because three nodes gave
|
|
145
173
|
* `node --test test/harnesses` 120s against a measured 84.4s (bound 127s).
|
|
146
174
|
*
|
|
175
|
+
* The plan's own contract-level suites (RM-107) are raised here too, over the
|
|
176
|
+
* same measurement: `assertTimeoutsCoverMeasured` reads them, so a repair that
|
|
177
|
+
* skipped them would leave the check naming a repair this function does not
|
|
178
|
+
* make. Its label is the suite key, since a contract suite belongs to no node.
|
|
179
|
+
*
|
|
147
180
|
* @param {import("./template.mjs").PlanOutput} plan
|
|
148
181
|
* @param {MeasuredFacts} facts
|
|
149
182
|
* @returns {{plan: import("./template.mjs").PlanOutput, raised: string[]}}
|
|
@@ -151,19 +184,22 @@ export function assertTimeoutsCoverMeasured(contract, facts) {
|
|
|
151
184
|
export function raiseTimeoutsToMeasured(plan, facts) {
|
|
152
185
|
/** @type {string[]} */
|
|
153
186
|
const raised = [];
|
|
154
|
-
const nodes = plan.nodes.map((node) =>
|
|
155
|
-
|
|
156
|
-
verification
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
187
|
+
const nodes = plan.nodes.map((node) => {
|
|
188
|
+
const verification = raiseCommandTimeouts(node.verification ?? [], facts, node.id, raised);
|
|
189
|
+
return verification === node.verification ? node : { ...node, verification };
|
|
190
|
+
});
|
|
191
|
+
const sharedVerification = raiseCommandTimeouts(plan.sharedVerification ?? [], facts, "sharedVerification", raised);
|
|
192
|
+
const finalVerification = raiseCommandTimeouts(plan.finalVerification ?? [], facts, "finalVerification", raised);
|
|
193
|
+
if (raised.length === 0) return { plan, raised };
|
|
194
|
+
return {
|
|
195
|
+
plan: {
|
|
196
|
+
...plan,
|
|
197
|
+
nodes,
|
|
198
|
+
...(plan.sharedVerification === undefined ? {} : { sharedVerification }),
|
|
199
|
+
...(plan.finalVerification === undefined ? {} : { finalVerification }),
|
|
200
|
+
},
|
|
201
|
+
raised,
|
|
202
|
+
};
|
|
167
203
|
}
|
|
168
204
|
|
|
169
205
|
/** @returns {string} the installed package's own version, read once per call so a freeze always names the toolchain that produced it */
|
|
@@ -413,6 +449,7 @@ export function freezePlan(plan, { outDir, provenance, phases, spec, humanSteps,
|
|
|
413
449
|
try {
|
|
414
450
|
const validated = validateContract(raw, contractPath);
|
|
415
451
|
assertFilteredProofsNameTheirTest(validated);
|
|
452
|
+
assertProofCommandsParse(validated.nodes);
|
|
416
453
|
if (facts) assertTimeoutsCoverMeasured(validated, facts);
|
|
417
454
|
} catch (error) {
|
|
418
455
|
rmSync(contractPath, { force: true });
|