faberun 0.3.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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Garbage collection for `.runs/` under disk pressure.
|
|
3
|
+
*
|
|
4
|
+
* A finished run's directory (contract, node snapshots, invocation logs) is
|
|
5
|
+
* disposable once nothing can ever read it again: every node is terminal, no
|
|
6
|
+
* controller holds it, and it is not the run currently writing. Reclaiming it
|
|
7
|
+
* is the only thing that can turn an ENOSPC mid-run into something a resume
|
|
8
|
+
* can recover from, since the disk will not free itself.
|
|
9
|
+
*
|
|
10
|
+
* Selection is a pure function over already-gathered facts (`describeRuns`
|
|
11
|
+
* reads the disk; `selectGarbageCollectableRuns` does not) so the eligibility
|
|
12
|
+
* rules are testable without creating a single file. Removal is a separate,
|
|
13
|
+
* impure step that stops as soon as free space clears the threshold — it
|
|
14
|
+
* never removes more than it has to — and it never removes `.runs/campaigns/`
|
|
15
|
+
* or `.runs/archive/`, the durable handoff and any future archive, no matter
|
|
16
|
+
* what a caller passes in.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
19
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
20
|
+
import { TERMINAL } from "../engine/prompts.mjs";
|
|
21
|
+
import { lockStale, readLock } from "./lock.mjs";
|
|
22
|
+
|
|
23
|
+
import { checkDisk, minFreeDiskBytes } from "../host/preflight.mjs";
|
|
24
|
+
import { appendJsonl, writeTextAtomic } from "./store.mjs";
|
|
25
|
+
import { errorCode } from "../util.mjs";
|
|
26
|
+
import { campaignsDir } from "../campaign/layout.mjs";
|
|
27
|
+
|
|
28
|
+
/** @typedef {{path: string, startedAt: string|null, hasActiveController: boolean, allNodesTerminal: boolean}} RunDescriptor */
|
|
29
|
+
|
|
30
|
+
/** Names under `.runs/` a run directory can never be, in any circumstance. */
|
|
31
|
+
const RESERVED_RUN_DIR_NAMES = new Set(["campaigns", "archive"]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Gather the facts GC needs about every candidate under `runsDir`, straight
|
|
35
|
+
* off disk. A directory this cannot positively identify as a run (no
|
|
36
|
+
* readable `run.json`) is never described at all — `selectGarbageCollectableRuns`
|
|
37
|
+
* only ever sees directories this function is sure are runs.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} runsDir
|
|
40
|
+
* @returns {RunDescriptor[]}
|
|
41
|
+
*/
|
|
42
|
+
export function describeRuns(runsDir) {
|
|
43
|
+
/** @type {string[]} */
|
|
44
|
+
let names;
|
|
45
|
+
try {
|
|
46
|
+
names = readdirSync(runsDir);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (errorCode(error) === "ENOENT") return [];
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
const reservedPaths = new Set([campaignsDir(runsDir), join(runsDir, "archive")].map((path) => resolve(path)));
|
|
52
|
+
/** @type {RunDescriptor[]} */
|
|
53
|
+
const descriptors = [];
|
|
54
|
+
for (const name of names) {
|
|
55
|
+
if (RESERVED_RUN_DIR_NAMES.has(name)) continue;
|
|
56
|
+
const path = join(runsDir, name);
|
|
57
|
+
if (reservedPaths.has(resolve(path))) continue;
|
|
58
|
+
let stat;
|
|
59
|
+
try {
|
|
60
|
+
stat = statSync(path);
|
|
61
|
+
} catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!stat.isDirectory()) continue;
|
|
65
|
+
/** @type {string|null} */
|
|
66
|
+
let startedAt = null;
|
|
67
|
+
try {
|
|
68
|
+
const metadata = JSON.parse(readFileSync(join(path, "run.json"), "utf8"));
|
|
69
|
+
startedAt = typeof metadata.startedAt === "string" ? metadata.startedAt : null;
|
|
70
|
+
} catch {
|
|
71
|
+
// Not a recognizable run directory (missing or unreadable run.json) —
|
|
72
|
+
// never described, so it can never be selected.
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const lock = readLock(path);
|
|
76
|
+
descriptors.push({
|
|
77
|
+
path,
|
|
78
|
+
startedAt,
|
|
79
|
+
hasActiveController: lock !== null && !lockStale(lock),
|
|
80
|
+
allNodesTerminal: allNodeStatesTerminal(path),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return descriptors;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} runDir
|
|
88
|
+
* @returns {boolean}
|
|
89
|
+
*/
|
|
90
|
+
function allNodeStatesTerminal(runDir) {
|
|
91
|
+
/** @type {string[]} */
|
|
92
|
+
let names;
|
|
93
|
+
try {
|
|
94
|
+
names = readdirSync(join(runDir, "nodes")).filter((name) => name.endsWith(".json"));
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
if (names.length === 0) return false;
|
|
99
|
+
for (const name of names) {
|
|
100
|
+
try {
|
|
101
|
+
const state = JSON.parse(readFileSync(join(runDir, "nodes", name), "utf8"));
|
|
102
|
+
if (!TERMINAL.has(state.status)) return false;
|
|
103
|
+
} catch {
|
|
104
|
+
// An unreadable or corrupt node snapshot is never provably terminal.
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Pure selection: every rule here is checked against already-gathered facts,
|
|
113
|
+
* never the filesystem, so this is testable with hand-built descriptors and
|
|
114
|
+
* no run directory ever created. Eligible runs are returned oldest first —
|
|
115
|
+
* `run.json`'s own `startedAt` is the only ordering signal, so a run whose
|
|
116
|
+
* `startedAt` this could not read is never eligible; there is nothing safe to
|
|
117
|
+
* compare it against.
|
|
118
|
+
*
|
|
119
|
+
* @param {RunDescriptor[]} descriptors
|
|
120
|
+
* @param {{currentRunDir?: string|null}} [options]
|
|
121
|
+
* @returns {string[]} run directories eligible for GC, oldest to newest
|
|
122
|
+
*/
|
|
123
|
+
export function selectGarbageCollectableRuns(descriptors, options = {}) {
|
|
124
|
+
const currentRunDir = options.currentRunDir ? resolve(options.currentRunDir) : null;
|
|
125
|
+
return descriptors
|
|
126
|
+
.filter((run) => !RESERVED_RUN_DIR_NAMES.has(basename(run.path)))
|
|
127
|
+
.filter((run) => typeof run.startedAt === "string")
|
|
128
|
+
.filter((run) => run.allNodesTerminal)
|
|
129
|
+
.filter((run) => !run.hasActiveController)
|
|
130
|
+
.filter((run) => resolve(run.path) !== currentRunDir)
|
|
131
|
+
.sort((a, b) => Date.parse(/** @type {string} */ (a.startedAt)) - Date.parse(/** @type {string} */ (b.startedAt)))
|
|
132
|
+
.map((run) => run.path);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Remove eligible run directories, oldest first, stopping the instant free
|
|
137
|
+
* space clears the threshold — never more than the minimum necessary. Every
|
|
138
|
+
* removal is appended to `<runsDir>/gc.jsonl` with its path and reason before
|
|
139
|
+
* the next candidate is even considered, so a removal is never silent.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} runsDir
|
|
142
|
+
* @param {{currentRunDir?: string|null, minFreeBytes?: number, probePath?: string, reason?: string, isAboveThreshold?: () => boolean}} [options]
|
|
143
|
+
* @returns {{removed: string[]}}
|
|
144
|
+
*/
|
|
145
|
+
export function runGarbageCollection(runsDir, options = {}) {
|
|
146
|
+
const minFreeBytes = options.minFreeBytes ?? minFreeDiskBytes(process.env);
|
|
147
|
+
const probePath = options.probePath ?? runsDir;
|
|
148
|
+
const reason = options.reason ?? "enospc";
|
|
149
|
+
// A real statfs threshold check by default, unless a case deterministically
|
|
150
|
+
// simulates disk pressure (see `simulatedDiskPressureOverride`) — the same
|
|
151
|
+
// threshold `checkDisk` already reuses, just paired with a way to prove the
|
|
152
|
+
// removal loop without waiting on real free space to move. Tests may also
|
|
153
|
+
// substitute their own stub directly instead of either.
|
|
154
|
+
const isAboveThreshold = options.isAboveThreshold
|
|
155
|
+
?? (() => simulatedDiskPressureOverride() ?? checkDisk(probePath, minFreeBytes).ok);
|
|
156
|
+
/** @type {string[]} */
|
|
157
|
+
const removed = [];
|
|
158
|
+
if (isAboveThreshold()) return { removed };
|
|
159
|
+
const descriptors = describeRuns(runsDir);
|
|
160
|
+
const candidates = selectGarbageCollectableRuns(descriptors, { currentRunDir: options.currentRunDir });
|
|
161
|
+
for (const runDir of candidates) {
|
|
162
|
+
if (isAboveThreshold()) break;
|
|
163
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
164
|
+
removed.push(runDir);
|
|
165
|
+
appendJsonl(join(runsDir, "gc.jsonl"), { at: new Date().toISOString(), path: runDir, reason });
|
|
166
|
+
}
|
|
167
|
+
return { removed };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Error code a run stops with when GC could not recover from disk pressure. */
|
|
171
|
+
export const DISK_PRESSURE_UNRECOVERABLE = "disk_pressure_unrecoverable";
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Write one run-directory text file, running GC exactly once and retrying
|
|
175
|
+
* exactly once if the first write fails with ENOSPC. A second ENOSPC is never
|
|
176
|
+
* retried again and never swallowed: it becomes a distinct, named error so
|
|
177
|
+
* the run stops visibly instead of failing on whatever generic thing ENOSPC
|
|
178
|
+
* happened to break next.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} runDir
|
|
181
|
+
* @param {string} path
|
|
182
|
+
* @param {string} text
|
|
183
|
+
*/
|
|
184
|
+
export function writeRunTextWithDiskPressureRetry(runDir, path, text) {
|
|
185
|
+
try {
|
|
186
|
+
simulateEnospcForTest(path);
|
|
187
|
+
writeTextAtomic(path, text);
|
|
188
|
+
return;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (errorCode(error) !== "ENOSPC") throw error;
|
|
191
|
+
}
|
|
192
|
+
runGarbageCollection(dirname(runDir), { currentRunDir: runDir });
|
|
193
|
+
try {
|
|
194
|
+
simulateEnospcForTest(path);
|
|
195
|
+
writeTextAtomic(path, text);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (errorCode(error) !== "ENOSPC") throw error;
|
|
198
|
+
throw Object.assign(
|
|
199
|
+
new Error(`disk pressure persists after garbage collection while writing ${path}`),
|
|
200
|
+
{ code: DISK_PRESSURE_UNRECOVERABLE },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Deterministic ENOSPC injection for tests and evals, never a real full
|
|
207
|
+
* disk. Inert unless `FABERUN_SIMULATE_ENOSPC_MATCH` names a substring
|
|
208
|
+
* of `path` and `FABERUN_SIMULATE_ENOSPC_COUNT` holds a positive
|
|
209
|
+
* integer; each simulated failure decrements that count, so a case sets it to
|
|
210
|
+
* `1` to prove GC recovers the write and `2` to prove a second ENOSPC in a
|
|
211
|
+
* row is never retried again.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} path
|
|
214
|
+
*/
|
|
215
|
+
function simulateEnospcForTest(path) {
|
|
216
|
+
const match = process.env.FABERUN_SIMULATE_ENOSPC_MATCH;
|
|
217
|
+
if (!match || !path.includes(match)) return;
|
|
218
|
+
const remaining = Number(process.env.FABERUN_SIMULATE_ENOSPC_COUNT ?? "0");
|
|
219
|
+
if (!Number.isInteger(remaining) || remaining <= 0) return;
|
|
220
|
+
process.env.FABERUN_SIMULATE_ENOSPC_COUNT = String(remaining - 1);
|
|
221
|
+
throw Object.assign(
|
|
222
|
+
new Error(`ENOSPC: simulated no space left on device, write '${path}'`),
|
|
223
|
+
{ code: "ENOSPC", errno: -28, syscall: "write", path },
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Deterministic stand-in for "free space is still below the threshold",
|
|
229
|
+
* paired with `simulateEnospcForTest` so a case can prove the removal loop
|
|
230
|
+
* itself — not just the write retry — without waiting on real free space to
|
|
231
|
+
* move. Inert unless `FABERUN_SIMULATE_GC_ROUNDS` holds a non-negative
|
|
232
|
+
* integer; never consulted by `environmentPreflight`'s own disk check, which
|
|
233
|
+
* always reads real free space.
|
|
234
|
+
*
|
|
235
|
+
* The count is calls, not removals: `runGarbageCollection` calls this once
|
|
236
|
+
* before considering any candidate, then once more before each one it
|
|
237
|
+
* removes, so a run with `n` eligible candidates needs `n + 1` to remove all
|
|
238
|
+
* of them, or fewer to stop early after that many candidates.
|
|
239
|
+
*
|
|
240
|
+
* @returns {boolean|null} `false`/`true` to override the real check, or
|
|
241
|
+
* `null` when no simulation is configured and the real check should decide
|
|
242
|
+
*/
|
|
243
|
+
function simulatedDiskPressureOverride() {
|
|
244
|
+
const raw = process.env.FABERUN_SIMULATE_GC_ROUNDS;
|
|
245
|
+
if (raw === undefined) return null;
|
|
246
|
+
const remaining = Number(raw);
|
|
247
|
+
if (!Number.isInteger(remaining) || remaining < 0) return null;
|
|
248
|
+
if (remaining <= 0) return true;
|
|
249
|
+
process.env.FABERUN_SIMULATE_GC_ROUNDS = String(remaining - 1);
|
|
250
|
+
return false;
|
|
251
|
+
}
|
package/src/run/lock.mjs
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One controller per run, held by an atomic lock file (TECH-SPEC lean, rule 5).
|
|
3
|
+
*
|
|
4
|
+
* `controller.lock` records the holder's pid, its process start token, and
|
|
5
|
+
* when it started. There is no expiry and nothing to renew: a lock stays
|
|
6
|
+
* valid for as long as its holder is alive, however long that takes, and a
|
|
7
|
+
* contender treats it as stale only once it can prove the holder dead — the
|
|
8
|
+
* pid is gone, or its start token no longer matches (the pid was recycled).
|
|
9
|
+
* That is a strictly stronger claim than a lease's TTL, so there is no
|
|
10
|
+
* healthy-but-expired window and nothing to fence against: at most one
|
|
11
|
+
* process can ever hold a live pid, so at most one takeover can ever observe
|
|
12
|
+
* a captured lock as stale.
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from "node:fs";
|
|
17
|
+
import { hostname } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { fsyncDirectory } from "./store.mjs";
|
|
20
|
+
import { errorCode } from "../util.mjs";
|
|
21
|
+
|
|
22
|
+
const LOCK_FILE = "controller.lock";
|
|
23
|
+
const TAKEOVER_ATTEMPTS = 20;
|
|
24
|
+
|
|
25
|
+
export class LockBusyError extends Error {
|
|
26
|
+
/** @param {string} message @param {unknown} lock */
|
|
27
|
+
constructor(message, lock = null) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "LockBusyError";
|
|
30
|
+
this.code = "lock_busy";
|
|
31
|
+
this.lock = lock;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class LockLostError extends Error {
|
|
36
|
+
constructor(message = "controller lock was lost") {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "LockLostError";
|
|
39
|
+
this.code = "lock_lost";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @typedef {{schemaVersion: number, pid: number, processStartToken: string|null, startedAt: string, hostname: string, invalid?: never}} LockRecord */
|
|
44
|
+
/** @typedef {LockRecord|null|{invalid: true}} ReadLockResult */
|
|
45
|
+
/** @typedef {{pid?: number, processStartToken?: string|null}} LockOptions */
|
|
46
|
+
|
|
47
|
+
/** @param {string} runDir @returns {string} */
|
|
48
|
+
export function lockPath(runDir) {
|
|
49
|
+
return join(runDir, LOCK_FILE);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The process start time distinguishes a live pid from a recycled one. On
|
|
54
|
+
* Linux, field 22 of /proc/<pid>/stat. On darwin, there is no /proc, so the
|
|
55
|
+
* fingerprint comes from the OS process table instead: `ps -o lstart=`
|
|
56
|
+
* reports the same live process's own start time on every call and a
|
|
57
|
+
* different one for whatever process next reuses that pid, without a
|
|
58
|
+
* compiled addon or elevated privileges. Every other platform has no cheap
|
|
59
|
+
* equivalent, so the pid probe alone decides there.
|
|
60
|
+
* @param {number|null} pid @returns {string|null}
|
|
61
|
+
*/
|
|
62
|
+
export function processStartToken(pid) {
|
|
63
|
+
if (!pid) return null;
|
|
64
|
+
if (process.platform === "linux") {
|
|
65
|
+
try {
|
|
66
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8").trim();
|
|
67
|
+
return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19] ?? null;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (process.platform === "darwin") {
|
|
73
|
+
const started = psStartTime(pid);
|
|
74
|
+
if (started.length > 0) return started;
|
|
75
|
+
// Immediately after spawn, `ps` can race the kernel's process-table insert
|
|
76
|
+
// and report nothing for a pid that is already alive. One short retry
|
|
77
|
+
// closes that window; a still-empty answer stays null.
|
|
78
|
+
if (!pidAlive(pid)) return null;
|
|
79
|
+
sleepMs(20);
|
|
80
|
+
const retried = psStartTime(pid);
|
|
81
|
+
return retried.length > 0 ? retried : null;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `ps -o lstart=` for one pid, or the empty string when the probe finds
|
|
88
|
+
* nothing. It is the darwin half of the start-token fingerprint.
|
|
89
|
+
*
|
|
90
|
+
* @param {number} pid
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
function psStartTime(pid) {
|
|
94
|
+
try {
|
|
95
|
+
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }).trim();
|
|
96
|
+
} catch {
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Block without a timer so the synchronous darwin retry above can wait out the
|
|
103
|
+
* spawn race. The duration is tiny and bounded, so blocking the loop is safe.
|
|
104
|
+
*
|
|
105
|
+
* @param {number} milliseconds
|
|
106
|
+
*/
|
|
107
|
+
function sleepMs(milliseconds) {
|
|
108
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @param {number|null|undefined} pid @returns {boolean} */
|
|
112
|
+
export function pidAlive(pid) {
|
|
113
|
+
if (!Number.isInteger(pid) || /** @type {number} */ (pid) <= 0) return false;
|
|
114
|
+
try {
|
|
115
|
+
process.kill(/** @type {number} */ (pid), 0);
|
|
116
|
+
return true;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return errorCode(error) === "EPERM";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @param {string} runDir @returns {ReadLockResult} */
|
|
123
|
+
export function readLock(runDir) {
|
|
124
|
+
try {
|
|
125
|
+
return /** @type {LockRecord} */ (JSON.parse(readFileSync(lockPath(runDir), "utf8")));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (errorCode(error) === "ENOENT") return null;
|
|
128
|
+
if (error instanceof SyntaxError) return { invalid: true };
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A lock is stale only once its holder is proven dead. A live holder keeps
|
|
135
|
+
* the lock no matter its age — there is no expiry to race.
|
|
136
|
+
* @param {ReadLockResult} lock @returns {boolean}
|
|
137
|
+
*/
|
|
138
|
+
export function lockStale(lock) {
|
|
139
|
+
if (!lock || /** @type {{invalid?: true}} */ (lock).invalid) return true;
|
|
140
|
+
const record = /** @type {LockRecord} */ (lock);
|
|
141
|
+
if (!pidAlive(record.pid)) return true;
|
|
142
|
+
return Boolean(record.processStartToken) && processStartToken(record.pid) !== record.processStartToken;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Acquire the run's controller lock, taking over a stale one. Contention on a
|
|
147
|
+
* live lock fails immediately: there is nothing to wait for, since a live
|
|
148
|
+
* holder does not become dead within this call.
|
|
149
|
+
* @param {string} runDir @param {LockOptions} [options] @returns {LockRecord & {current: LockRecord, assert: () => void, release: () => void, released: boolean}}
|
|
150
|
+
*/
|
|
151
|
+
export function acquire(runDir, options = {}) {
|
|
152
|
+
mkdirSync(runDir, { recursive: true });
|
|
153
|
+
const path = lockPath(runDir);
|
|
154
|
+
const pid = options.pid ?? process.pid;
|
|
155
|
+
/** @type {LockRecord} */
|
|
156
|
+
const record = {
|
|
157
|
+
schemaVersion: 1,
|
|
158
|
+
pid,
|
|
159
|
+
processStartToken: options.processStartToken !== undefined ? options.processStartToken : processStartToken(pid),
|
|
160
|
+
startedAt: new Date().toISOString(),
|
|
161
|
+
hostname: hostname(),
|
|
162
|
+
};
|
|
163
|
+
for (let attempt = 0; attempt < TAKEOVER_ATTEMPTS; attempt += 1) {
|
|
164
|
+
try {
|
|
165
|
+
writeExclusive(path, record, runDir);
|
|
166
|
+
return createHandle(runDir, record);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (errorCode(error) !== "EEXIST") throw error;
|
|
169
|
+
}
|
|
170
|
+
const occupant = readLock(runDir);
|
|
171
|
+
if (!lockStale(occupant)) {
|
|
172
|
+
throw new LockBusyError(`run controller lock is held by pid ${/** @type {LockRecord} */ (occupant)?.pid}`, occupant);
|
|
173
|
+
}
|
|
174
|
+
// Capture is one atomic rename: there is no gap in which a live successor
|
|
175
|
+
// could install under the name and be destroyed by a decision made about
|
|
176
|
+
// its dead predecessor. A capture that turns out to still be live — the
|
|
177
|
+
// successor won the race between our read and our rename — is handed
|
|
178
|
+
// straight back, never discarded.
|
|
179
|
+
const aside = captureEntry(path);
|
|
180
|
+
if (aside === null) continue;
|
|
181
|
+
const captured = readCapturedLock(aside);
|
|
182
|
+
if (!lockStale(captured)) {
|
|
183
|
+
try {
|
|
184
|
+
linkSync(aside, path);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (errorCode(error) !== "EEXIST") throw error;
|
|
187
|
+
}
|
|
188
|
+
discardEntry(aside);
|
|
189
|
+
fsyncDirectory(runDir);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
discardEntry(aside);
|
|
193
|
+
fsyncDirectory(runDir);
|
|
194
|
+
}
|
|
195
|
+
throw new LockBusyError(`contended controller lock takeover for ${runDir} did not settle`, readLock(runDir));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** @param {string} path @param {LockRecord} record @param {string} runDir */
|
|
199
|
+
function writeExclusive(path, record, runDir) {
|
|
200
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
201
|
+
const fd = openSync(temporary, "wx", 0o600);
|
|
202
|
+
try {
|
|
203
|
+
writeSync(fd, `${JSON.stringify(record, null, 2)}\n`, 0, "utf8");
|
|
204
|
+
fsyncSync(fd);
|
|
205
|
+
} finally {
|
|
206
|
+
closeSync(fd);
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
linkSync(temporary, path);
|
|
210
|
+
fsyncDirectory(runDir);
|
|
211
|
+
} finally {
|
|
212
|
+
discardEntry(temporary);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @param {string} path @returns {string|null} */
|
|
217
|
+
function captureEntry(path) {
|
|
218
|
+
const aside = `${path}.captured.${process.pid}.${randomUUID()}`;
|
|
219
|
+
try {
|
|
220
|
+
renameSync(path, aside);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (errorCode(error) === "ENOENT") return null;
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
return aside;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** @param {string} path @returns {ReadLockResult} */
|
|
229
|
+
function readCapturedLock(path) {
|
|
230
|
+
try {
|
|
231
|
+
return /** @type {LockRecord} */ (JSON.parse(readFileSync(path, "utf8")));
|
|
232
|
+
} catch {
|
|
233
|
+
return { invalid: true };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** @param {string} path */
|
|
238
|
+
function discardEntry(path) {
|
|
239
|
+
try {
|
|
240
|
+
unlinkSync(path);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (errorCode(error) !== "ENOENT") throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** @param {string} runDir @param {LockRecord} record */
|
|
247
|
+
function createHandle(runDir, record) {
|
|
248
|
+
const path = lockPath(runDir);
|
|
249
|
+
let released = false;
|
|
250
|
+
/** @returns {boolean} */
|
|
251
|
+
const held = () => {
|
|
252
|
+
const actual = readLock(runDir);
|
|
253
|
+
return Boolean(actual) && !/** @type {{invalid?: true}} */ (actual).invalid
|
|
254
|
+
&& /** @type {LockRecord} */ (actual).pid === record.pid
|
|
255
|
+
&& /** @type {LockRecord} */ (actual).startedAt === record.startedAt;
|
|
256
|
+
};
|
|
257
|
+
return {
|
|
258
|
+
...record,
|
|
259
|
+
get current() { return record; },
|
|
260
|
+
assert() {
|
|
261
|
+
if (released || !held()) throw new LockLostError();
|
|
262
|
+
},
|
|
263
|
+
release() {
|
|
264
|
+
if (released) return;
|
|
265
|
+
if (held()) {
|
|
266
|
+
discardEntry(path);
|
|
267
|
+
fsyncDirectory(runDir);
|
|
268
|
+
}
|
|
269
|
+
released = true;
|
|
270
|
+
},
|
|
271
|
+
get released() { return released; },
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Detached-bootstrap identity (TECH-SPEC section 4.3).
|
|
277
|
+
*
|
|
278
|
+
* A `run --detach` parent hands its child a nonce and later needs proof that
|
|
279
|
+
* the bootstrap record and the controller lock it finds really belong to the
|
|
280
|
+
* pid it spawned, not to some other process that reused that pid in the
|
|
281
|
+
* meantime. Matching pid and nonce alone is not that proof: a nonce is
|
|
282
|
+
* generated by the parent and never reused, but a recycled pid could
|
|
283
|
+
* coincidentally match while belonging to an unrelated process if the
|
|
284
|
+
* parent's own child died and something else took its pid before the parent
|
|
285
|
+
* finished checking. The process start token is what rules that out.
|
|
286
|
+
*/
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* @param {string|null|undefined} actual
|
|
290
|
+
* @param {string|null|undefined} expected
|
|
291
|
+
* @returns {boolean}
|
|
292
|
+
*/
|
|
293
|
+
export function sameProcessStartToken(actual, expected) {
|
|
294
|
+
return actual === expected;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* @param {unknown} value
|
|
299
|
+
* @returns {boolean}
|
|
300
|
+
*/
|
|
301
|
+
export function validBootstrapNonce(value) {
|
|
302
|
+
return typeof value === "string" && /^[A-Za-z0-9-]{16,64}$/u.test(value);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* @param {{pid?: number, nonce?: string, processStartToken?: string|null}} record
|
|
307
|
+
* @param {number} pid
|
|
308
|
+
* @param {string} nonce
|
|
309
|
+
* @param {string|null} expectedProcessStartToken
|
|
310
|
+
* @returns {boolean}
|
|
311
|
+
*/
|
|
312
|
+
export function bootstrapMatchesChild(record, pid, nonce, expectedProcessStartToken) {
|
|
313
|
+
return record?.pid === pid && record?.nonce === nonce && validBootstrapNonce(record.nonce) && sameProcessStartToken(record.processStartToken, expectedProcessStartToken);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* @param {{pid?: number, nonce?: string, processStartToken?: string|null}} record
|
|
318
|
+
* @param {number} pid
|
|
319
|
+
* @param {string} nonce
|
|
320
|
+
* @param {string|null} expectedProcessStartToken
|
|
321
|
+
* @returns {boolean}
|
|
322
|
+
*/
|
|
323
|
+
export function bootstrapFailureMatchesChild(record, pid, nonce, expectedProcessStartToken) {
|
|
324
|
+
return record?.pid === pid && record?.nonce === nonce && validBootstrapNonce(record.nonce) && (
|
|
325
|
+
expectedProcessStartToken === null
|
|
326
|
+
? record.processStartToken === null || typeof record.processStartToken === "string"
|
|
327
|
+
: record.processStartToken === expectedProcessStartToken
|
|
328
|
+
);
|
|
329
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that knows where a node's snapshot lives on disk
|
|
3
|
+
* (`<runDir>/nodes/<nodeId>.json`). Everything that persists or reads a node
|
|
4
|
+
* snapshot goes through here so the path itself has a single owner.
|
|
5
|
+
*/
|
|
6
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { errorCode } from "../util.mjs";
|
|
9
|
+
import { writeRunTextWithDiskPressureRetry } from "./disk-gc.mjs";
|
|
10
|
+
import { validateNodeSnapshot } from "../contract/snapshot.mjs";
|
|
11
|
+
|
|
12
|
+
/** @typedef {ReturnType<typeof import("./lock.mjs").acquire>} LockHandle */
|
|
13
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
14
|
+
|
|
15
|
+
const NODES_DIR_NAME = "nodes";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} runDir
|
|
19
|
+
* @param {string} nodeId
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
export function nodeSnapshotPath(runDir, nodeId) {
|
|
23
|
+
return join(runDir, NODES_DIR_NAME, `${nodeId}.json`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} runDir
|
|
28
|
+
* @param {NodeSnapshot} state
|
|
29
|
+
* @param {LockHandle|null} [lock]
|
|
30
|
+
*/
|
|
31
|
+
export function writeNodeSnapshot(runDir, state, lock = null) {
|
|
32
|
+
lock?.assert();
|
|
33
|
+
validateNodeSnapshot(state);
|
|
34
|
+
const serialized = JSON.stringify(state);
|
|
35
|
+
if (Buffer.byteLength(serialized, "utf8") > 128 * 1024) throw new Error("node snapshot exceeds 131072 bytes");
|
|
36
|
+
writeRunTextWithDiskPressureRetry(runDir, nodeSnapshotPath(runDir, state.id), `${serialized}\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} runDir
|
|
41
|
+
* @param {string} nodeId
|
|
42
|
+
* @returns {Record<string, unknown>}
|
|
43
|
+
*/
|
|
44
|
+
export function readNodeSnapshot(runDir, nodeId) {
|
|
45
|
+
return JSON.parse(readFileSync(nodeSnapshotPath(runDir, nodeId), "utf8"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* File names (`<nodeId>.json`) of every node snapshot persisted in this run.
|
|
50
|
+
* A run directory with no `nodes/` yet reads as empty, not ENOENT.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} runDir
|
|
53
|
+
* @returns {string[]}
|
|
54
|
+
*/
|
|
55
|
+
export function listNodeSnapshots(runDir) {
|
|
56
|
+
try {
|
|
57
|
+
return readdirSync(join(runDir, NODES_DIR_NAME)).filter((name) => name.endsWith(".json"));
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (errorCode(error) === "ENOENT") return [];
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|