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.
Files changed (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. 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
+ }
@@ -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
+ }