faberun 0.28.0 → 0.29.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 +2 -0
- package/src/plan/pipeline.mjs +56 -4
- 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 +35 -4
- package/src/plan/spec.mjs +82 -6
- package/src/plan/template.mjs +9 -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.29.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 */
|
|
@@ -413,6 +414,7 @@ export function freezePlan(plan, { outDir, provenance, phases, spec, humanSteps,
|
|
|
413
414
|
try {
|
|
414
415
|
const validated = validateContract(raw, contractPath);
|
|
415
416
|
assertFilteredProofsNameTheirTest(validated);
|
|
417
|
+
assertProofCommandsParse(validated.nodes);
|
|
416
418
|
if (facts) assertTimeoutsCoverMeasured(validated, facts);
|
|
417
419
|
} catch (error) {
|
|
418
420
|
rmSync(contractPath, { force: true });
|
package/src/plan/pipeline.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { validateJudgeIndependence } from "../contract/judge-independence.mjs";
|
|
|
30
30
|
import { firstEligibleReviewer, reviewerProvenanceOf } from "./reviewer.mjs";
|
|
31
31
|
import { parseSpec, validateSpec } from "./spec.mjs";
|
|
32
32
|
import { collectRepoFacts } from "./repo-facts.mjs";
|
|
33
|
+
import { clearPlanProgress, writePlanProgress } from "./progress.mjs";
|
|
33
34
|
import { checkPlanProofs } from "./proof-check.mjs";
|
|
34
35
|
import { collectHumanSteps } from "./human-step.mjs";
|
|
35
36
|
import { RISK_TIERS, TASK_KIND_CATALOGUE_FILE, buildPlanningContract, renderTaskKindCatalogue, validatePlanOutput } from "./template.mjs";
|
|
@@ -38,6 +39,7 @@ import { resolveRuntimes } from "./routing.mjs";
|
|
|
38
39
|
import { contentDigest, freezePlan, writeFrozenPlanRecord } from "./freeze.mjs";
|
|
39
40
|
import { availabilityOf, fileLineCount, highestOf, modelOf, toContractNode, toSizingNode } from "./pipeline-shape.mjs";
|
|
40
41
|
import { campaignTree, runDirectory } from "../run/paths.mjs";
|
|
42
|
+
import { assertLaunchBaseClean } from "../repo/source-identity.mjs";
|
|
41
43
|
import { contestPlan } from "./contest.mjs";
|
|
42
44
|
import { runReviewRounds } from "./rounds.mjs";
|
|
43
45
|
|
|
@@ -105,6 +107,27 @@ const APPROVE_BELOW_VALUES = new Set(["standard", "high", "none"]);
|
|
|
105
107
|
* @returns {Promise<FrozenPipelineResult|ContestedPipelineResult>}
|
|
106
108
|
*/
|
|
107
109
|
export async function runPlanningPipeline(options) {
|
|
110
|
+
try {
|
|
111
|
+
return await runPlanningStages(options);
|
|
112
|
+
} finally {
|
|
113
|
+
// The record of a process that is working must not outlive the work. It is
|
|
114
|
+
// cleared here, in the one place every return and every throw passes
|
|
115
|
+
// through, rather than by a caller: `plan` already removed it, but a
|
|
116
|
+
// caller that forgot left a record that makes the next launch report a
|
|
117
|
+
// death that never happened, and the record is worth nothing if it can be
|
|
118
|
+
// wrong in that direction.
|
|
119
|
+
if (typeof options.campaignId === "string" && typeof options.phase === "string") clearPlanProgress(resolve(options.cwd ?? "."), options.campaignId, options.phase);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The stages themselves, separate from the exported entry point only so that
|
|
125
|
+
* the liveness record is removed on every path out of them.
|
|
126
|
+
*
|
|
127
|
+
* @param {Parameters<typeof runPlanningPipeline>[0]} options
|
|
128
|
+
* @returns {Promise<FrozenPipelineResult|ContestedPipelineResult>}
|
|
129
|
+
*/
|
|
130
|
+
async function runPlanningStages(options) {
|
|
108
131
|
const {
|
|
109
132
|
specPath, campaignId, phase, runtimes, launch, wait,
|
|
110
133
|
reviewRounds = 2, runtimeDefaults = {}, reviewers = [], verification = {}, targetedFix = false, judgeIndependence,
|
|
@@ -125,6 +148,17 @@ export async function runPlanningPipeline(options) {
|
|
|
125
148
|
const campaignPath = campaignTree(cwd, campaignId);
|
|
126
149
|
const campaign = readCampaign(campaignPath);
|
|
127
150
|
if (campaign.status !== "active") throw new Error(`campaign is closed: ${campaignId}`);
|
|
151
|
+
// The run this pipeline launches is the last thing it does, and the run
|
|
152
|
+
// refuses a tree whose HEAD is dirty. Asking the same question here, before
|
|
153
|
+
// the first stage, is the difference between a refusal and sixteen minutes
|
|
154
|
+
// spent on repo facts first. Measured 2026-09-27 (AP10): a `plan` stage that
|
|
155
|
+
// died exactly that way reported "refusing to launch against HEAD: the
|
|
156
|
+
// working tree has 2 uncommitted paths" only when the operator re-ran it in
|
|
157
|
+
// the foreground. Nothing this pipeline writes touches the tree
|
|
158
|
+
// `dirtyTreePaths` reads: the plan artifacts live under the campaign tree in
|
|
159
|
+
// the home, and the scratch relay inside the repository is excluded by the
|
|
160
|
+
// same pathspec the launch uses.
|
|
161
|
+
assertLaunchBaseClean(cwd, undefined);
|
|
128
162
|
refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode, judgeIndependence);
|
|
129
163
|
refusePlanningSilence(await ask(runtimes, runtimeDefaults, cwd), cwd);
|
|
130
164
|
|
|
@@ -142,10 +176,18 @@ export async function runPlanningPipeline(options) {
|
|
|
142
176
|
const plansDir = join(campaignTree(cwd, campaignId), "plans", phase);
|
|
143
177
|
mkdirSync(plansDir, { recursive: true });
|
|
144
178
|
const pipelineLog = join(plansDir, "pipeline.jsonl");
|
|
179
|
+
// Every stage line is also the liveness record: the file is the answer to
|
|
180
|
+
// "is this plan still working", and a plan that dies mid-stage leaves the
|
|
181
|
+
// stage it died in as the last thing it ever said. Nothing removes it here;
|
|
182
|
+
// the caller clears it when the pipeline returns, so a record that survives
|
|
183
|
+
// a returned pipeline cannot happen and a record that survives anything
|
|
184
|
+
// else means exactly one thing.
|
|
145
185
|
/** @param {string} stage @param {Record<string, unknown>} [extra] */
|
|
146
|
-
const logStage = (stage, extra = {}) =>
|
|
147
|
-
|
|
148
|
-
|
|
186
|
+
const logStage = (stage, extra = {}) => {
|
|
187
|
+
const at = new Date().toISOString();
|
|
188
|
+
appendJsonl(pipelineLog, { type: "plan.stage", at, campaignId, phase, stage, ...extra });
|
|
189
|
+
writePlanProgress(cwd, campaignId, phase, { campaignId, phase, at, stage, ...extra });
|
|
190
|
+
};
|
|
149
191
|
|
|
150
192
|
// A discovery node's `readFiles` must resolve inside `cwd` (the task packet's
|
|
151
193
|
// own containment rule), but `plansDir` lives under the home since R2 and no
|
|
@@ -160,7 +202,17 @@ export async function runPlanningPipeline(options) {
|
|
|
160
202
|
// the draft stage reads what the planner measured instead of inferring it
|
|
161
203
|
// from the spec's prose.
|
|
162
204
|
const parsedSpec = parseSpec(specText);
|
|
163
|
-
|
|
205
|
+
// This stage holds the terminal for minutes, which is why it says what it is
|
|
206
|
+
// about to do before it does it and one line per command as each finishes.
|
|
207
|
+
// Measured 2026-09-25/26 (AP3): five to eight minutes with nothing written
|
|
208
|
+
// anywhere, on six relaunches.
|
|
209
|
+
const repoFacts = collectRepoFacts(cwd, {
|
|
210
|
+
requirements: parsedSpec.requirements,
|
|
211
|
+
onProgress: (event) => {
|
|
212
|
+
if (event.kind === "commands") logStage("repo-facts:start", { commands: event.commands.map((argv) => argv.join(" ")) });
|
|
213
|
+
else logStage("repo-facts:measured", { argv: event.argv, measuredMs: event.measuredMs, index: event.index, total: event.total });
|
|
214
|
+
},
|
|
215
|
+
});
|
|
164
216
|
// R16: an operator step a requirement's constraints declare, detected once
|
|
165
217
|
// here so both the freeze below and a caller inspecting the pipeline agree
|
|
166
218
|
// on the same list.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The liveness record of a planning process: where a `faberun plan` says which
|
|
3
|
+
* stage it is in, and how a later reader tells a process that is still working
|
|
4
|
+
* from one that died without a word.
|
|
5
|
+
*
|
|
6
|
+
* It is separate from `pipeline.mjs` because two layers need it and neither
|
|
7
|
+
* owns it: the pipeline writes it as it advances through stages, and
|
|
8
|
+
* `cli/plan.mjs` reads it before starting and clears it when the pipeline
|
|
9
|
+
* returns. `cli/plan.mjs` already owns the sibling record for a plan that dies
|
|
10
|
+
* before the pipeline is reached (`writePlanBootstrapFailure`); this one
|
|
11
|
+
* covers the death that record cannot, the one that happens minutes later.
|
|
12
|
+
*
|
|
13
|
+
* Measured 2026-09-26 (AP4): a detached `plan` (pid 64270) vanished during the
|
|
14
|
+
* repo-facts stage, leaving `plans/phase-1` empty, with no `pipeline.jsonl` and
|
|
15
|
+
* no bootstrap failure. `watchPlanBootstrap` covers a five-second window, so a
|
|
16
|
+
* death after it recorded nothing at all.
|
|
17
|
+
*
|
|
18
|
+
* A record exists because a detached process's stdio is discarded, which is
|
|
19
|
+
* also why `plan --resolve` writes none: it is never detached, so a death
|
|
20
|
+
* there is visible where it happens and a record would only add a second
|
|
21
|
+
* account of something the operator already watched.
|
|
22
|
+
*/
|
|
23
|
+
import { rmSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { pidAlive, processStartToken, sameProcessStartToken } from "../run/lock.mjs";
|
|
26
|
+
import { campaignTree } from "../run/paths.mjs";
|
|
27
|
+
import { readJson, writeJsonAtomic } from "../run/store.mjs";
|
|
28
|
+
|
|
29
|
+
/** @typedef {{pid?: number, processStartToken?: string|null, at?: string, stage?: string, [key: string]: unknown}} PlanProgressRecord */
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Beside the phase's durable plan artifacts rather than in the disposable
|
|
33
|
+
* scratch tree, for the same reason `bootstrap-failure.json` is: a record a
|
|
34
|
+
* later launch has to find cannot live where the next `plan` may wipe.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} cwd @param {string} campaignId @param {string} phase
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function planProgressPath(cwd, campaignId, phase) {
|
|
40
|
+
return join(campaignTree(cwd, campaignId), "plans", phase, "progress.json");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Write the record of the process that is doing the work right now. It carries
|
|
45
|
+
* the pid together with the process start token, so a reader an hour later is
|
|
46
|
+
* not fooled by a pid the operating system handed to somebody else.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} cwd @param {string} campaignId @param {string} phase
|
|
49
|
+
* @param {PlanProgressRecord} record
|
|
50
|
+
* @returns {void}
|
|
51
|
+
*/
|
|
52
|
+
export function writePlanProgress(cwd, campaignId, phase, record) {
|
|
53
|
+
writeJsonAtomic(planProgressPath(cwd, campaignId, phase), {
|
|
54
|
+
pid: process.pid,
|
|
55
|
+
processStartToken: processStartToken(process.pid),
|
|
56
|
+
at: new Date().toISOString(),
|
|
57
|
+
...record,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} cwd @param {string} campaignId @param {string} phase
|
|
63
|
+
* @returns {PlanProgressRecord|null}
|
|
64
|
+
*/
|
|
65
|
+
export function readPlanProgress(cwd, campaignId, phase) {
|
|
66
|
+
try {
|
|
67
|
+
const record = /** @type {PlanProgressRecord} */ (readJson(planProgressPath(cwd, campaignId, phase)));
|
|
68
|
+
return record && typeof record === "object" && !Array.isArray(record) ? record : null;
|
|
69
|
+
} catch {
|
|
70
|
+
// An absent record is the ordinary case: a plan that finished clears it.
|
|
71
|
+
// A record that will not parse is one a dying process left half-written,
|
|
72
|
+
// and there is nothing in it a reader could act on either way.
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The record of a process that stopped where it stood, or null when no plan is
|
|
79
|
+
* running and nothing died. A live pid with the recorded start token is work
|
|
80
|
+
* in progress, however long it has been in the same stage; anything else means
|
|
81
|
+
* the pipeline never reached its own end.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} cwd @param {string} campaignId @param {string} phase
|
|
84
|
+
* @returns {PlanProgressRecord|null}
|
|
85
|
+
*/
|
|
86
|
+
export function abandonedPlanProgress(cwd, campaignId, phase) {
|
|
87
|
+
const record = readPlanProgress(cwd, campaignId, phase);
|
|
88
|
+
if (!record) return null;
|
|
89
|
+
const pid = typeof record.pid === "number" ? record.pid : null;
|
|
90
|
+
const token = typeof record.processStartToken === "string" ? record.processStartToken : null;
|
|
91
|
+
if (pid !== null && pidAlive(pid) && sameProcessStartToken(processStartToken(pid), token)) return null;
|
|
92
|
+
return record;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} cwd @param {string} campaignId @param {string} phase
|
|
97
|
+
* @returns {void}
|
|
98
|
+
*/
|
|
99
|
+
export function clearPlanProgress(cwd, campaignId, phase) {
|
|
100
|
+
rmSync(planProgressPath(cwd, campaignId, phase), { force: true });
|
|
101
|
+
}
|
package/src/plan/proof-check.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { shellWords } from "../util.mjs";
|
|
16
|
-
import { inlineScriptParseError } from "../contract/definition-of-done.mjs";
|
|
16
|
+
import { inlineScriptParseError, unclosedQuote } from "../contract/definition-of-done.mjs";
|
|
17
17
|
import { NAME_PATTERN_FLAG } from "./proof-run.mjs";
|
|
18
18
|
|
|
19
19
|
/** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
|
|
@@ -150,6 +150,18 @@ export function checkPlanProofs(plan, repoFacts, cwd) {
|
|
|
150
150
|
});
|
|
151
151
|
continue;
|
|
152
152
|
}
|
|
153
|
+
// AP13: a command the shell cannot parse never runs, so no delivery
|
|
154
|
+
// makes the item pass; the proof text has to change before the freeze.
|
|
155
|
+
const openQuote = unclosedQuote(commandText);
|
|
156
|
+
if (openQuote !== null) {
|
|
157
|
+
findings.push({
|
|
158
|
+
id: `proof-unparseable-${node.id}-${item.id}`,
|
|
159
|
+
severity: "critical",
|
|
160
|
+
nodeId: node.id,
|
|
161
|
+
text: `Node ${node.id}'s Definition of Done item "${item.id}" proves itself with a command that leaves a ${openQuote} open ("${commandText}"), which /bin/sh refuses before it runs, so no delivery can make this proof exit 0. Quote the value, as in --test-name-pattern="<title>".`,
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
153
165
|
const nameFlag = NAME_PATTERN_FLAG.exec(commandText);
|
|
154
166
|
if (nameFlag) {
|
|
155
167
|
const pattern = nameFlag[1] ?? nameFlag[2] ?? nameFlag[3];
|
package/src/plan/proof-scope.mjs
CHANGED
|
@@ -30,7 +30,7 @@ const TEST_TITLE = /\b(?:test|it|describe)(?:\.\w+)?\(\s*(["'`])((?:\\.|(?!\1).)
|
|
|
30
30
|
* @param {string[]} argv
|
|
31
31
|
* @returns {{nameFilters: Array<{flag: string, value: string}>, paths: string[]}|null}
|
|
32
32
|
*/
|
|
33
|
-
function nodeTestShape(argv) {
|
|
33
|
+
export function nodeTestShape(argv) {
|
|
34
34
|
if (argv[0] !== "node" || !argv.includes("--test")) return null;
|
|
35
35
|
/** @type {Array<{flag: string, value: string}>} */
|
|
36
36
|
const nameFilters = [];
|
package/src/plan/repo-facts.mjs
CHANGED
|
@@ -176,6 +176,19 @@ function manifestCandidateCommands(cwd) {
|
|
|
176
176
|
return commands;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* How a caller watches this stage work. `commands` arrives once, naming every
|
|
181
|
+
* command about to be timed; `measured` arrives once per command as it
|
|
182
|
+
* finishes. Neither reaches the returned facts: two calls at the same HEAD
|
|
183
|
+
* stay byte-identical because no event is written into them.
|
|
184
|
+
*
|
|
185
|
+
* Measured 2026-09-25/26 (AP3): this stage took five to eight minutes and said
|
|
186
|
+
* nothing while it did, so `faberun plan` was indistinguishable from a hang
|
|
187
|
+
* and the operator checked the process by hand on six relaunches.
|
|
188
|
+
*
|
|
189
|
+
* @typedef {(event: {kind: "commands", commands: string[][]} | {kind: "measured", argv: string[], measuredMs: number, index: number, total: number}) => void} RepoFactsProgress
|
|
190
|
+
*/
|
|
191
|
+
|
|
179
192
|
/**
|
|
180
193
|
* Time every candidate through `timeVerificationCommands`'s own probe —
|
|
181
194
|
* real `spawnSync` and `Date.now` by default, or the caller's fake — instead
|
|
@@ -187,9 +200,10 @@ function manifestCandidateCommands(cwd) {
|
|
|
187
200
|
* @param {string} cwd
|
|
188
201
|
* @param {{argv: string[]}[]} commands
|
|
189
202
|
* @param {MeasureProbes} probes
|
|
203
|
+
* @param {RepoFactsProgress} [onProgress]
|
|
190
204
|
* @returns {{argv: string[], measuredMs: number, eligible: boolean}[]}
|
|
191
205
|
*/
|
|
192
|
-
function measureCandidates(cwd, commands, probes) {
|
|
206
|
+
function measureCandidates(cwd, commands, probes, onProgress) {
|
|
193
207
|
if (commands.length === 0) return [];
|
|
194
208
|
const now = probes.now ?? (() => Date.now());
|
|
195
209
|
/** @type {number[]} */
|
|
@@ -201,7 +215,22 @@ function measureCandidates(cwd, commands, probes) {
|
|
|
201
215
|
taskPacket: { verification: [{ argv: command.argv, timeoutSec: CANDIDATE_TIMEOUT_SEC }] },
|
|
202
216
|
})),
|
|
203
217
|
}));
|
|
204
|
-
timeVerificationCommands(contract, {
|
|
218
|
+
timeVerificationCommands(contract, {
|
|
219
|
+
...probes,
|
|
220
|
+
now: () => {
|
|
221
|
+
const mark = now();
|
|
222
|
+
marks.push(mark);
|
|
223
|
+
// Every command contributes exactly one start mark and one stop mark, in
|
|
224
|
+
// order, so an even mark count means the command at half that count just
|
|
225
|
+
// finished. That is the only progress signal this stage has while it
|
|
226
|
+
// holds the terminal for minutes.
|
|
227
|
+
const stopped = marks.length;
|
|
228
|
+
if (stopped % 2 === 0 && onProgress) {
|
|
229
|
+
onProgress({ kind: "measured", argv: commands[stopped / 2 - 1].argv, measuredMs: mark - marks[stopped - 2], index: stopped / 2, total: commands.length });
|
|
230
|
+
}
|
|
231
|
+
return mark;
|
|
232
|
+
},
|
|
233
|
+
});
|
|
205
234
|
return commands.map((command, index) => {
|
|
206
235
|
const measuredMs = marks[index * 2 + 1] - marks[index * 2];
|
|
207
236
|
return { argv: command.argv, measuredMs, eligible: measuredMs <= ELIGIBLE_MS_CEILING };
|
|
@@ -247,7 +276,7 @@ export function measureRequirements(cwd, requirements, probes = {}) {
|
|
|
247
276
|
|
|
248
277
|
/**
|
|
249
278
|
* @param {string} cwd
|
|
250
|
-
* @param {{measure?: MeasureProbes, maxPaths?: number, requirements?: SpecRequirement[]}} [options]
|
|
279
|
+
* @param {{measure?: MeasureProbes, maxPaths?: number, requirements?: SpecRequirement[], onProgress?: RepoFactsProgress}} [options]
|
|
251
280
|
* @returns {RepoFacts}
|
|
252
281
|
*/
|
|
253
282
|
export function collectRepoFacts(cwd, options = {}) {
|
|
@@ -255,7 +284,9 @@ export function collectRepoFacts(cwd, options = {}) {
|
|
|
255
284
|
const allPaths = listTrackedPaths(cwd);
|
|
256
285
|
const pathSet = new Set(allPaths);
|
|
257
286
|
const scripts = readScripts(cwd);
|
|
258
|
-
const
|
|
287
|
+
const candidates = nodeCandidateCommands(scripts, allPaths);
|
|
288
|
+
options.onProgress?.({ kind: "commands", commands: candidates.map((command) => command.argv) });
|
|
289
|
+
const measured = measureCandidates(cwd, candidates, options.measure ?? {}, options.onProgress);
|
|
259
290
|
const detected = manifestCandidateCommands(cwd);
|
|
260
291
|
const truncated = allPaths.length > maxPaths;
|
|
261
292
|
return {
|
package/src/plan/spec.mjs
CHANGED
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
* schedules, or reaches a provider — this module invokes no model.
|
|
7
7
|
*/
|
|
8
8
|
import { git } from "../repo/worktree.mjs";
|
|
9
|
+
import { shellWords } from "../util.mjs";
|
|
10
|
+
import { nodeTestShape } from "./proof-scope.mjs";
|
|
9
11
|
import { proveRequirements } from "./proof-run.mjs";
|
|
10
12
|
|
|
11
13
|
/** @typedef {"command"|"path"|"judgment"} ProofKind */
|
|
12
14
|
/** @typedef {{kind: ProofKind, ref?: string}} SpecProof */
|
|
13
|
-
/** @typedef {{
|
|
15
|
+
/** @typedef {{key: string, line: number}} UnknownBulletKey */
|
|
16
|
+
/** @typedef {{id: string|null, title: string, statement: string|null, proof: SpecProof|null, measure: SpecProof|null, constraints: string|null, unknownBulletKeys: UnknownBulletKey[], line: number}} SpecRequirement */
|
|
14
17
|
/** @typedef {Record<string, string>} SpecFrontMatter */
|
|
15
18
|
/** @typedef {{heading: string, body: string, line: number}} SpecSection */
|
|
16
19
|
/** @typedef {{frontMatter: SpecFrontMatter|null, sections: Map<string, SpecSection>, requirements: SpecRequirement[]}} ParsedSpec */
|
|
@@ -98,25 +101,41 @@ function extractSections(lines, startIndex) {
|
|
|
98
101
|
* A `- **key:** value` bullet, and any following non-blank, non-bullet line as
|
|
99
102
|
* its wrapped continuation.
|
|
100
103
|
*
|
|
104
|
+
* A bullet whose key does not match is *not* a continuation of the one above
|
|
105
|
+
* it: measured 2026-09-27 (AP14 of safe-to-hand-to-a-friend), a
|
|
106
|
+
* `- **esclarecimento (27/09):**` line right after `- **proof:**` was dropped
|
|
107
|
+
* and the lines under it were appended to the proof above, so R8 read as
|
|
108
|
+
* having no proof while the text said otherwise. The dropped key is returned
|
|
109
|
+
* so `validateSpec` can name it instead of letting it vanish.
|
|
110
|
+
*
|
|
101
111
|
* @param {string[]} lines
|
|
102
|
-
* @returns {Map<string, string>}
|
|
112
|
+
* @returns {{bullets: Map<string, string>, unknownKeys: Array<{key: string, offset: number}>}}
|
|
103
113
|
*/
|
|
104
114
|
function parseBullets(lines) {
|
|
105
115
|
/** @type {Map<string, string>} */
|
|
106
116
|
const bullets = new Map();
|
|
117
|
+
/** @type {Array<{key: string, offset: number}>} */
|
|
118
|
+
const unknownKeys = [];
|
|
107
119
|
let currentKey = null;
|
|
108
|
-
for (
|
|
120
|
+
for (let offset = 0; offset < lines.length; offset += 1) {
|
|
121
|
+
const line = lines[offset];
|
|
109
122
|
const match = /^-\s+\*\*([a-zA-Z-]+):\*\*\s?(.*)$/u.exec(line);
|
|
110
123
|
if (match) {
|
|
111
124
|
currentKey = match[1].toLowerCase();
|
|
112
125
|
bullets.set(currentKey, match[2].trim());
|
|
113
126
|
continue;
|
|
114
127
|
}
|
|
128
|
+
const unrecognized = /^-\s+\*\*(.+?):\*\*\s?/u.exec(line);
|
|
129
|
+
if (unrecognized) {
|
|
130
|
+
unknownKeys.push({ key: unrecognized[1], offset });
|
|
131
|
+
currentKey = null;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
115
134
|
const trimmed = line.trim();
|
|
116
135
|
if (!trimmed) { currentKey = null; continue; }
|
|
117
136
|
if (currentKey && !trimmed.startsWith("-")) bullets.set(currentKey, `${bullets.get(currentKey)} ${trimmed}`.trim());
|
|
118
137
|
}
|
|
119
|
-
return bullets;
|
|
138
|
+
return { bullets, unknownKeys };
|
|
120
139
|
}
|
|
121
140
|
|
|
122
141
|
/**
|
|
@@ -137,6 +156,44 @@ function parseProof(raw) {
|
|
|
137
156
|
return kind === "judgment" ? { kind } : { kind, ref: match[2].trim() };
|
|
138
157
|
}
|
|
139
158
|
|
|
159
|
+
/**
|
|
160
|
+
* A requirement proof that filters `node:test` by name without naming the file
|
|
161
|
+
* the test lives in, or null. The same shape the frozen contract refuses
|
|
162
|
+
* (`plan/proof-scope.mjs`, AP1 of safe-to-hand-to-a-friend); raised here so a
|
|
163
|
+
* spec author sees it when the spec is written, rather than a plan later.
|
|
164
|
+
* Measured 2026-09-27 (AP8): R1 to R7 of the `safe-to-hand-to-a-friend` spec
|
|
165
|
+
* each wrote `--test-name-pattern="<title>"` with no file, and only the
|
|
166
|
+
* planner's own nodes were refused for it.
|
|
167
|
+
*
|
|
168
|
+
* @param {SpecProof|null} proof
|
|
169
|
+
* @returns {string|null}
|
|
170
|
+
*/
|
|
171
|
+
function proofWithoutNamedTestFile(proof) {
|
|
172
|
+
if (proof?.kind !== "command" || typeof proof.ref !== "string") return null;
|
|
173
|
+
const shape = nodeTestShape(shellWords(proof.ref));
|
|
174
|
+
if (!shape || shape.nameFilters.length === 0 || shape.paths.length > 0) return null;
|
|
175
|
+
return proof.ref;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The one finding `strict` does not promote. Measured 2026-09-28: 105 of the
|
|
180
|
+
* 237 `--test-name-pattern` proofs in the 109 documents under
|
|
181
|
+
* `docs/campaigns/` name no test file, across 20 documents. Every one of them
|
|
182
|
+
* is the record of a run that already happened, and the repository forbids
|
|
183
|
+
* editing that record to satisfy a rule written later, so promoting this to
|
|
184
|
+
* blocking would retroactively refuse twenty specs that were accepted when
|
|
185
|
+
* they were written. The shape is refused where it costs something anyway:
|
|
186
|
+
* `assertFilteredProofsNameTheirTest` (plan/proof-scope.mjs) fails the freeze
|
|
187
|
+
* of any plan that carries it, so a plan cannot be built from a fresh spec
|
|
188
|
+
* with this proof whatever the spec validator says about it.
|
|
189
|
+
*
|
|
190
|
+
* @param {SpecFinding} finding
|
|
191
|
+
* @returns {boolean}
|
|
192
|
+
*/
|
|
193
|
+
function promotableUnderStrict(finding) {
|
|
194
|
+
return finding.rule !== "proof-filter-names-no-file";
|
|
195
|
+
}
|
|
196
|
+
|
|
140
197
|
/**
|
|
141
198
|
* @param {SpecSection|undefined} section
|
|
142
199
|
* @returns {SpecRequirement[]}
|
|
@@ -154,7 +211,8 @@ function extractRequirements(section) {
|
|
|
154
211
|
const blockLine = section.line + i + 1;
|
|
155
212
|
let end = i + 1;
|
|
156
213
|
while (end < lines.length && !/^###\s+/u.test(lines[end])) end += 1;
|
|
157
|
-
const
|
|
214
|
+
const body = lines.slice(i + 1, end);
|
|
215
|
+
const { bullets, unknownKeys } = parseBullets(body);
|
|
158
216
|
const idMatch = /^(R\d+)\.\s*(.*)$/u.exec(heading);
|
|
159
217
|
requirements.push({
|
|
160
218
|
id: idMatch ? idMatch[1] : null,
|
|
@@ -163,6 +221,7 @@ function extractRequirements(section) {
|
|
|
163
221
|
proof: parseProof(bullets.get("proof")),
|
|
164
222
|
measure: parseProof(bullets.get("measure")),
|
|
165
223
|
constraints: bullets.get("constraints") ?? null,
|
|
224
|
+
unknownBulletKeys: unknownKeys.map(({ key, offset }) => ({ key, line: blockLine + offset })),
|
|
166
225
|
line: blockLine,
|
|
167
226
|
});
|
|
168
227
|
i = end;
|
|
@@ -318,6 +377,23 @@ export function validateSpec(text, options = {}) {
|
|
|
318
377
|
if (!requirement.proof) {
|
|
319
378
|
findings.push({ rule: "requirement-missing-proof", severity: "advisory", message: `requirement ${requirement.id ?? requirement.title} has no proof`, line: requirement.line });
|
|
320
379
|
}
|
|
380
|
+
for (const { key, line } of requirement.unknownBulletKeys) {
|
|
381
|
+
findings.push({
|
|
382
|
+
rule: "requirement-unknown-bullet-key",
|
|
383
|
+
severity: "advisory",
|
|
384
|
+
message: `requirement ${requirement.id ?? requirement.title} has a bullet keyed "${key}", which the format does not read: a key is letters and hyphens (statement, proof, measure, constraints), so this bullet and the lines under it are dropped instead of reaching the requirement. Reword the bullet or use one of the four keys.`,
|
|
385
|
+
line,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
const unbacked = proofWithoutNamedTestFile(requirement.proof);
|
|
389
|
+
if (unbacked !== null) {
|
|
390
|
+
findings.push({
|
|
391
|
+
rule: "proof-filter-names-no-file",
|
|
392
|
+
severity: "advisory",
|
|
393
|
+
message: `requirement ${requirement.id ?? requirement.title}'s proof "${unbacked}" filters node:test by name but names no test file: with no path node --test runs every test file in the tree, each file the filter selects nothing in prints a zero plan, and the plan's own freeze refuses the shape. Name the file that holds the test, as in node --test --test-name-pattern="<title>" test/<area>/<file>.test.mjs.`,
|
|
394
|
+
line: requirement.line,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
321
397
|
}
|
|
322
398
|
const successCriteria = parsed.sections.get("success criteria");
|
|
323
399
|
if (successCriteria) findings.push(...baselineColumnFindings(successCriteria));
|
|
@@ -340,6 +416,6 @@ export function validateSpec(text, options = {}) {
|
|
|
340
416
|
});
|
|
341
417
|
}
|
|
342
418
|
}
|
|
343
|
-
const graded = findings.map((finding) => (strict ? { ...finding, severity: /** @type {const} */ ("blocking") } : finding));
|
|
419
|
+
const graded = findings.map((finding) => (strict && promotableUnderStrict(finding) ? { ...finding, severity: /** @type {const} */ ("blocking") } : finding));
|
|
344
420
|
return { class: "structured", ok: !graded.some((finding) => finding.severity === "blocking"), findings: graded };
|
|
345
421
|
}
|
package/src/plan/template.mjs
CHANGED
|
@@ -154,6 +154,14 @@ function instructionsFor(kind, inputs) {
|
|
|
154
154
|
// tree and read as having measured nothing; two nodes exhausted on it. The
|
|
155
155
|
// freeze now refuses that shape (proof-scope.mjs); this is the same rule told
|
|
156
156
|
// to the author, so the draft never writes it.
|
|
157
|
+
// AP11 of safe-to-hand-to-friend, measured 2026-09-27: the plan proved R6 with
|
|
158
|
+
// `! grep -q "\.runs" README.md docs/*.md` while the requirement allowed the
|
|
159
|
+
// mention inside a section labelled as legacy layout, and the node exhausted
|
|
160
|
+
// both attempts on a proof stricter than the statement it proved. The proof's
|
|
161
|
+
// wording is a claim the reviewer can compare with the requirement's own; no
|
|
162
|
+
// deterministic check does, because "stricter" is a reading of two sentences.
|
|
163
|
+
const PROOF_NOT_STRICTER_THAN_REQUIREMENT_RULE = "For every node, compare each command proof with the requirement statement it proves, sentence by sentence: a proof that asserts more than the statement is a finding, because the node is refused for work the requirement allows. A bare `grep` for an absence the statement does not claim, a pattern narrower than the statement's words, or a whole-file scope where the statement names a section are the three shapes this takes.";
|
|
164
|
+
|
|
157
165
|
const NAMED_TEST_FILE_RULE = 'A command that filters node:test by name (--test-name-pattern) names the test file it selects from, as in node --test --test-name-pattern="<exact test title>" test/<area>/<file>.test.mjs: with no path node --test runs every test file in the tree and the freeze refuses it. A requirement proof the spec writes without a file gains the file of the test that carries that title.';
|
|
158
166
|
|
|
159
167
|
// The rule every planned packet is held to at freeze time, worded from
|
|
@@ -198,6 +206,7 @@ const INSTRUCTIONS = Object.freeze({
|
|
|
198
206
|
],
|
|
199
207
|
review: [
|
|
200
208
|
"You are given only the spec, the repository facts, and the plan under review; you have not seen how the plan was produced or any reasoning behind it. Review the artefact alone.",
|
|
209
|
+
PROOF_NOT_STRICTER_THAN_REQUIREMENT_RULE,
|
|
201
210
|
`Return exactly one worker-result JSON object. Put your findings in output.findings as ${FINDINGS_SHAPE} and nothing else in output.`,
|
|
202
211
|
"severity must be one of critical, major, minor. Every finding's nodeId must name a node id that actually appears in the plan under review.",
|
|
203
212
|
],
|
|
@@ -13,9 +13,9 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
|
13
13
|
import { SETTLED } from "../engine/prompts.mjs";
|
|
14
14
|
import { describeRuns } from "../run/disk-gc.mjs";
|
|
15
15
|
import { worktreeRoot } from "../run/paths.mjs";
|
|
16
|
-
import { git, gitHead, releaseRunWorktrees, runRefName } from "./worktree.mjs";
|
|
16
|
+
import { git, gitHead, releaseAttemptBranches, releaseRunWorktrees, runRefName } from "./worktree.mjs";
|
|
17
17
|
|
|
18
|
-
/** @typedef {{runId: string, removed: number, archived: string[]}} PrunedRun */
|
|
18
|
+
/** @typedef {{runId: string, removed: number, archived: string[], branches: string[]}} PrunedRun */
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Is the run's integrated head already on a branch of the operator's? An
|
|
@@ -48,7 +48,9 @@ function allNodesSettled(runDir) {
|
|
|
48
48
|
* nodes are all terminal, and whose run ref a local branch already contains.
|
|
49
49
|
* `releaseRunWorktrees` keeps each attempt's HEAD and uncommitted delta under
|
|
50
50
|
* `refs/faberun-archive/`, so a released attempt can still be recovered.
|
|
51
|
-
*
|
|
51
|
+
* The attempt branches of the same run go with them (AP21): a released
|
|
52
|
+
* worktree deletes only the branch it had checked out, so a run that tried
|
|
53
|
+
* three times kept three. `parked` also takes a run with a blocked or exhausted node: only on the
|
|
52
54
|
* operator's word, because a resume that re-judges an attempt reads its
|
|
53
55
|
* worktree, and only the operator knows the run was superseded.
|
|
54
56
|
*
|
|
@@ -64,9 +66,15 @@ export function pruneIntegratedRunWorktrees(repo, runsDir, options = {}) {
|
|
|
64
66
|
if (run.hasActiveController) continue;
|
|
65
67
|
if (!run.allNodesTerminal && !(options.parked === true && allNodesSettled(run.path))) continue;
|
|
66
68
|
const runId = basename(run.path);
|
|
67
|
-
if (!existsSync(worktreeRoot(join(runsDir, runId), runId))) continue;
|
|
68
69
|
if (!runRefLanded(repo, runId)) continue;
|
|
69
|
-
|
|
70
|
+
// The branch sweep is not gated on the worktree root: the run whose
|
|
71
|
+
// worktrees an earlier prune already released is exactly the run whose
|
|
72
|
+
// attempt branches are still there (AP21).
|
|
73
|
+
const runDir = join(runsDir, runId);
|
|
74
|
+
const released = existsSync(worktreeRoot(runDir, runId)) ? releaseRunWorktrees(repo, runDir, runId) : { removed: 0, archived: [] };
|
|
75
|
+
const branches = releaseAttemptBranches(repo, runId);
|
|
76
|
+
if (released.removed === 0 && branches.length === 0) continue;
|
|
77
|
+
pruned.push({ runId, ...released, branches });
|
|
70
78
|
}
|
|
71
79
|
return pruned;
|
|
72
80
|
}
|
package/src/repo/worktree.mjs
CHANGED
|
@@ -482,6 +482,60 @@ export function attemptWorkspace(state) {
|
|
|
482
482
|
return path && state?.worktree?.status !== "removed" && existsSync(path) ? path : null;
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
+
/**
|
|
486
|
+
* Delete the attempt branches of `runId` whose commit another ref already
|
|
487
|
+
* holds: the run's own `refs/faberun/<run>/…`, an archive ref written when a
|
|
488
|
+
* worktree was released, or any branch of the operator's. Measured 2026-09-28
|
|
489
|
+
* (AP21 of safe-to-hand-to-friend): `prune --parked` released 20 worktrees and
|
|
490
|
+
* left 133 branches `faberun/<run>/<node>/<n>` behind, every one of them
|
|
491
|
+
* already reachable from one of those refs; releasing a worktree deletes only
|
|
492
|
+
* the branch it had checked out.
|
|
493
|
+
*
|
|
494
|
+
* A branch nothing else holds is never deleted: an attempt that exists only as
|
|
495
|
+
* its own branch is the only copy of that work. Attempt branches are not
|
|
496
|
+
* keepers for each other, so the rule is about the run, not about whichever
|
|
497
|
+
* attempt happens to be newest.
|
|
498
|
+
*
|
|
499
|
+
* @param {string} repo
|
|
500
|
+
* @param {string} runId
|
|
501
|
+
* @returns {string[]} the branch names deleted, without `refs/heads/`
|
|
502
|
+
*/
|
|
503
|
+
export function releaseAttemptBranches(repo, runId) {
|
|
504
|
+
const prefix = `refs/heads/faberun/${runId}/`;
|
|
505
|
+
/** @type {Array<{ref: string, commit: string}>} */
|
|
506
|
+
const attempts = git(repo, ["for-each-ref", "--format=%(refname) %(objectname)", "refs/heads/faberun/"])
|
|
507
|
+
.split("\n")
|
|
508
|
+
.map((line) => line.trim())
|
|
509
|
+
.filter(Boolean)
|
|
510
|
+
.map((line) => {
|
|
511
|
+
const [ref, commit] = line.split(" ");
|
|
512
|
+
return { ref: ref ?? "", commit: commit ?? "" };
|
|
513
|
+
})
|
|
514
|
+
.filter(({ ref }) => ref.startsWith(prefix));
|
|
515
|
+
if (attempts.length === 0) return [];
|
|
516
|
+
const keepers = git(repo, ["for-each-ref", "--format=%(refname)", "refs/heads", "refs/faberun", "refs/faberun-archive"])
|
|
517
|
+
.split("\n")
|
|
518
|
+
.map((line) => line.trim())
|
|
519
|
+
.filter((line) => line && !line.startsWith("refs/heads/faberun/"));
|
|
520
|
+
/** @type {string[]} */
|
|
521
|
+
const deleted = [];
|
|
522
|
+
for (const { ref, commit } of attempts) {
|
|
523
|
+
const held = keepers.some((keeper) => {
|
|
524
|
+
try {
|
|
525
|
+
git(repo, ["merge-base", "--is-ancestor", commit, keeper]);
|
|
526
|
+
return true;
|
|
527
|
+
} catch {
|
|
528
|
+
// Not an ancestor of this keeper; the next one may still hold it.
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
if (!held) continue;
|
|
533
|
+
deleteRef(repo, ref);
|
|
534
|
+
deleted.push(ref.slice("refs/heads/".length));
|
|
535
|
+
}
|
|
536
|
+
return deleted;
|
|
537
|
+
}
|
|
538
|
+
|
|
485
539
|
/**
|
|
486
540
|
* Release every attempt worktree a finished run left behind, keeping what it
|
|
487
541
|
* held. Measured 2026-09-24: attempts that were never accepted (a failed or
|