faberun 0.16.0 → 0.17.1
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 +2 -2
- package/src/campaign/journal.mjs +11 -2
- package/src/campaign/record.mjs +6 -1
- package/src/engine/gate.mjs +8 -3
- package/src/engine/judge-gate.mjs +88 -1
- package/src/harnesses/index.mjs +5 -1
- package/src/notify/index.mjs +24 -1
- package/src/repo/signal.mjs +76 -9
- package/src/run/migrate.mjs +109 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
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": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"scripts": {
|
|
27
27
|
"check": "node -e \"const{readdirSync}=require('node:fs');const{spawnSync}=require('node:child_process');const roots=['bin','.claude/hooks','src','evals','test'];const files=roots.flatMap(r=>readdirSync(r,{recursive:true}).map(String).filter(p=>p.endsWith('.mjs')).map(p=>r+'/'+p));for(const f of files)if(spawnSync(process.execPath,['--check',f],{stdio:'inherit'}).status!==0)process.exit(1);console.log(files.length+' files checked')\"",
|
|
28
28
|
"typecheck": "tsc",
|
|
29
|
-
"test": "node --test test/*.test.mjs test/*/*.test.mjs",
|
|
29
|
+
"test": "node --test --import ./test/scoped-home.mjs --import ./test/setup.mjs test/*.test.mjs test/*/*.test.mjs",
|
|
30
30
|
"docs": "node src/cli/manual.mjs --write",
|
|
31
31
|
"docs:check": "node src/cli/manual.mjs --check",
|
|
32
32
|
"prepare": "husky"
|
package/src/campaign/journal.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* the journal's own `eventId` so two sessions cannot consume each other's place.
|
|
9
9
|
*/
|
|
10
10
|
import { JOURNAL_FILE, JOURNAL_TEXT_BYTES, JOURNAL_WATCH_CURSOR_DIR, JOURNAL_WATCH_CURSOR_SCHEMA_VERSION } from "./layout.mjs";
|
|
11
|
-
import {
|
|
11
|
+
import { collapseLines } from "../util.mjs";
|
|
12
12
|
import { campaignIdOf } from "./record.mjs";
|
|
13
13
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
@@ -382,6 +382,11 @@ function normalizeEntry(entry) {
|
|
|
382
382
|
return /** @type {JournalEntry} */ (normalized);
|
|
383
383
|
}
|
|
384
384
|
/**
|
|
385
|
+
* Collapse to one line and enforce the byte cap by refusing, not truncating:
|
|
386
|
+
* the journal is the record of what was written, so a note that does not fit
|
|
387
|
+
* is the author's to cut -- a silently shortened entry lies about its own
|
|
388
|
+
* write. Readers of already-stored text never pass through here.
|
|
389
|
+
*
|
|
385
390
|
* @param {unknown} value
|
|
386
391
|
* @param {string} label
|
|
387
392
|
* @param {number} maxBytes
|
|
@@ -391,5 +396,9 @@ export function normalizeText(value, label, maxBytes = JOURNAL_TEXT_BYTES) {
|
|
|
391
396
|
requireText(value, label);
|
|
392
397
|
const collapsed = collapseLines(value);
|
|
393
398
|
if (!collapsed) throw new TypeError(`${label} must not be blank`);
|
|
394
|
-
|
|
399
|
+
const bytes = Buffer.byteLength(collapsed, "utf8");
|
|
400
|
+
if (bytes > maxBytes) {
|
|
401
|
+
throw new TypeError(`${label} is ${bytes} bytes, over the ${maxBytes}-byte cap; cut ${bytes - maxBytes} bytes and retry`);
|
|
402
|
+
}
|
|
403
|
+
return collapsed;
|
|
395
404
|
}
|
package/src/campaign/record.mjs
CHANGED
|
@@ -49,9 +49,14 @@ export function campaignIdOf(campaignPath) {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
|
+
* The campaign record's schema, in one home. Exported so the repair verb in
|
|
53
|
+
* `run/migrate.mjs` can prove a record's only defect is the absent `status`
|
|
54
|
+
* field by validating the record with that default applied, instead of the
|
|
55
|
+
* schema being restated beside the repair.
|
|
56
|
+
*
|
|
52
57
|
* @param {unknown} campaign
|
|
53
58
|
*/
|
|
54
|
-
function validateCampaign(campaign) {
|
|
59
|
+
export function validateCampaign(campaign) {
|
|
55
60
|
if (!campaign || typeof campaign !== "object" || Array.isArray(campaign)) {
|
|
56
61
|
throw new TypeError("campaign.json must be an object");
|
|
57
62
|
}
|
package/src/engine/gate.mjs
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import { existsSync, readFileSync, statSync, openSync, closeSync, readSync, writeSync } from "node:fs";
|
|
31
31
|
import { dirname } from "node:path";
|
|
32
32
|
import { spawn } from "node:child_process";
|
|
33
|
+
import { NOTIFY_ENV_NAMES } from "../notify/index.mjs";
|
|
33
34
|
|
|
34
35
|
/** @typedef {{executable: string, args: string[], cwd: string, promptTransport: "stdin"|"argv", harness: string, env: Record<string, string|null>|null, stdoutPath: string, stderrPath: string}} GateConfig */
|
|
35
36
|
|
|
@@ -155,9 +156,13 @@ function childEnv() {
|
|
|
155
156
|
if (value === null) delete merged[key];
|
|
156
157
|
else merged[key] = value;
|
|
157
158
|
}
|
|
158
|
-
// Worker providers are not a notification surface: strip
|
|
159
|
-
// transport after the harness overlay so no harness can reintroduce
|
|
160
|
-
|
|
159
|
+
// Worker providers are not a notification surface: strip every controller-only
|
|
160
|
+
// transport after the harness overlay so no harness can reintroduce one. The
|
|
161
|
+
// list lives in `notify/index.mjs`, not here: this line once named
|
|
162
|
+
// FABERUN_NOTIFY_BIN alone, and on 2026-09-21 a worker that inherited
|
|
163
|
+
// FABERUN_NOTIFY_SESSION ran this repository's suite, whose fixture
|
|
164
|
+
// controllers woke the operator's live session seven times in minutes.
|
|
165
|
+
for (const name of NOTIFY_ENV_NAMES) delete merged[name];
|
|
161
166
|
return merged;
|
|
162
167
|
}
|
|
163
168
|
|
|
@@ -135,6 +135,74 @@ function proveVerification(id, proof, recorded) {
|
|
|
135
135
|
return { id, kind: "verification", ref: proof.ref, pass: entry.passed === true, detail };
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* A command proof that declares a node:test filter (`--test-name-pattern`,
|
|
140
|
+
* `--test-skip-pattern`) is judged on more than its exit code, because the
|
|
141
|
+
* runner exits 0 whether its filter selected anything or not. The proof runs
|
|
142
|
+
* with the TAP reporter selected through `NODE_OPTIONS` and is refused when the
|
|
143
|
+
* output carries TAP's zero-plan line. Measured 2026-09-21 on node v26.8.1: a
|
|
144
|
+
* filter that matches emits `1..0` zero times, a filter that matches nothing
|
|
145
|
+
* emits it exactly once, a run with no filter emits it zero times, and an empty
|
|
146
|
+
* suite under a matching filter emits no nested zero plan. The default reporter
|
|
147
|
+
* cannot make the distinction -- both cases print identical counters, because
|
|
148
|
+
* the tick is the file rather than a test. The reporter goes through the
|
|
149
|
+
* environment because appending `--test-reporter=tap` to the command string is
|
|
150
|
+
* a no-op whenever a test file precedes it: node reads its own options
|
|
151
|
+
* left to right, so the flag lands among the script's arguments and the runner
|
|
152
|
+
* never sees it.
|
|
153
|
+
*/
|
|
154
|
+
const TEST_FILTER_FLAGS = ["--test-name-pattern", "--test-skip-pattern"];
|
|
155
|
+
const TAP_ZERO_PLAN = /^1\.\.0$/mu;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The environment a filtered proof runs under: the ambient environment with
|
|
159
|
+
* the TAP reporter selected through `NODE_OPTIONS`, minus `NODE_TEST_CONTEXT`.
|
|
160
|
+
* That marker belongs to whichever test runner spawned this process; a nested
|
|
161
|
+
* `node --test` that inherits it stays a runner child and emits no TAP at all
|
|
162
|
+
* (measured 2026-09-21: with the marker the zero-plan line never appears,
|
|
163
|
+
* without it exactly once) -- and a proof is judged as its own top-level run,
|
|
164
|
+
* not as the suite's child.
|
|
165
|
+
*
|
|
166
|
+
* @returns {NodeJS.ProcessEnv}
|
|
167
|
+
*/
|
|
168
|
+
function envForFilteredProof() {
|
|
169
|
+
const { NODE_TEST_CONTEXT: _outer, NODE_OPTIONS: existing, ...ambient } = process.env;
|
|
170
|
+
return { ...ambient, NODE_OPTIONS: existing ? `${existing} --test-reporter=tap` : "--test-reporter=tap" };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The node:test filters a command string declares, in argv order, as flag and
|
|
175
|
+
* value. Presence alone changes behaviour (the appended reporter and the
|
|
176
|
+
* zero-plan look-up); the value is read for the refusal detail alone, which is
|
|
177
|
+
* why a whitespace split is close enough even though the command runs through
|
|
178
|
+
* a shell.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} ref
|
|
181
|
+
* @returns {Array<{flag: string, value: string}>}
|
|
182
|
+
*/
|
|
183
|
+
function declaredTestFilters(ref) {
|
|
184
|
+
const tokens = ref.split(/\s+/u).filter(Boolean);
|
|
185
|
+
/** @type {Array<{flag: string, value: string}>} */
|
|
186
|
+
const filters = [];
|
|
187
|
+
for (const [index, token] of tokens.entries()) {
|
|
188
|
+
for (const flag of TEST_FILTER_FLAGS) {
|
|
189
|
+
if (token.startsWith(`${flag}=`)) filters.push({ flag, value: unquote(token.slice(flag.length + 1)) });
|
|
190
|
+
else if (token === flag) filters.push({ flag, value: unquote(tokens[index + 1] ?? "") });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return filters;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** @param {string} value @returns {string} */
|
|
197
|
+
function unquote(value) {
|
|
198
|
+
return value.replace(/^['"]|['"]$/gu, "");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** @param {Array<{flag: string, value: string}>} filters @returns {string} */
|
|
202
|
+
function filterNames(filters) {
|
|
203
|
+
return filters.map(({ flag, value }) => `${flag} "${value}"`).join(", ");
|
|
204
|
+
}
|
|
205
|
+
|
|
138
206
|
/**
|
|
139
207
|
* @param {string} id
|
|
140
208
|
* @param {DefinitionOfDoneProof} proof
|
|
@@ -144,11 +212,18 @@ function proveVerification(id, proof, recorded) {
|
|
|
144
212
|
*/
|
|
145
213
|
async function proveCommand(id, proof, cwd, timeoutMs) {
|
|
146
214
|
const ref = proof.ref;
|
|
215
|
+
const filters = declaredTestFilters(ref);
|
|
147
216
|
return new Promise((settle) => {
|
|
148
217
|
// Detached on POSIX so the shell leads its own process group: `shell: true`
|
|
149
218
|
// means the timeout must kill the group, not the shell, or the command the
|
|
150
219
|
// shell started keeps running and keeps the result pending forever.
|
|
151
|
-
const child = spawn(ref, {
|
|
220
|
+
const child = spawn(ref, {
|
|
221
|
+
cwd,
|
|
222
|
+
shell: true,
|
|
223
|
+
detached: process.platform !== "win32",
|
|
224
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
225
|
+
...(filters.length ? { env: envForFilteredProof() } : {}),
|
|
226
|
+
});
|
|
152
227
|
let stdout = "";
|
|
153
228
|
let stderr = "";
|
|
154
229
|
let settled = false;
|
|
@@ -178,6 +253,18 @@ async function proveCommand(id, proof, cwd, timeoutMs) {
|
|
|
178
253
|
finish({ id, kind: "command", ref, pass: false, detail: boundedText(error.message) });
|
|
179
254
|
});
|
|
180
255
|
child.on("close", (code, signal) => {
|
|
256
|
+
// Its own detail, not an ordinary command failure: the command succeeded,
|
|
257
|
+
// so what failed is that the declared filter selected nothing to prove.
|
|
258
|
+
if (code === 0 && signal === null && filters.length > 0 && TAP_ZERO_PLAN.test(stdout)) {
|
|
259
|
+
finish({
|
|
260
|
+
id,
|
|
261
|
+
kind: "command",
|
|
262
|
+
ref,
|
|
263
|
+
pass: false,
|
|
264
|
+
detail: boundedText(`${filterNames(filters)} selected no test: exit 0 over a TAP plan of 1..0, so the proof measured nothing`),
|
|
265
|
+
});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
181
268
|
const detail = signal !== null ? `killed by ${signal}` : `exit ${code ?? "?"}`;
|
|
182
269
|
const pass = code === 0 && signal === null;
|
|
183
270
|
finish({ id, kind: "command", ref, pass, detail: pass ? detail : boundedText(`${detail}: ${(stderr || stdout).trim()}`) });
|
package/src/harnesses/index.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { dshHarness } from "./dsh/index.mjs";
|
|
|
6
6
|
import { zcodeHarness } from "./zcode/index.mjs";
|
|
7
7
|
import { execJsonlHarness } from "./exec-jsonl/index.mjs";
|
|
8
8
|
import { replayHarness } from "./replay/index.mjs";
|
|
9
|
+
import { withoutNotifyEnv } from "../notify/index.mjs";
|
|
9
10
|
|
|
10
11
|
/** Current wire-contract version for runner protocol artifacts. */
|
|
11
12
|
export const PROTOCOL_SCHEMA_VERSION = 3;
|
|
@@ -451,7 +452,10 @@ export function probeRuntime(runtime, options = {}) {
|
|
|
451
452
|
try {
|
|
452
453
|
child = spawn(executable, args, {
|
|
453
454
|
cwd: options.cwd,
|
|
454
|
-
|
|
455
|
+
// A worker or judge never delivers a notification; the controller does.
|
|
456
|
+
// In this repository a worker runs the test suite, whose fixture
|
|
457
|
+
// controllers would otherwise inherit a live transport and deliver.
|
|
458
|
+
env: withoutNotifyEnv(process.env),
|
|
455
459
|
stdio: ["ignore", "pipe", "pipe"],
|
|
456
460
|
});
|
|
457
461
|
} catch (error) {
|
package/src/notify/index.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import { createHash } from "node:crypto";
|
|
|
50
50
|
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
|
|
51
51
|
import { join } from "node:path";
|
|
52
52
|
import { createMacosNotifier } from "./os-macos.mjs";
|
|
53
|
-
import { deliverToSessions, resolveSessionTargets, sessionWakeNotice } from "./session.mjs";
|
|
53
|
+
import { NOTIFY_SESSION_ENV, deliverToSessions, resolveSessionTargets, sessionWakeNotice } from "./session.mjs";
|
|
54
54
|
import { errorMessage } from "../util.mjs";
|
|
55
55
|
|
|
56
56
|
/**
|
|
@@ -74,6 +74,29 @@ function loadProgressModule() {
|
|
|
74
74
|
|
|
75
75
|
export const NOTIFY_BIN_ENV = "FABERUN_NOTIFY_BIN";
|
|
76
76
|
const MACOS_TRANSPORT = "os-macos";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Every variable that binds a notification transport. The controller is the
|
|
80
|
+
* only process that delivers: a worker, a judge or a verification command
|
|
81
|
+
* that inherits these would notify on the controller's behalf -- and in this
|
|
82
|
+
* repository, whose workers run its own test suite, every fixture controller
|
|
83
|
+
* the suite spawns would deliver its terminal events for real. Measured
|
|
84
|
+
* 2026-09-21: a run launched with `FABERUN_NOTIFY_SESSION=auto` woke the
|
|
85
|
+
* operator's session seven times in minutes from `test/repo/base-ref.test.mjs`
|
|
86
|
+
* fixtures its worker ran. `withoutNotifyEnv` is the boundary every child
|
|
87
|
+
* crosses; `test/setup.mjs` neutralises the same names inside the suite.
|
|
88
|
+
*/
|
|
89
|
+
export const NOTIFY_ENV_NAMES = Object.freeze([NOTIFY_BIN_ENV, NOTIFY_SESSION_ENV]);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {NodeJS.ProcessEnv} env
|
|
93
|
+
* @returns {NodeJS.ProcessEnv} a copy with every notify transport unbound
|
|
94
|
+
*/
|
|
95
|
+
export function withoutNotifyEnv(env) {
|
|
96
|
+
const copy = { ...env };
|
|
97
|
+
for (const name of NOTIFY_ENV_NAMES) delete copy[name];
|
|
98
|
+
return copy;
|
|
99
|
+
}
|
|
77
100
|
export const NOTIFY_LOG_FILE = "notify.jsonl";
|
|
78
101
|
/**
|
|
79
102
|
* The bounded retry budget the dispatcher used to spend before giving up.
|
package/src/repo/signal.mjs
CHANGED
|
@@ -14,8 +14,12 @@
|
|
|
14
14
|
* about the work that most needs it. The block now renders the phase-2
|
|
15
15
|
* `runOutcome` per linked run: `parked` with its nodes, their error codes and
|
|
16
16
|
* the exact `resume` command; `succeeded` as one line; plus the most recent
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* `attention` entry from `.runs/inbox.jsonl`. An attention belongs to one
|
|
18
|
+
* campaign or none: an explicit `campaignId` decides, a null one is resolved
|
|
19
|
+
* from the entry's `runId` (a run belongs to at most one campaign), and an
|
|
20
|
+
* entry whose run no active campaign owns is shown once at run level instead
|
|
21
|
+
* of under every campaign at once. It is bounded, because every session pays
|
|
22
|
+
* for it in its first tokens.
|
|
19
23
|
*/
|
|
20
24
|
|
|
21
25
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -47,21 +51,45 @@ export function renderAgentSignalBlock(runsDir) {
|
|
|
47
51
|
const lines = [];
|
|
48
52
|
/** @type {Set<string>} */
|
|
49
53
|
const linked = new Set();
|
|
50
|
-
const
|
|
51
|
-
|
|
54
|
+
const active = discoverCampaigns(runsDir).campaigns.filter(({ campaign }) => campaign.status !== "closed");
|
|
55
|
+
const ownerByRun = runOwnerIndex(active);
|
|
56
|
+
for (const { campaign } of active) {
|
|
52
57
|
lines.push(`- faberun campaign \`${campaign.id}\`: active — read \`.runs/campaigns/${campaign.id}/${HANDOFF_FILE}\``);
|
|
53
58
|
for (const runId of campaign.linkedRunIds) {
|
|
54
59
|
linked.add(runId);
|
|
55
60
|
lines.push(...runSignalLines(runsDir, runId));
|
|
56
61
|
}
|
|
57
|
-
const attention = campaignAttentionLine(runsDir, campaign);
|
|
62
|
+
const attention = campaignAttentionLine(runsDir, campaign, ownerByRun);
|
|
58
63
|
if (attention) lines.push(attention);
|
|
59
64
|
}
|
|
60
65
|
for (const line of activeRunLines(runsDir, linked)) lines.push(line);
|
|
66
|
+
const orphan = orphanAttentionLine(runsDir, ownerByRun);
|
|
67
|
+
if (orphan) lines.push(orphan);
|
|
61
68
|
if (!lines.length) return "";
|
|
62
69
|
return `${SIGNAL_START}\n${HEADER}\n\n${boundLines(lines).join("\n")}\n${SIGNAL_END}`;
|
|
63
70
|
}
|
|
64
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Which active campaign owns each run, read from the campaigns' own
|
|
74
|
+
* `linkedRunIds`. A run belongs to at most one campaign, so the first
|
|
75
|
+
* campaign naming a run wins; a run no active campaign names is absent from
|
|
76
|
+
* the map, and that absence — never a guess — is what makes an inbox entry
|
|
77
|
+
* unattributable.
|
|
78
|
+
*
|
|
79
|
+
* @param {{campaign: import("../campaign/index.mjs").Campaign}[]} active
|
|
80
|
+
* @returns {Map<string, string>}
|
|
81
|
+
*/
|
|
82
|
+
function runOwnerIndex(active) {
|
|
83
|
+
/** @type {Map<string, string>} */
|
|
84
|
+
const ownerByRun = new Map();
|
|
85
|
+
for (const { campaign } of active) {
|
|
86
|
+
for (const runId of campaign.linkedRunIds) {
|
|
87
|
+
if (!ownerByRun.has(runId)) ownerByRun.set(runId, campaign.id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return ownerByRun;
|
|
91
|
+
}
|
|
92
|
+
|
|
65
93
|
/**
|
|
66
94
|
* One linked run's outcome as block lines. `runProgress` folds the phase-2
|
|
67
95
|
* `runOutcome`, so a parked run is rendered by its own declared nodes rather
|
|
@@ -132,16 +160,17 @@ function activeRunLines(runsDir, linked) {
|
|
|
132
160
|
}
|
|
133
161
|
|
|
134
162
|
/**
|
|
135
|
-
* The most recent campaign
|
|
136
|
-
* otherwise the durable record on the campaign itself.
|
|
163
|
+
* The most recent attention this campaign owns: an inbox entry when one
|
|
164
|
+
* exists, otherwise the durable record on the campaign itself.
|
|
137
165
|
*
|
|
138
166
|
* @param {string} runsDir
|
|
139
167
|
* @param {import("../campaign/index.mjs").Campaign} campaign
|
|
168
|
+
* @param {Map<string, string>} ownerByRun
|
|
140
169
|
* @returns {string|null}
|
|
141
170
|
*/
|
|
142
|
-
function campaignAttentionLine(runsDir, campaign) {
|
|
171
|
+
function campaignAttentionLine(runsDir, campaign, ownerByRun) {
|
|
143
172
|
const latest = readInbox(runsDir)
|
|
144
|
-
.filter((entry) => entry.type === "attention" && (entry
|
|
173
|
+
.filter((entry) => entry.type === "attention" && attentionBelongsTo(entry, campaign.id, ownerByRun))
|
|
145
174
|
.at(-1);
|
|
146
175
|
if (latest) return ` - attention: ${boundedAttention(latest.summary)}`;
|
|
147
176
|
if (campaign.attention && typeof campaign.attention.message === "string") {
|
|
@@ -151,6 +180,44 @@ function campaignAttentionLine(runsDir, campaign) {
|
|
|
151
180
|
return null;
|
|
152
181
|
}
|
|
153
182
|
|
|
183
|
+
/**
|
|
184
|
+
* An attention belongs to one campaign or none. An explicit `campaignId` is
|
|
185
|
+
* authoritative; a null one is resolved from the entry's `runId` through the
|
|
186
|
+
* run-owner index. Measured 2026-09-21: all 12 attention entries in the live
|
|
187
|
+
* inbox carry null, so the old `campaignId === null` fallback attributed an
|
|
188
|
+
* orphan to every campaign at once, permanently. An entry whose run resolves
|
|
189
|
+
* to no active campaign belongs to none and is surfaced once at run level by
|
|
190
|
+
* `orphanAttentionLine`, not dropped.
|
|
191
|
+
*
|
|
192
|
+
* @param {import("../notify/index.mjs").InboxEntry} entry
|
|
193
|
+
* @param {string} campaignId
|
|
194
|
+
* @param {Map<string, string>} ownerByRun
|
|
195
|
+
* @returns {boolean}
|
|
196
|
+
*/
|
|
197
|
+
function attentionBelongsTo(entry, campaignId, ownerByRun) {
|
|
198
|
+
if (entry.campaignId !== null) return entry.campaignId === campaignId;
|
|
199
|
+
return entry.runId !== null && ownerByRun.get(entry.runId) === campaignId;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The most recent attention no campaign owns, as one run-level line. Dropping
|
|
204
|
+
* it would trade the old wrong report (every campaign) for a missing one, and
|
|
205
|
+
* the run-level section of the block is where campaign-less work already
|
|
206
|
+
* lives.
|
|
207
|
+
*
|
|
208
|
+
* @param {string} runsDir
|
|
209
|
+
* @param {Map<string, string>} ownerByRun
|
|
210
|
+
* @returns {string|null}
|
|
211
|
+
*/
|
|
212
|
+
function orphanAttentionLine(runsDir, ownerByRun) {
|
|
213
|
+
const latest = readInbox(runsDir)
|
|
214
|
+
.filter((entry) => entry.type === "attention" && entry.campaignId === null && (entry.runId === null || !ownerByRun.has(entry.runId)))
|
|
215
|
+
.at(-1);
|
|
216
|
+
if (!latest) return null;
|
|
217
|
+
const subject = latest.runId ? `run \`${latest.runId}\`` : "an entry with no run";
|
|
218
|
+
return `- attention: ${subject} resolves to no campaign — ${boundedAttention(latest.summary)}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
154
221
|
/**
|
|
155
222
|
* @param {import("../engine/supervise.mjs").OutcomeNode} node
|
|
156
223
|
* @returns {string}
|
package/src/run/migrate.mjs
CHANGED
|
@@ -24,6 +24,16 @@
|
|
|
24
24
|
* redone, by the next run: it verifies the published copy still holds every
|
|
25
25
|
* byte the original holds and removes the original.
|
|
26
26
|
*
|
|
27
|
+
* A published copy that predates a path the original later gained can never
|
|
28
|
+
* pass that verification, and the branch that sees one runs no copy for the
|
|
29
|
+
* path to reach: measured 2026-09-21, `.runs/control/second-opinions` was
|
|
30
|
+
* written into this repository's original tree after the home side had
|
|
31
|
+
* become authoritative, and every migrate run refused on it identically.
|
|
32
|
+
* Repairing the copy in place would publish state nobody verified, so the
|
|
33
|
+
* refusal is made to carry the resolution instead — it names what to carry
|
|
34
|
+
* into the published copy by hand, and the run after that action completes
|
|
35
|
+
* the move.
|
|
36
|
+
*
|
|
27
37
|
* Idempotent by the same shape. A second run after a completed migration
|
|
28
38
|
* finds no legacy root and reports nothing to move — the normal case for an
|
|
29
39
|
* operator rerunning the command to be sure. A re-run after an interrupted
|
|
@@ -34,10 +44,18 @@
|
|
|
34
44
|
* composed here from `projectsDir` plus names `run/paths.mjs` keeps private;
|
|
35
45
|
* widening that module's surface for one caller is worse than spelling the
|
|
36
46
|
* two literals here, next to this comment.
|
|
47
|
+
*
|
|
48
|
+
* `repairCampaignRecords` belongs here for the same reason the move does:
|
|
49
|
+
* both bring state an older faberun wrote to the shape the current code
|
|
50
|
+
* reads. Read keeps refusing a record it cannot trust, so the repair is a
|
|
51
|
+
* verb rather than a silent default on read, and it names every record it
|
|
52
|
+
* repairs and the field it filled — a repair that happens unreported is
|
|
53
|
+
* corruption by another name.
|
|
37
54
|
*/
|
|
38
|
-
import { cpSync, existsSync, lstatSync, readFileSync, readlinkSync, readdirSync, renameSync, rmSync } from "node:fs";
|
|
55
|
+
import { cpSync, existsSync, lstatSync, readFileSync, readlinkSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
39
56
|
import { join } from "node:path";
|
|
40
|
-
import { campaignsDir } from "../campaign/layout.mjs";
|
|
57
|
+
import { CAMPAIGN_FILE, campaignsDir } from "../campaign/layout.mjs";
|
|
58
|
+
import { validateCampaign } from "../campaign/record.mjs";
|
|
41
59
|
import { readRemotes } from "../cli/project.mjs";
|
|
42
60
|
import { faberunHome } from "../host/home.mjs";
|
|
43
61
|
import { projectsDir, registerProject } from "../host/projects.mjs";
|
|
@@ -101,7 +119,7 @@ export function migrateRunState(cwd, options = {}) {
|
|
|
101
119
|
// became authoritative; this branch never writes to the copy, so nothing
|
|
102
120
|
// of theirs is at risk.
|
|
103
121
|
if (existsSync(target)) {
|
|
104
|
-
verifyCopy(legacy, target);
|
|
122
|
+
verifyCopy(legacy, target, "published");
|
|
105
123
|
const runs = countRunDirs(legacy);
|
|
106
124
|
const campaigns = countCampaigns(legacy);
|
|
107
125
|
refuseLiveLeases(leasesOf(legacy));
|
|
@@ -117,7 +135,7 @@ export function migrateRunState(cwd, options = {}) {
|
|
|
117
135
|
// moment the removal ran; verbatim keeps the literal target, so relative
|
|
118
136
|
// links (the worktrees' dependency symlinks) survive the move.
|
|
119
137
|
cpSync(legacy, staging, { recursive: true, verbatimSymlinks: true });
|
|
120
|
-
verifyCopy(legacy, staging);
|
|
138
|
+
verifyCopy(legacy, staging, "staging");
|
|
121
139
|
verifyNothingExtra(legacy, staging);
|
|
122
140
|
const runs = countRunDirs(legacy);
|
|
123
141
|
const campaigns = countCampaigns(legacy);
|
|
@@ -136,6 +154,65 @@ export function migrateRunState(cwd, options = {}) {
|
|
|
136
154
|
return { moved: true, legacy, target, runs, campaigns };
|
|
137
155
|
}
|
|
138
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Repair every campaign record under `runsDir` whose only defect is the
|
|
159
|
+
* absent `status` field: a record written before the field existed carries
|
|
160
|
+
* id, goal and linkedRunIds but no status, and discovery would report it
|
|
161
|
+
* corrupt forever. The repair fills the default current writes apply, writes
|
|
162
|
+
* the record back, and names every record it repaired and the field it
|
|
163
|
+
* filled in the returned list.
|
|
164
|
+
*
|
|
165
|
+
* The default is `closed` because an active campaign is one the product is
|
|
166
|
+
* currently driving, and a record written before the field existed has not
|
|
167
|
+
* been driven since.
|
|
168
|
+
*
|
|
169
|
+
* The predicate is the validator itself, run on the record with the default
|
|
170
|
+
* applied, so the repair cannot widen: a record that still fails validation
|
|
171
|
+
* with a valid status — malformed JSON, a missing id, a goal that is not
|
|
172
|
+
* text, a linkedRunIds that is not an array, a status present but wrong — is
|
|
173
|
+
* left exactly as discovery reports it. A record already carrying status is
|
|
174
|
+
* never rewritten, so a second run repairs nothing and writes nothing.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} runsDir
|
|
177
|
+
* @returns {{id: string, field: string}[]} one entry per repaired record
|
|
178
|
+
*/
|
|
179
|
+
export function repairCampaignRecords(runsDir) {
|
|
180
|
+
const campaigns = campaignsDir(runsDir);
|
|
181
|
+
if (!existsSync(campaigns)) return [];
|
|
182
|
+
/** @type {{id: string, field: string}[]} */
|
|
183
|
+
const repaired = [];
|
|
184
|
+
for (const entry of readdirSync(campaigns, { withFileTypes: true })) {
|
|
185
|
+
if (!entry.isDirectory()) continue;
|
|
186
|
+
const file = join(campaigns, entry.name, CAMPAIGN_FILE);
|
|
187
|
+
if (!existsSync(file)) continue;
|
|
188
|
+
let parsed;
|
|
189
|
+
try {
|
|
190
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
191
|
+
} catch {
|
|
192
|
+
// Unreadable or unparseable: there is no absent field to fill, and
|
|
193
|
+
// discovery keeps reporting the record corrupt.
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
197
|
+
const record = /** @type {Record<string, unknown>} */ (parsed);
|
|
198
|
+
// A status present but wrong is corruption, not age: only the absent
|
|
199
|
+
// field is repairable.
|
|
200
|
+
if (record.status !== undefined) continue;
|
|
201
|
+
record.status = "closed";
|
|
202
|
+
try {
|
|
203
|
+
validateCampaign(record);
|
|
204
|
+
} catch {
|
|
205
|
+
// The record fails the schema for a reason other than the absent
|
|
206
|
+
// status; the fill above dies with this in-memory object and the file
|
|
207
|
+
// is never written, so discovery's corrupt report stays true.
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`);
|
|
211
|
+
repaired.push({ id: String(record.id), field: "status" });
|
|
212
|
+
}
|
|
213
|
+
return repaired;
|
|
214
|
+
}
|
|
215
|
+
|
|
139
216
|
/**
|
|
140
217
|
* Every controller lock under `tree` whose holder is still alive. The lock
|
|
141
218
|
* file's name is spelled here because `run/lock.mjs` keeps the constant
|
|
@@ -184,22 +261,28 @@ function refuseLiveLeases(leases) {
|
|
|
184
261
|
* of what must be true, and byte-for-byte equality is the strongest check
|
|
185
262
|
* that proves it without trusting the copy step's own bookkeeping.
|
|
186
263
|
*
|
|
264
|
+
* `role` is which copy this is, and it only chooses what a refusal tells the
|
|
265
|
+
* operator to do about it: a staging copy is discarded and recopied by the
|
|
266
|
+
* next run, while a published copy is the one every reader answers while
|
|
267
|
+
* both trees exist, so only the operator can carry the difference into it.
|
|
268
|
+
*
|
|
187
269
|
* @param {string} source
|
|
188
270
|
* @param {string} copy
|
|
271
|
+
* @param {"staging"|"published"} role
|
|
189
272
|
* @returns {void}
|
|
190
273
|
*/
|
|
191
|
-
function verifyCopy(source, copy) {
|
|
274
|
+
function verifyCopy(source, copy, role) {
|
|
192
275
|
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
193
276
|
const from = join(source, entry.name);
|
|
194
277
|
const to = join(copy, entry.name);
|
|
195
278
|
if (entry.isDirectory()) {
|
|
196
279
|
// lstat, not stat: a copied symlink to a directory must not pass as
|
|
197
280
|
// the directory it points at.
|
|
198
|
-
if (!lstatSync(to, { throwIfNoEntry: false })?.isDirectory()) throw verifyFailure(from, to);
|
|
199
|
-
verifyCopy(from, to);
|
|
281
|
+
if (!lstatSync(to, { throwIfNoEntry: false })?.isDirectory()) throw verifyFailure(from, to, role);
|
|
282
|
+
verifyCopy(from, to, role);
|
|
200
283
|
} else if (entry.isSymbolicLink()) {
|
|
201
|
-
if (readLinkOrUndefined(to) !== readlinkSync(from)) throw verifyFailure(from, to);
|
|
202
|
-
} else if (readOrUndefined(to)?.equals(readFileSync(from)) !== true) throw verifyFailure(from, to);
|
|
284
|
+
if (readLinkOrUndefined(to) !== readlinkSync(from)) throw verifyFailure(from, to, role);
|
|
285
|
+
} else if (readOrUndefined(to)?.equals(readFileSync(from)) !== true) throw verifyFailure(from, to, role);
|
|
203
286
|
}
|
|
204
287
|
}
|
|
205
288
|
|
|
@@ -233,9 +316,23 @@ function verifyNothingExtra(source, copy) {
|
|
|
233
316
|
}
|
|
234
317
|
}
|
|
235
318
|
|
|
236
|
-
/**
|
|
237
|
-
|
|
238
|
-
|
|
319
|
+
/**
|
|
320
|
+
* The refusal is the only way out of a copy that does not verify, so it
|
|
321
|
+
* names the action that lets a later run finish, not just the mismatch. For
|
|
322
|
+
* a staging copy that action is nothing: the next run discards the staging
|
|
323
|
+
* and copies anew. For a published copy there is no next-run help — the
|
|
324
|
+
* branch runs no copy, and migrating must not repair a copy behind the
|
|
325
|
+
* operator's back — so the refusal carries the whole resolution: what to
|
|
326
|
+
* carry into the published copy, by hand, and that the run after that
|
|
327
|
+
* completes the move.
|
|
328
|
+
*
|
|
329
|
+
* @param {string} from @param {string} to @param {"staging"|"published"} role @returns {Error}
|
|
330
|
+
*/
|
|
331
|
+
function verifyFailure(from, to, role) {
|
|
332
|
+
const action = role === "published"
|
|
333
|
+
? `${to} is the copy every reader answers while both trees exist: move what ${from} holds that it lacks into it and settle any differing bytes by hand, then run migrate again`
|
|
334
|
+
: "the staging copy is discarded and recopied by the next run, so run migrate again";
|
|
335
|
+
return new Error(`migration copy does not verify: ${from} is missing or different at ${to}; nothing was published or removed — ${action}`);
|
|
239
336
|
}
|
|
240
337
|
|
|
241
338
|
/** @param {string} path @returns {Error} */
|