faberun 0.15.0 → 0.17.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 +2 -2
- package/skills/faberun/references/operations.md +22 -11
- package/src/campaign/journal.mjs +11 -2
- package/src/engine/cancel.mjs +20 -6
- package/src/engine/gate.mjs +8 -3
- package/src/engine/judge-gate.mjs +88 -1
- package/src/harnesses/index.mjs +5 -1
- package/src/host/preflight.mjs +5 -3
- package/src/notify/index.mjs +89 -32
- package/src/notify/session.mjs +240 -0
- package/src/plan/pipeline.mjs +14 -2
- package/src/plan/repo-facts.mjs +2 -0
- package/src/repo/signal.mjs +76 -9
- package/src/repo/worktree.mjs +28 -0
- package/src/report/locale.mjs +164 -0
- package/src/report/message.mjs +602 -0
- package/src/report/progress.mjs +33 -153
- package/src/seat/tmux.mjs +12 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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": {
|
|
@@ -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"
|
|
@@ -109,18 +109,29 @@ the contract is frozen with a digest, and the phone's middle ground is a note.
|
|
|
109
109
|
|
|
110
110
|
## Notify
|
|
111
111
|
|
|
112
|
-
On `node.terminal`, `run.terminal` and `attention` the controller renders
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
On `node.terminal`, `run.terminal` and `attention` the controller renders one
|
|
113
|
+
message from persisted state, in the operator's own language (detected from
|
|
114
|
+
the campaign goal, journal notes and node objectives; English otherwise):
|
|
115
|
+
line one is the outcome (`✅ <node> · done in 8m · $0.09`, `🏁 run 15 · <name>
|
|
116
|
+
· 2/2 done`, or `👀 <node> needs you · <error>`), then asked / done / proof
|
|
117
|
+
for a node, what every node delivered for a run, or why / asked / do for
|
|
118
|
+
attention, then a progress bar, a rule and the `🐦 faberun` signature with
|
|
119
|
+
campaign percent, cost and elapsed; an `⬆️` line names a newer release when
|
|
120
|
+
the cached update check has one. ≤2 KiB. It delivers that
|
|
121
|
+
same text to every bound transport at once, appending one receipt
|
|
122
|
+
(`delivered`, `failed`, `no_transport`, one entry per transport) to
|
|
117
123
|
`<run-dir>/notify.jsonl`. Delivery is lossy: **exactly one attempt**, no retry,
|
|
118
|
-
no backoff.
|
|
119
|
-
`
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
no backoff. `FABERUN_NOTIFY_BIN` names an executable called with the event as
|
|
125
|
+
JSON on stdin (`os-macos` selects the bundled `osascript` adapter); it pushes to
|
|
126
|
+
a person, `canWake: false`. `FABERUN_NOTIFY_SESSION=auto` wakes the harness
|
|
127
|
+
session the controller was launched from, `canWake: true`: the Claude Code
|
|
128
|
+
inbox socket and the Codex thread the environment names. A seat window sets it;
|
|
129
|
+
nothing else does, so a test suite never wakes a session. **On an inbound
|
|
130
|
+
`🐦 faberun` message**: `✅` or `🏁` with nothing waiting, answer in one line
|
|
131
|
+
and keep waiting; `👀`, act on the `do` command it names. A
|
|
132
|
+
resume never re-sends a notification already recorded for the same node, attempt
|
|
133
|
+
and outcome. No transport is a default: `doctor`, `preflight` and the foreground
|
|
134
|
+
launch warn when both variables are empty, and `--wake` names what will wake.
|
|
124
135
|
Campaign-level lines are queued in `.runs/inbox.jsonl`, the managed block's
|
|
125
136
|
append-only record — one object per line `{schemaVersion, eventId, at, type,
|
|
126
137
|
campaignId, runId, nodeId, status, errorCode, dedupeKey, summary}`, deduped on
|
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/engine/cancel.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { terminateInvocation } from "./process.mjs";
|
|
|
17
17
|
import { join, resolve } from "node:path";
|
|
18
18
|
import { readFileSync } from "node:fs";
|
|
19
19
|
import { readRunNodes } from "./scheduler.mjs";
|
|
20
|
-
import { deleteRef, releaseAttemptWorktree, runRefName } from "../repo/worktree.mjs";
|
|
20
|
+
import { createPreservedRef, deleteRef, releaseAttemptWorktree, runRefName } from "../repo/worktree.mjs";
|
|
21
21
|
import { syncAgentSignal } from "../repo/signal.mjs";
|
|
22
22
|
import { transition, writeNode } from "./state.mjs";
|
|
23
23
|
import { validateContract } from "../contract/index.mjs";
|
|
@@ -26,10 +26,11 @@ import { writeJsonAtomic } from "../run/store.mjs";
|
|
|
26
26
|
/** @typedef {import("./process.mjs").InvocationProbe} InvocationProbe */
|
|
27
27
|
/** @typedef {import("../cli.mjs").LockHandle} LockHandle */
|
|
28
28
|
/** @typedef {import("../run/lock.mjs").LockRecord} LockRecord */
|
|
29
|
+
/** @typedef {{preservedRefs: string[], released: string[]}} CancelResult */
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* @param {string} runDirPath
|
|
32
|
-
* @returns {Promise<
|
|
33
|
+
* @returns {Promise<CancelResult>}
|
|
33
34
|
*/
|
|
34
35
|
export async function cancelRun(runDirPath) {
|
|
35
36
|
const runDir = resolve(runDirPath);
|
|
@@ -108,20 +109,33 @@ export async function cancelRun(runDirPath) {
|
|
|
108
109
|
throw error;
|
|
109
110
|
}
|
|
110
111
|
if (!await waitForTerminal(runDir, 1_000)) throw new Error("cancel could not confirm a terminal run state");
|
|
112
|
+
// Preserved refs come first, before anything is released: a cancel that
|
|
113
|
+
// dies part-way must leave more work reachable, never less. If a creation
|
|
114
|
+
// fails here, nothing below has run and every integrated commit is as
|
|
115
|
+
// reachable as cancel found it. A node whose integratedHead is null was
|
|
116
|
+
// never integrated and gets none.
|
|
117
|
+
const preservedRefs = states
|
|
118
|
+
.filter((state) => state.integratedHead)
|
|
119
|
+
.map((state) => createPreservedRef(contract.cwd, contract.id, state.id, state.integratedHead));
|
|
111
120
|
// The run directory is evidence a campaign ledger may still want, so it
|
|
112
121
|
// stays; the run ref and every node's attempt branch are just git names
|
|
113
122
|
// the next launch of this same contract id needs back, and cancel is the
|
|
114
123
|
// operator saying this run is over. Releasing a name is not destroying a
|
|
115
|
-
// record
|
|
116
|
-
//
|
|
124
|
+
// record: the sha remains in the persisted snapshot, and the commit it
|
|
125
|
+
// names stays reachable through the preserved ref created above.
|
|
117
126
|
// Idempotent both ways: `removeWorktree` and `deleteRef` already tolerate
|
|
118
127
|
// an artefact a previous cancel (or the run itself) already released.
|
|
128
|
+
const released = [];
|
|
119
129
|
for (const state of states) {
|
|
120
|
-
if (state.worktree?.branch)
|
|
130
|
+
if (!state.worktree?.branch) continue;
|
|
131
|
+
releaseAttemptWorktree(contract.cwd, state.worktree.path, state.worktree.branch);
|
|
132
|
+
released.push(`refs/heads/${state.worktree.branch}`);
|
|
133
|
+
if (state.worktree.path) released.push(state.worktree.path);
|
|
121
134
|
}
|
|
122
135
|
deleteRef(contract.cwd, runRefName(contract.id));
|
|
136
|
+
released.push(runRefName(contract.id));
|
|
123
137
|
syncAgentSignal(join(runDir, ".."));
|
|
124
|
-
return
|
|
138
|
+
return { preservedRefs, released };
|
|
125
139
|
} finally {
|
|
126
140
|
controllerLock.release();
|
|
127
141
|
}
|
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/host/preflight.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { errorMessage } from "../util.mjs";
|
|
|
27
27
|
import { boundedGitSync } from "../repo/worktree.mjs";
|
|
28
28
|
import { routeRuntime } from "../contract/runtime.mjs";
|
|
29
29
|
import { NOTIFY_BIN_ENV, noTransportWarning } from "../notify/index.mjs";
|
|
30
|
+
import { NOTIFY_SESSION_ENV, sessionWakeNotice } from "../notify/session.mjs";
|
|
30
31
|
import { findExecutable } from "./platform.mjs";
|
|
31
32
|
import { colorLevel, statusToken } from "../cli/brand.mjs";
|
|
32
33
|
import { RUNS_DIR_NAME } from "../run/paths.mjs";
|
|
@@ -209,9 +210,9 @@ export function environmentPreflight(options) {
|
|
|
209
210
|
*/
|
|
210
211
|
export function notifyTransportCheck(env = process.env) {
|
|
211
212
|
const warning = noTransportWarning(env);
|
|
212
|
-
return warning
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
if (warning) return fail("notify transport", warning, true);
|
|
214
|
+
const external = env[NOTIFY_BIN_ENV] ? `${NOTIFY_BIN_ENV}=${env[NOTIFY_BIN_ENV]}` : `${NOTIFY_BIN_ENV} unset`;
|
|
215
|
+
return pass("notify transport", `${external} · ${sessionWakeNotice(env)}`);
|
|
215
216
|
}
|
|
216
217
|
|
|
217
218
|
/** @param {EnvReport} report @returns {EnvCheck[]} the checks that block a dispatch */
|
|
@@ -265,6 +266,7 @@ export function declaredVerificationCommands(contract) {
|
|
|
265
266
|
*/
|
|
266
267
|
const SIDE_EFFECT_ENV_KEYS = [
|
|
267
268
|
NOTIFY_BIN_ENV, // a measurement must not notify a human
|
|
269
|
+
NOTIFY_SESSION_ENV, // nor wake the harness session it was measured from
|
|
268
270
|
"FABERUN_CODEX_BIN", // could redirect the timed command at a live, paid codex binary instead of this repository's own fixtures
|
|
269
271
|
"FABERUN_CLAUDE_BIN", // same, for the claude harness
|
|
270
272
|
"FABERUN_AGY_BIN", // same, for the agy harness
|
package/src/notify/index.mjs
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Direct notification dispatcher (TECH-SPEC lean, rule 6). On `node.terminal`,
|
|
3
3
|
* `run.terminal` and `attention` the controller renders the message through
|
|
4
|
-
* `renderRunProgress` (`report/
|
|
5
|
-
* state,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
4
|
+
* `renderRunProgress` (`report/message.mjs`) from the run's own persisted
|
|
5
|
+
* state, delivers that one text to every bound transport at once, and appends
|
|
6
|
+
* one receipt (`delivered`, `failed` or `no_transport`, with the timestamp
|
|
7
|
+
* and one entry per transport) to `<run-dir>/notify.jsonl`. Two transports
|
|
8
|
+
* exist, additive and independently opted in: `FABERUN_NOTIFY_BIN`, an
|
|
9
|
+
* executable called with the event as JSON on stdin (a phone, a chat), and
|
|
10
|
+
* `FABERUN_NOTIFY_SESSION`, the harness session the controller was launched
|
|
11
|
+
* from (`session.mjs`), which is what wakes the operator's seat. Delivery is
|
|
12
|
+
* lossy: an event is attempted once per transport, a failure schedules no
|
|
13
|
+
* further attempt and is never requeued, and the controller never waits on a
|
|
14
|
+
* retry it will not make. The next read of the run's own artefacts carries
|
|
15
|
+
* the full state. With no transport bound nothing is spawned and a
|
|
16
|
+
* `no_transport` receipt is recorded instead — there is no implicit desktop
|
|
13
17
|
* fallback. The macOS notifier is reachable only by setting
|
|
14
18
|
* `FABERUN_NOTIFY_BIN=os-macos`, an explicit opt-in, never a default.
|
|
15
19
|
*
|
|
@@ -46,29 +50,53 @@ import { createHash } from "node:crypto";
|
|
|
46
50
|
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
|
|
47
51
|
import { join } from "node:path";
|
|
48
52
|
import { createMacosNotifier } from "./os-macos.mjs";
|
|
53
|
+
import { NOTIFY_SESSION_ENV, deliverToSessions, resolveSessionTargets, sessionWakeNotice } from "./session.mjs";
|
|
49
54
|
import { errorMessage } from "../util.mjs";
|
|
50
55
|
|
|
51
56
|
/**
|
|
52
|
-
* `report/
|
|
57
|
+
* `report/message.mjs` reaches back to this module (through
|
|
53
58
|
* `run/node-store.mjs` -> `run/disk-gc.mjs` -> `host/preflight.mjs`, which
|
|
54
59
|
* reads `NOTIFY_BIN_ENV`), so a static top-level import of it here would be a
|
|
55
60
|
* real cycle: `host/preflight.mjs` would read `NOTIFY_BIN_ENV` while this
|
|
56
61
|
* module's own top level was still mid-evaluation, before the `const` is
|
|
57
62
|
* assigned. A dynamic import resolves this module first and defers loading
|
|
58
|
-
* `report/
|
|
63
|
+
* `report/message.mjs` until the first call, by which point this module has
|
|
59
64
|
* already finished initializing -- so the cycle is real but harmless. Cached
|
|
60
65
|
* after the first call so every subsequent render reuses the same module.
|
|
61
|
-
* @type {Promise<typeof import("../report/
|
|
66
|
+
* @type {Promise<typeof import("../report/message.mjs")>|null}
|
|
62
67
|
*/
|
|
63
68
|
let progressModule = null;
|
|
64
|
-
/** @returns {Promise<typeof import("../report/
|
|
69
|
+
/** @returns {Promise<typeof import("../report/message.mjs")>} */
|
|
65
70
|
function loadProgressModule() {
|
|
66
|
-
progressModule ??= import("../report/
|
|
71
|
+
progressModule ??= import("../report/message.mjs");
|
|
67
72
|
return progressModule;
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
export const NOTIFY_BIN_ENV = "FABERUN_NOTIFY_BIN";
|
|
71
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
|
+
}
|
|
72
100
|
export const NOTIFY_LOG_FILE = "notify.jsonl";
|
|
73
101
|
/**
|
|
74
102
|
* The bounded retry budget the dispatcher used to spend before giving up.
|
|
@@ -129,7 +157,8 @@ export const NOTIFY_NO_TRANSPORT_WARNING = "no human notification transport is c
|
|
|
129
157
|
/** @typedef {{schemaVersion: number, eventId: string, at: string, type: string, campaignId: string|null, runId: string|null, nodeId: string|null, status: string|null, errorCode: string|null, dedupeKey: string, summary: string}} InboxEntry */
|
|
130
158
|
/** @typedef {{type: string, dedupeKey: string, summary: string, at?: string, campaignId?: string|null, runId?: string|null, nodeId?: string|null, status?: string|null, errorCode?: string|null}} InboxEvent */
|
|
131
159
|
/** @typedef {{type: "node.terminal"|"run.terminal"|"attention", runId: string|null, campaignId?: string|null, nodeId?: string|null, status?: string|null, attempt?: number|null, errorCode?: string|null, done?: number|null, total?: number|null, dedupeKey?: string|null, runDir?: string|null, costUsd?: number|null, summary?: string|null, eventId?: string}} NotifyEvent */
|
|
132
|
-
/** @typedef {{ok: boolean, error?: string,
|
|
160
|
+
/** @typedef {{id: string, ok: boolean, error?: string}} TransportOutcome one transport's own outcome, named so the receipt says which took the message */
|
|
161
|
+
/** @typedef {{ok: boolean, error?: string, noTransport?: boolean, transports?: TransportOutcome[]}} DeliveryResult */
|
|
133
162
|
|
|
134
163
|
/**
|
|
135
164
|
* Render the message for an event. For `node.terminal`, `run.terminal` and
|
|
@@ -250,25 +279,31 @@ function truncate(value) {
|
|
|
250
279
|
* @returns {string|null}
|
|
251
280
|
*/
|
|
252
281
|
export function noTransportWarning(env = process.env) {
|
|
253
|
-
|
|
282
|
+
if (env[NOTIFY_BIN_ENV]) return null;
|
|
283
|
+
if (resolveSessionTargets(env).length) return null;
|
|
284
|
+
return NOTIFY_NO_TRANSPORT_WARNING;
|
|
254
285
|
}
|
|
255
286
|
|
|
256
287
|
/**
|
|
257
|
-
* What `campaign watch --wake` must say about waking.
|
|
258
|
-
*
|
|
259
|
-
*
|
|
288
|
+
* What `campaign watch --wake` must say about waking. The external transport
|
|
289
|
+
* never wakes anything (`os-macos` and every `FABERUN_NOTIFY_BIN` executable
|
|
290
|
+
* are `canWake: false`: they push to a person); the session transports are
|
|
291
|
+
* the only `canWake: true`, and the notice says which of them the environment
|
|
292
|
+
* resolves to, so the verb never implies a session was woken when none will be.
|
|
260
293
|
*
|
|
261
294
|
* @param {string|undefined} [bin]
|
|
295
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
262
296
|
* @returns {string}
|
|
263
297
|
*/
|
|
264
|
-
export function wakeCapabilityNotice(bin = process.env[NOTIFY_BIN_ENV]) {
|
|
298
|
+
export function wakeCapabilityNotice(bin = process.env[NOTIFY_BIN_ENV], env = process.env) {
|
|
299
|
+
const session = sessionWakeNotice(env);
|
|
265
300
|
if (!bin) {
|
|
266
|
-
return
|
|
301
|
+
return `no external notify transport is configured; --wake records to .runs/inbox.jsonl and the AGENTS.md managed block; ${session}`;
|
|
267
302
|
}
|
|
268
303
|
if (bin === MACOS_TRANSPORT) {
|
|
269
|
-
return
|
|
304
|
+
return `os-macos cannot wake a session (canWake: false); --wake records to .runs/inbox.jsonl and the AGENTS.md managed block; ${session}`;
|
|
270
305
|
}
|
|
271
|
-
return `notify transport ${bin} declares canWake: false; --wake records to .runs/inbox.jsonl and the AGENTS.md managed block;
|
|
306
|
+
return `notify transport ${bin} declares canWake: false; --wake records to .runs/inbox.jsonl and the AGENTS.md managed block; ${session}`;
|
|
272
307
|
}
|
|
273
308
|
|
|
274
309
|
/** @param {string} runsDir @returns {string} */
|
|
@@ -345,20 +380,39 @@ export function appendInbox(runsDir, event) {
|
|
|
345
380
|
}
|
|
346
381
|
|
|
347
382
|
/**
|
|
348
|
-
* Deliver one event through
|
|
349
|
-
*
|
|
383
|
+
* Deliver one event through every bound transport at once: the external
|
|
384
|
+
* executable and each resolved harness session, all given the same rendered
|
|
385
|
+
* text. The result is `ok` when any one of them took the message, carries
|
|
386
|
+
* every transport's own outcome for the receipt, and is
|
|
387
|
+
* `{ok: false, noTransport: true}` when nothing at all is bound -- without
|
|
388
|
+
* spawning or connecting anything.
|
|
350
389
|
*
|
|
351
390
|
* @param {{type: string, summary: string, campaignId?: string|null, [key: string]: unknown}} event
|
|
352
|
-
* @param {{bin?: string, spawn?: typeof defaultSpawn, timeoutMs?: number}} [options]
|
|
391
|
+
* @param {{bin?: string, env?: NodeJS.ProcessEnv, spawn?: typeof defaultSpawn, timeoutMs?: number}} [options]
|
|
353
392
|
* @returns {Promise<DeliveryResult>}
|
|
354
393
|
*/
|
|
355
|
-
function deliverNotification(event, options = {}) {
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
394
|
+
async function deliverNotification(event, options = {}) {
|
|
395
|
+
const env = options.env ?? process.env;
|
|
396
|
+
const bin = options.bin ?? env[NOTIFY_BIN_ENV];
|
|
397
|
+
const targets = resolveSessionTargets(env);
|
|
398
|
+
/** @type {Promise<TransportOutcome>[]} */
|
|
399
|
+
const attempts = [];
|
|
400
|
+
if (bin) {
|
|
401
|
+
const external = bin === MACOS_TRANSPORT
|
|
402
|
+
? createMacosNotifier({ spawn: options.spawn }).deliver(/** @type {any} */ (event))
|
|
403
|
+
: spawnDeliver(bin, event, options);
|
|
404
|
+
attempts.push(external.then((result) => ({ id: bin === MACOS_TRANSPORT ? MACOS_TRANSPORT : "bin", ...result })));
|
|
360
405
|
}
|
|
361
|
-
|
|
406
|
+
/** @type {Promise<TransportOutcome[]>} */
|
|
407
|
+
const sessions = targets.length ? deliverToSessions(event, targets, { timeoutMs: options.timeoutMs, env }) : Promise.resolve([]);
|
|
408
|
+
if (!attempts.length && !targets.length) return { ok: false, noTransport: true, transports: [] };
|
|
409
|
+
const transports = [...(await Promise.all(attempts)), ...(await sessions)];
|
|
410
|
+
const failures = transports.filter((outcome) => !outcome.ok).map((outcome) => `${outcome.id}: ${outcome.error ?? "failed"}`);
|
|
411
|
+
return {
|
|
412
|
+
ok: transports.some((outcome) => outcome.ok),
|
|
413
|
+
...(failures.length ? { error: failures.join("; ") } : {}),
|
|
414
|
+
transports,
|
|
415
|
+
};
|
|
362
416
|
}
|
|
363
417
|
|
|
364
418
|
/**
|
|
@@ -472,6 +526,9 @@ export class NotifyQueue {
|
|
|
472
526
|
summary,
|
|
473
527
|
attempt: 1,
|
|
474
528
|
status: result.ok ? "delivered" : result.noTransport ? "no_transport" : "failed",
|
|
529
|
+
// One entry per bound transport, so a receipt that says `delivered`
|
|
530
|
+
// also says whether the phone, the session, or both took the message.
|
|
531
|
+
transports: result.transports ?? [],
|
|
475
532
|
at: new Date(this.now()).toISOString(),
|
|
476
533
|
};
|
|
477
534
|
if (!result.ok && !result.noTransport) receipt.error = result.error ?? null;
|