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,713 @@
1
+ /**
2
+ * The watchdog that keeps a run finishing when nobody is watching it, and the
3
+ * heartbeat record it reads.
4
+ *
5
+ * `resume` takes over a run once. That is enough when a human is at the
6
+ * keyboard and enough for one crash; it is not enough for the case this
7
+ * factory exists for, which is a campaign running for hours from a host
8
+ * scheduler while the operator is asleep. A controller that dies at 02:00
9
+ * leaves the run parked until someone types `resume` — and every node still
10
+ * pending is wall clock that buys nothing.
11
+ *
12
+ * So this loop owns nothing. It never takes the controller lock, never writes
13
+ * run state, and never touches a worktree: it watches, and when the run is
14
+ * unfinished with no live controller, it launches one exactly the way an
15
+ * operator would. Keeping it outside the lock is the whole point — a
16
+ * supervisor that became the controller would die with it, and a supervisor
17
+ * that wrote state would be a second writer racing the one that exists.
18
+ *
19
+ * The heartbeat is the two-question split the watchdog needs. `at` is written
20
+ * by an unref'd timer and answers "is the loop process alive"; the loop body
21
+ * never writes it, because the loop awaits verification on its own critical
22
+ * path and a single field would stop during a legitimate 600s verification
23
+ * and kill a healthy controller. `lastProgressAt` is written by the loop on a
24
+ * node state transition or provider output and answers "is work advancing";
25
+ * a child that never closes keeps the process alive while work stops, so the
26
+ * timer stays fresh and progress goes stale. `activeNodes` is per node, each
27
+ * carrying its own last-progress instant and its own derived budget, because a
28
+ * healthy sibling refreshing a single global timestamp would keep a run-level
29
+ * maximum unbreached forever and a frozen node would never be caught.
30
+ *
31
+ * The `launch` and `sleep` seams are injected so a test can drive the loop
32
+ * deterministically without spawning a process or waiting out an interval.
33
+ */
34
+ import { existsSync, readFileSync } from "node:fs";
35
+ import { join } from "node:path";
36
+ import { SETTLED, SUCCESS } from "./prompts.mjs";
37
+ import { earliestTierReset } from "./retry.mjs";
38
+ import { lockStale, pidAlive, readLock } from "../run/lock.mjs";
39
+ import { listNodeSnapshots, readNodeSnapshot } from "../run/node-store.mjs";
40
+ import { readJson, writeJsonAtomic } from "../run/store.mjs";
41
+ import { delay, errorCode, errorMessage } from "../util.mjs";
42
+ import { loadPersistedContract } from "../contract/index.mjs";
43
+ import { emitScheduledAttention } from "./notify-queue.mjs";
44
+
45
+ /** What `--interval` defaults to, in seconds: often enough that a dead controller costs a minute of wall clock, rare enough to be free. */
46
+ export const DEFAULT_SUPERVISE_INTERVAL_SEC = 30;
47
+
48
+ /** Bound on consecutive failed launches before the supervisor gives up and says why. A run that refuses to resume will refuse forever, and a loop that keeps trying hides that from the operator. */
49
+ export const MAX_CONSECUTIVE_LAUNCH_FAILURES = 3;
50
+
51
+ /**
52
+ * How many times the supervisor will relaunch a run whose `lastProgressAt`
53
+ * never moves before it parks the run instead of killing a third controller.
54
+ * Two is deliberate: one relaunch can be a transient provider death, two is a
55
+ * controller that dies without making progress, and a third kill would only
56
+ * repeat the second.
57
+ */
58
+ export const MAX_CONSECUTIVE_RELAUNCHES = 2;
59
+
60
+ /** The controller heartbeat's file name inside the run directory. */
61
+ export const HEARTBEAT_FILE = "heartbeat.json";
62
+
63
+ /**
64
+ * The interval the controller's unref'd `at` timer beats at, and therefore the
65
+ * half of the `2 x interval` staleness threshold. Fifteen seconds is often
66
+ * enough that a frozen controller is noticed within half a minute and rare
67
+ * enough that the write is free next to a poll loop.
68
+ */
69
+ export const HEARTBEAT_INTERVAL_MS = 15_000;
70
+
71
+ /**
72
+ * Extra time a `phase: "recovering"` controller is allowed past the orphan
73
+ * invocation's own deadline before the supervisor treats it as dead. The
74
+ * adoption busy-wait is already bounded by that deadline; the grace only
75
+ * covers the write and the read around it.
76
+ */
77
+ export const RECOVERING_GRACE_MS = 30_000;
78
+
79
+ /** How long a breached controller group gets to honour `SIGTERM` before the supervisor escalates. */
80
+ export const DEFAULT_TERMINATE_GRACE_MS = 5_000;
81
+
82
+ /** How long the group gets after `SIGKILL` before the supervisor stops waiting; the lock is taken only once it is gone. */
83
+ export const DEFAULT_TERMINATE_KILL_GRACE_MS = 5_000;
84
+
85
+ /**
86
+ * @typedef {{state: "done"|"unfinished"|"waiting"|"unknown", total: number, terminal: number, reason?: string, waitingUntil?: string, runOutcome?: "succeeded"|"parked"|"waiting"|"canceled", outcomeNodes?: OutcomeNode[]}} RunProgress
87
+ * @typedef {{id: string, status?: string, errorCode?: string|null, message?: string, waitingUntil?: string}} OutcomeNode
88
+ * @typedef {{nodeId: string, lastProgressAt: string, budgetBasis: number}} HeartbeatNode
89
+ * @typedef {{at: string, lastProgressAt: string, iteration: number, activeNodes: HeartbeatNode[], phase?: string, until?: string}} HeartbeatRecord
90
+ * @typedef {{kind: "at"|"node"|"recovering", nodeId?: string, ageMs?: number, budgetBasis?: number, until?: string}} HeartbeatBreach
91
+ */
92
+
93
+ /** @param {string} runDir @returns {string} */
94
+ export function heartbeatPath(runDir) {
95
+ return join(runDir, HEARTBEAT_FILE);
96
+ }
97
+
98
+ /**
99
+ * Read the heartbeat, treating a missing or torn file as no evidence. The
100
+ * supervisor must not mistake a write in progress for a dead controller: the
101
+ * next tick reads a whole file.
102
+ *
103
+ * @param {string} runDir
104
+ * @returns {HeartbeatRecord|null}
105
+ */
106
+ export function readHeartbeat(runDir) {
107
+ let text;
108
+ try {
109
+ text = readFileSync(heartbeatPath(runDir), "utf8");
110
+ } catch (error) {
111
+ if (errorCode(error) === "ENOENT") return null;
112
+ throw error;
113
+ }
114
+ try {
115
+ const parsed = JSON.parse(text);
116
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? /** @type {HeartbeatRecord} */ (parsed) : null;
117
+ } catch {
118
+ // A heartbeat caught mid-write is not evidence either way; the next tick reads a whole one.
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * @param {string} runDir
125
+ * @param {HeartbeatRecord} record
126
+ */
127
+ export function writeHeartbeat(runDir, record) {
128
+ writeJsonAtomic(heartbeatPath(runDir), record);
129
+ }
130
+
131
+ /**
132
+ * The controller-side heartbeat writer. `at` is owned by the injected timer and
133
+ * is never written by the caller's loop; `lastProgressAt` and `activeNodes` are
134
+ * owned by the loop. Construction preserves an existing `lastProgressAt`
135
+ * instead of resetting it to now, so a relaunched controller that immediately
136
+ * freezes leaves the value flat and the supervisor's no-progress guard can see
137
+ * it. The timer is unref'd so it can never hold the controller process open.
138
+ *
139
+ * @param {{runDir: string, intervalMs?: number, now?: () => number, setIntervalFn?: (fn: () => void, ms: number) => unknown, clearIntervalFn?: (timer: unknown) => void}} options
140
+ * @returns {{write: () => void, progress: (nodeId?: string, budgetBasis?: number) => void, setActive: (nodes: {nodeId: string, budgetBasis: number}[]) => void, snapshot: () => HeartbeatRecord, stop: () => void}}
141
+ */
142
+ export function createHeartbeat(options) {
143
+ const { runDir } = options;
144
+ const intervalMs = options.intervalMs ?? HEARTBEAT_INTERVAL_MS;
145
+ const now = options.now ?? Date.now;
146
+ const setIntervalFn = options.setIntervalFn ?? setInterval;
147
+ const clearIntervalFn = options.clearIntervalFn ?? ((timer) => clearInterval(/** @type {NodeJS.Timeout} */ (timer)));
148
+ const previous = readHeartbeat(runDir);
149
+ const startedAt = new Date(now()).toISOString();
150
+ let lastProgressAt = typeof previous?.lastProgressAt === "string" ? previous.lastProgressAt : startedAt;
151
+ let iteration = 0;
152
+ /** @type {Map<string, {lastProgressAt: string, budgetBasis: number}>} */
153
+ const activeNodes = new Map();
154
+
155
+ /** @returns {HeartbeatRecord} */
156
+ const snapshot = () => ({
157
+ at: new Date(now()).toISOString(),
158
+ lastProgressAt,
159
+ iteration,
160
+ activeNodes: [...activeNodes.entries()].map(([nodeId, value]) => ({ nodeId, lastProgressAt: value.lastProgressAt, budgetBasis: value.budgetBasis })),
161
+ });
162
+ let stopped = false;
163
+ const write = () => {
164
+ if (!stopped) writeHeartbeat(runDir, snapshot());
165
+ };
166
+ const timer = setIntervalFn(write, intervalMs);
167
+ if (timer && typeof timer === "object" && "unref" in timer && typeof timer.unref === "function") timer.unref();
168
+ write();
169
+ return {
170
+ write,
171
+ progress(nodeId, budgetBasis) {
172
+ iteration += 1;
173
+ lastProgressAt = new Date(now()).toISOString();
174
+ if (typeof nodeId === "string") {
175
+ const existing = activeNodes.get(nodeId);
176
+ activeNodes.set(nodeId, { lastProgressAt, budgetBasis: budgetBasis ?? existing?.budgetBasis ?? 0 });
177
+ }
178
+ write();
179
+ },
180
+ setActive(nodes) {
181
+ const keep = new Set(nodes.map((node) => node.nodeId));
182
+ for (const nodeId of [...activeNodes.keys()]) if (!keep.has(nodeId)) activeNodes.delete(nodeId);
183
+ const at = new Date(now()).toISOString();
184
+ for (const node of nodes) {
185
+ const existing = activeNodes.get(node.nodeId);
186
+ if (!existing || existing.budgetBasis !== node.budgetBasis) {
187
+ activeNodes.set(node.nodeId, { lastProgressAt: existing?.lastProgressAt ?? at, budgetBasis: node.budgetBasis });
188
+ }
189
+ }
190
+ write();
191
+ },
192
+ snapshot,
193
+ stop() {
194
+ stopped = true;
195
+ clearIntervalFn(timer);
196
+ },
197
+ };
198
+ }
199
+
200
+ /**
201
+ * The recovery phase a `resume` enters while it busy-waits for an orphan. The
202
+ * poll refreshes `at` so a frozen resume is still caught by the timer
203
+ * threshold, but it must never touch `lastProgressAt`: an orphan that is alive
204
+ * but never advancing would otherwise look like perpetual progress. The bound
205
+ * is the orphan invocation's own deadline, which the supervisor widens by the
206
+ * named recovering grace.
207
+ *
208
+ * @param {string} runDir
209
+ * @param {string} until the orphan invocation's own deadlineAt
210
+ * @param {string} [at] the poll's instant, injectable for a fake clock
211
+ */
212
+ export function markRecovering(runDir, until, at = new Date().toISOString()) {
213
+ const previous = readHeartbeat(runDir);
214
+ writeHeartbeat(runDir, {
215
+ at,
216
+ lastProgressAt: typeof previous?.lastProgressAt === "string" ? previous.lastProgressAt : at,
217
+ iteration: typeof previous?.iteration === "number" ? previous.iteration : 0,
218
+ activeNodes: Array.isArray(previous?.activeNodes) ? previous.activeNodes : [],
219
+ phase: "recovering",
220
+ until,
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Which heartbeat threshold, if any, this record breaches. `at` staleness is
226
+ * the frozen-process signal and applies during recovery too; otherwise a
227
+ * `recovering` record is judged only against `until + grace`, never against
228
+ * `lastProgressAt`; and any active node whose own last-progress instant is
229
+ * older than its own budget is a breach even while the global timestamp is
230
+ * fresh.
231
+ *
232
+ * @param {HeartbeatRecord|null|undefined} heartbeat
233
+ * @param {number} now epoch milliseconds
234
+ * @param {number} [intervalMs]
235
+ * @returns {HeartbeatBreach|null}
236
+ */
237
+ export function heartbeatBreach(heartbeat, now, intervalMs = HEARTBEAT_INTERVAL_MS) {
238
+ if (!heartbeat) return null;
239
+ const at = Date.parse(typeof heartbeat.at === "string" ? heartbeat.at : "");
240
+ if (Number.isFinite(at) && now - at > 2 * intervalMs) return { kind: "at", ageMs: now - at };
241
+ if (heartbeat.phase === "recovering") {
242
+ const until = Date.parse(typeof heartbeat.until === "string" ? heartbeat.until : "");
243
+ if (Number.isFinite(until) && now > until + RECOVERING_GRACE_MS) return { kind: "recovering", until: heartbeat.until };
244
+ return null;
245
+ }
246
+ for (const node of Array.isArray(heartbeat.activeNodes) ? heartbeat.activeNodes : []) {
247
+ const last = Date.parse(typeof node.lastProgressAt === "string" ? node.lastProgressAt : "");
248
+ const budget = Number(node.budgetBasis);
249
+ if (Number.isFinite(last) && Number.isFinite(budget) && now - last > budget) {
250
+ return { kind: "node", nodeId: node.nodeId, ageMs: now - last, budgetBasis: budget };
251
+ }
252
+ }
253
+ return null;
254
+ }
255
+
256
+ /**
257
+ * The run's declared contract, loaded as a persisted replay: the node set the
258
+ * reduction must answer against, never whatever snapshots happen to exist. A
259
+ * missing or unreadable contract is no evidence, so `null` falls back to the
260
+ * snapshots alone.
261
+ *
262
+ * @param {string} runDir
263
+ * @returns {import("../contract/index.mjs").ValidatedContract|null}
264
+ */
265
+ export function readRunContract(runDir) {
266
+ try {
267
+ let digest;
268
+ try {
269
+ const metadata = readJson(join(runDir, "run.json"));
270
+ digest = typeof metadata.contractDigest === "string" ? metadata.contractDigest : undefined;
271
+ } catch (error) {
272
+ if (errorCode(error) !== "ENOENT") return null;
273
+ }
274
+ return loadPersistedContract(join(runDir, "contract.json"), digest);
275
+ } catch (error) {
276
+ return null;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Reduce the declared node set to a run outcome. Pure over the declared ids and
282
+ * a snapshot map, so the empty-input invariant is a unit claim and the sparse
283
+ * case cannot be faked by whatever snapshots exist.
284
+ *
285
+ * `succeeded` needs a readable snapshot for every declared node, each in
286
+ * `{done, no-op}`. `canceled` comes from the durable run-level marker, never
287
+ * from a node status. `waiting` is the single case where a parked-shaped node
288
+ * is not parked: a `blocked`/`runtime_tier_exhausted` node whose recorded reset
289
+ * is still in the future. Everything else is `parked`, naming each non-success
290
+ * node; a missing or unreadable snapshot is named, never dropped.
291
+ *
292
+ * @param {string[]} declaredIds
293
+ * @param {Map<string, import("../contract/index.mjs").NodeSnapshot|{unreadable: string}>} snapshots
294
+ * @param {{canceled?: boolean, now?: number}} [options]
295
+ * @returns {{outcome: "succeeded"|"parked"|"waiting"|"canceled", nodes: OutcomeNode[], waitingUntil?: string}}
296
+ */
297
+ export function reduceRunOutcome(declaredIds, snapshots, options = {}) {
298
+ if (options.canceled) return { outcome: "canceled", nodes: [] };
299
+ const now = options.now ?? Date.now();
300
+ /** @type {OutcomeNode[]} */
301
+ const nonSuccess = [];
302
+ /** @type {number|null} */
303
+ let earliestWaiting = null;
304
+ let allWaiting = true;
305
+ for (const id of declaredIds) {
306
+ const snapshot = snapshots.get(id);
307
+ if (!snapshot) {
308
+ nonSuccess.push({ id, status: "missing" });
309
+ allWaiting = false;
310
+ continue;
311
+ }
312
+ if ("unreadable" in snapshot) {
313
+ nonSuccess.push({ id, status: "unreadable", message: snapshot.unreadable });
314
+ allWaiting = false;
315
+ continue;
316
+ }
317
+ const status = snapshot.status;
318
+ if (SUCCESS.has(status)) continue;
319
+ if (status === "blocked" && snapshot.error?.code === "runtime_tier_exhausted") {
320
+ const earliest = earliestTierReset(snapshot);
321
+ if (earliest !== null) {
322
+ // A tier-exhausted node is waiting, never parked: the provider named a
323
+ // reset, and the retry dispatches at that instant (or is due now).
324
+ const waiting = now < earliest;
325
+ if (waiting) earliestWaiting = earliestWaiting === null ? earliest : Math.min(earliestWaiting, earliest);
326
+ nonSuccess.push({
327
+ id,
328
+ status,
329
+ errorCode: snapshot.error.code,
330
+ ...(waiting ? { waitingUntil: new Date(earliest).toISOString() } : {}),
331
+ });
332
+ continue;
333
+ }
334
+ }
335
+ nonSuccess.push({ id, status, errorCode: snapshot.error?.code ?? null });
336
+ allWaiting = false;
337
+ }
338
+ if (nonSuccess.length === 0) {
339
+ // The empty declared set never reaches `succeeded`: "every node succeeded"
340
+ // is vacuous there and validation owns rejecting the empty contract.
341
+ return { outcome: declaredIds.length === 0 ? "parked" : "succeeded", nodes: [] };
342
+ }
343
+ if (allWaiting) {
344
+ return {
345
+ outcome: "waiting",
346
+ nodes: nonSuccess,
347
+ ...(earliestWaiting !== null ? { waitingUntil: new Date(earliestWaiting).toISOString() } : {}),
348
+ };
349
+ }
350
+ return { outcome: "parked", nodes: nonSuccess };
351
+ }
352
+
353
+ /**
354
+ * How far the run has got, read from the node snapshots and the declared node
355
+ * set. `unknown` is not `unfinished`: a run directory with no snapshots yet has
356
+ * not proved it needs resuming, and resuming it would race the controller that
357
+ * is about to write them. A torn snapshot is `unknown` for the same reason.
358
+ *
359
+ * `waiting` is the one case where a blocked node is not counted settled: a
360
+ * `runtime_tier_exhausted` node whose earliest recorded reset is parseable and
361
+ * still in the future has nothing to do until that instant. A tier-exhausted
362
+ * node with no parseable reset keeps today's settled classification (there is
363
+ * nothing to wait for), and one whose reset is already past is ordinary
364
+ * `unfinished` so the retry dispatches. Whenever a run carries any node that is
365
+ * not waiting, `unfinished` outranks `waiting` and the waiting nodes never hold
366
+ * the run back.
367
+ *
368
+ * `state` says whether anything can still move; `runOutcome` says what the
369
+ * settled result is. A parked node is settled but not successful, so the
370
+ * watchdog must keep watching it rather than report the run done.
371
+ *
372
+ * @param {string} runDir
373
+ * @param {number} [now] epoch milliseconds, injectable so a fake clock can drive the wait
374
+ * @returns {RunProgress}
375
+ */
376
+ export function runProgress(runDir, now = Date.now()) {
377
+ const names = listNodeSnapshots(runDir);
378
+ if (!names.length) return { state: "unknown", total: 0, terminal: 0, reason: "no node snapshots yet" };
379
+ /** @type {Map<string, import("../contract/index.mjs").NodeSnapshot|{unreadable: string}>} */
380
+ const snapshots = new Map();
381
+ let unreadable = false;
382
+ for (const name of names) {
383
+ const id = name.replace(/\.json$/u, "");
384
+ try {
385
+ const snapshot = /** @type {import("../contract/index.mjs").NodeSnapshot} */ (readNodeSnapshot(runDir, id));
386
+ snapshots.set(snapshot?.id ?? id, snapshot);
387
+ } catch (error) {
388
+ // A snapshot caught mid-write is not evidence either way; the next tick
389
+ // reads a whole one, and the reduction names it rather than dropping it.
390
+ snapshots.set(id, { unreadable: errorMessage(error) });
391
+ unreadable = true;
392
+ }
393
+ }
394
+ const contract = readRunContract(runDir);
395
+ const declared = contract ? contract.nodes.map((node) => node.id) : [...snapshots.keys()];
396
+ let terminal = 0;
397
+ let unfinished = false;
398
+ /** @type {number|null} */
399
+ let earliestWaiting = null;
400
+ for (const id of declared) {
401
+ const snapshot = snapshots.get(id);
402
+ if (!snapshot || "unreadable" in snapshot) {
403
+ unfinished = true;
404
+ continue;
405
+ }
406
+ const status = snapshot.status;
407
+ if (status === "blocked" && snapshot.error?.code === "runtime_tier_exhausted") {
408
+ const earliest = earliestTierReset(snapshot);
409
+ if (earliest === null) {
410
+ // No parseable reset: nothing to wait for, so the blocked node stays
411
+ // settled exactly as it always has.
412
+ terminal += 1;
413
+ } else if (now < earliest) {
414
+ earliestWaiting = earliestWaiting === null ? earliest : Math.min(earliestWaiting, earliest);
415
+ } else {
416
+ // The instant has arrived; Phase 1b's retry is due now.
417
+ unfinished = true;
418
+ }
419
+ continue;
420
+ }
421
+ if (typeof status === "string" && SETTLED.has(status)) terminal += 1;
422
+ else unfinished = true;
423
+ }
424
+ const canceled = existsSync(join(runDir, "cancel.request.json"));
425
+ const reduction = reduceRunOutcome(declared, snapshots, { canceled, now });
426
+ /** @type {"done"|"unfinished"|"waiting"|"unknown"} */
427
+ let state;
428
+ if (unreadable) state = "unknown";
429
+ else if (terminal === declared.length) state = "done";
430
+ else if (unfinished) state = "unfinished";
431
+ else state = "waiting";
432
+ return {
433
+ state,
434
+ total: declared.length,
435
+ terminal,
436
+ ...(state === "unknown" ? { reason: "a node snapshot could not be read" } : {}),
437
+ ...(state === "waiting" && earliestWaiting !== null ? { waitingUntil: new Date(earliestWaiting).toISOString() } : {}),
438
+ runOutcome: reduction.outcome,
439
+ outcomeNodes: reduction.nodes,
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Whether a controller is alive on this run right now. A lock whose holder
445
+ * cannot be proven dead is a live controller, the same rule `acquire` uses:
446
+ * launching against one would be refused anyway, and refusing here keeps the
447
+ * supervisor from spawning a process per tick. A live lock is no longer
448
+ * sufficient on its own: a heartbeat that breaches either threshold is a
449
+ * controller the supervisor may kill, because a frozen process holds its lock
450
+ * open forever.
451
+ *
452
+ * @param {string} runDir
453
+ * @param {{now?: number, heartbeatIntervalMs?: number}} [options]
454
+ * @returns {boolean}
455
+ */
456
+ export function controllerAlive(runDir, options = {}) {
457
+ const lock = readLock(runDir);
458
+ if (lock === null || lockStale(lock)) return false;
459
+ const breach = heartbeatBreach(readHeartbeat(runDir), options.now ?? Date.now(), options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS);
460
+ return breach === null;
461
+ }
462
+
463
+ /**
464
+ * Whether a pid's process group is still visible. The controller is spawned
465
+ * detached, so its pid is its process group id and the group is what must be
466
+ * gone before the lock is taken.
467
+ *
468
+ * @param {number} pid
469
+ * @returns {boolean}
470
+ */
471
+ export function groupAlive(pid) {
472
+ if (process.platform === "win32" || !Number.isInteger(pid) || pid <= 0) return false;
473
+ try {
474
+ process.kill(-pid, 0);
475
+ return true;
476
+ } catch (error) {
477
+ return errorCode(error) === "EPERM";
478
+ }
479
+ }
480
+
481
+ /**
482
+ * @param {number} pid @param {(pid: number) => boolean} alive @param {number} graceMs @param {(ms: number) => Promise<void>} sleep @param {() => number} now
483
+ * @returns {Promise<boolean>}
484
+ */
485
+ export async function waitForGroupGone(pid, alive, graceMs, sleep, now) {
486
+ const deadline = now() + graceMs;
487
+ while (alive(pid) && now() < deadline) await sleep(Math.max(1, Math.min(250, deadline - now())));
488
+ return !alive(pid);
489
+ }
490
+
491
+ /**
492
+ * Terminate the controller's process group, bounded: `SIGTERM`, then `SIGKILL`
493
+ * after the named grace, waiting for the group to disappear after each. The
494
+ * caller relaunches only once this resolves, so the lock is taken only after
495
+ * the previous holder is gone. The kill and liveness probes are injectable so
496
+ * a test can prove the ordering without signalling a real process.
497
+ *
498
+ * @param {string} runDir
499
+ * @param {{kill?: (pid: number, signal: string) => void, alive?: (pid: number) => boolean, sleep?: (ms: number) => Promise<void>, now?: () => number, graceMs?: number, killGraceMs?: number}} [options]
500
+ * @returns {Promise<boolean>} whether a group was found and terminated
501
+ */
502
+ export async function terminateControllerGroup(runDir, options = {}) {
503
+ const lock = readLock(runDir);
504
+ if (!lock || /** @type {{invalid?: true}} */ (lock).invalid) return false;
505
+ const record = /** @type {import("../run/lock.mjs").LockRecord} */ (lock);
506
+ const pid = record.pid;
507
+ const kill = options.kill ?? ((target, signal) => {
508
+ try {
509
+ // The controller is normally detached, so its pid is its process group
510
+ // id; a non-detached holder has no such group, so fall back to the pid.
511
+ if (process.platform !== "win32") {
512
+ try {
513
+ process.kill(-target, signal);
514
+ return;
515
+ } catch (groupError) {
516
+ if (errorCode(groupError) !== "ESRCH") throw groupError;
517
+ }
518
+ }
519
+ process.kill(target, signal);
520
+ } catch (error) {
521
+ if (errorCode(error) !== "ESRCH") throw error;
522
+ }
523
+ });
524
+ const alive = options.alive ?? ((target) => pidAlive(target) || groupAlive(target));
525
+ const sleep = options.sleep ?? delay;
526
+ const now = options.now ?? Date.now;
527
+ const graceMs = options.graceMs ?? DEFAULT_TERMINATE_GRACE_MS;
528
+ const killGraceMs = options.killGraceMs ?? DEFAULT_TERMINATE_KILL_GRACE_MS;
529
+ if (!alive(pid)) return false;
530
+ kill(pid, "SIGTERM");
531
+ if (await waitForGroupGone(pid, alive, graceMs, sleep, now)) return true;
532
+ kill(pid, "SIGKILL");
533
+ await waitForGroupGone(pid, alive, killGraceMs, sleep, now);
534
+ return true;
535
+ }
536
+
537
+ /**
538
+ * @param {string} runDir
539
+ * @returns {Record<string, unknown>}
540
+ */
541
+ function readRunMetadata(runDir) {
542
+ try {
543
+ return readJson(join(runDir, "run.json"));
544
+ } catch (error) {
545
+ if (errorCode(error) === "ENOENT") return {};
546
+ throw error;
547
+ }
548
+ }
549
+
550
+ /**
551
+ * @param {string} runDir @param {Record<string, unknown>} metadata
552
+ */
553
+ function writeRunMetadata(runDir, metadata) {
554
+ writeJsonAtomic(join(runDir, "run.json"), metadata);
555
+ }
556
+
557
+ /**
558
+ * Decide whether the next breach is a relaunch or the park that replaces a
559
+ * third kill. The comparison is against the `lastProgressAt` recorded at the
560
+ * previous relaunch: a value that moved means the relaunched controller did
561
+ * make progress and the counter resets; a value that did not means this is
562
+ * another no-progress dispatch and the counter grows. The counter lives in
563
+ * `run.json` so a restarted supervisor reads the same number.
564
+ *
565
+ * @param {string} runDir
566
+ * @param {HeartbeatRecord|null} heartbeat
567
+ * @returns {{parked: boolean, message: string, count: number, lastProgressAt: string|null, metadata: Record<string, unknown>}}
568
+ */
569
+ function nextRelaunch(runDir, heartbeat) {
570
+ const metadata = readRunMetadata(runDir);
571
+ const progressAt = typeof heartbeat?.lastProgressAt === "string" ? heartbeat.lastProgressAt : null;
572
+ const recorded = typeof metadata.lastRelaunchProgressAt === "string" ? metadata.lastRelaunchProgressAt : null;
573
+ const advanced = progressAt !== null && recorded !== null && Date.parse(progressAt) > Date.parse(recorded);
574
+ const count = advanced ? 0 : (Number.isInteger(metadata.relaunchCount) ? Number(metadata.relaunchCount) : 0);
575
+ if (count >= MAX_CONSECUTIVE_RELAUNCHES) {
576
+ return {
577
+ parked: true,
578
+ message: `controller relaunched ${count} times without lastProgressAt advancing`,
579
+ count,
580
+ lastProgressAt: progressAt,
581
+ metadata,
582
+ };
583
+ }
584
+ return { parked: false, message: "", count: count + 1, lastProgressAt: progressAt, metadata };
585
+ }
586
+
587
+ /**
588
+ * @param {string} runDir @param {{count: number, lastProgressAt: string|null, metadata: Record<string, unknown>}} decision
589
+ */
590
+ function persistRelaunch(runDir, decision) {
591
+ writeRunMetadata(runDir, {
592
+ ...decision.metadata,
593
+ relaunchCount: decision.count,
594
+ lastRelaunchProgressAt: decision.lastProgressAt,
595
+ });
596
+ }
597
+
598
+ /**
599
+ * Record the durable park and its attention code. This is the run's
600
+ * `attention` record, written where a supervisor restart will still read it.
601
+ *
602
+ * @param {string} runDir @param {string} code @param {string} message @param {string} at
603
+ */
604
+ function parkRun(runDir, code, message, at) {
605
+ const metadata = readRunMetadata(runDir);
606
+ writeRunMetadata(runDir, { ...metadata, attention: { code, message, at } });
607
+ }
608
+
609
+ /**
610
+ * Report a parked run without returning: anchor its durable attention record
611
+ * the first time it is seen parked, then re-emit the schedule slot the clock
612
+ * has reached. The anchor lives in run.json, so a supervisor restart reads the
613
+ * same interval; the slot lives in the notify receipt log, so a restart does
614
+ * not re-announce a slot already sent. A resume that changes node state clears
615
+ * the anchor, which is what starts a fresh interval.
616
+ *
617
+ * @param {string} runDir @param {RunProgress} progress @param {number} now
618
+ */
619
+ async function reportParkedAttention(runDir, progress, now) {
620
+ const metadata = readRunMetadata(runDir);
621
+ const first = progress.outcomeNodes?.[0];
622
+ const existing = metadata.attention && typeof metadata.attention === "object" ? /** @type {{code: string, message: string, at: string}} */ (metadata.attention) : null;
623
+ const attention = existing ?? {
624
+ code: first?.errorCode ?? first?.status ?? "parked",
625
+ message: `run parked: ${(progress.outcomeNodes ?? []).map((node) => `${node.id}:${node.status ?? "unknown"}`).join(", ") || "no nodes named"}`,
626
+ at: new Date(now).toISOString(),
627
+ };
628
+ if (!existing) writeRunMetadata(runDir, { ...metadata, attention });
629
+ await emitScheduledAttention(runDir, { anchor: attention.at, code: attention.code, now });
630
+ }
631
+
632
+ /**
633
+ * Watch one run and relaunch its controller until every node is terminal.
634
+ *
635
+ * @param {string} runDir
636
+ * @param {{intervalSec?: number, heartbeatIntervalMs?: number, launch: (runDir: string) => Promise<void>|void, sleep?: (ms: number) => Promise<void>, now?: () => number, onTick?: (tick: {progress: RunProgress, alive: boolean, launched: boolean}) => void, maxTicks?: number, terminate?: (runDir: string, breach: HeartbeatBreach) => Promise<void>|void, kill?: (pid: number, signal: string) => void, alive?: (pid: number) => boolean, graceMs?: number, killGraceMs?: number}} options
637
+ * @returns {Promise<{state: "done"|"stopped", ticks: number, launches: number, reason?: string}>}
638
+ */
639
+ export async function superviseRun(runDir, options) {
640
+ const intervalMs = Math.max(1, Math.round((options.intervalSec ?? DEFAULT_SUPERVISE_INTERVAL_SEC) * 1000));
641
+ const heartbeatIntervalMs = options.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
642
+ const sleep = options.sleep ?? delay;
643
+ const now = options.now ?? Date.now;
644
+ const terminate = options.terminate ?? ((target, breach) => terminateControllerGroup(target, {
645
+ sleep,
646
+ now,
647
+ graceMs: options.graceMs,
648
+ killGraceMs: options.killGraceMs,
649
+ kill: options.kill,
650
+ alive: options.alive,
651
+ }));
652
+ let ticks = 0;
653
+ let launches = 0;
654
+ let consecutiveFailures = 0;
655
+ for (;;) {
656
+ if (options.maxTicks !== undefined && ticks >= options.maxTicks) {
657
+ return { state: "stopped", ticks, launches, reason: "tick budget exhausted" };
658
+ }
659
+ ticks += 1;
660
+ // A succeeded or canceled run is finished. A parked run is not: every node
661
+ // has stopped but the outcome needs attention, so it is announced and the
662
+ // loop keeps watching instead of returning as a finished one.
663
+ const progress = runProgress(runDir, now());
664
+ if (progress.runOutcome === "succeeded" || progress.runOutcome === "canceled") {
665
+ options.onTick?.({ progress, alive: false, launched: false });
666
+ return { state: "done", ticks, launches };
667
+ }
668
+ if (progress.state === "done" && progress.runOutcome === "parked") {
669
+ await reportParkedAttention(runDir, progress, now());
670
+ options.onTick?.({ progress, alive: false, launched: false });
671
+ await sleep(intervalMs);
672
+ continue;
673
+ }
674
+ const lock = readLock(runDir);
675
+ const lockAlive = lock !== null && !lockStale(lock);
676
+ const heartbeat = readHeartbeat(runDir);
677
+ // A breach on a live lock is a dead controller the lock cannot see: it is
678
+ // killed before the relaunch; a dead lock needs no kill.
679
+ const breach = lockAlive ? heartbeatBreach(heartbeat, now(), heartbeatIntervalMs) : null;
680
+ const alive = lockAlive && breach === null;
681
+ let launched = false;
682
+ // A breach is a frozen controller and is relaunched even while the run is
683
+ // `waiting` on a tier reset: the holder is not merely idle, it is dead and
684
+ // would never dispatch the retry at the reset instant. A dead lock with no
685
+ // breach still holds a waiting run until the instant arrives.
686
+ if ((progress.state === "unfinished" || breach !== null) && !alive) {
687
+ const decision = nextRelaunch(runDir, heartbeat);
688
+ if (decision.parked) {
689
+ parkRun(runDir, "controller_unresponsive", decision.message, new Date(now()).toISOString());
690
+ options.onTick?.({ progress, alive, launched: false });
691
+ return { state: "stopped", ticks, launches, reason: "controller_unresponsive" };
692
+ }
693
+ try {
694
+ if (breach && lockAlive) await terminate(runDir, breach);
695
+ await options.launch(runDir);
696
+ // Persist only a successful relaunch: a launch that threw is counted
697
+ // by the failure guard below, not by the no-progress guard.
698
+ persistRelaunch(runDir, decision);
699
+ launched = true;
700
+ launches += 1;
701
+ consecutiveFailures = 0;
702
+ } catch (error) {
703
+ consecutiveFailures += 1;
704
+ if (consecutiveFailures >= MAX_CONSECUTIVE_LAUNCH_FAILURES) {
705
+ options.onTick?.({ progress, alive, launched: false });
706
+ return { state: "stopped", ticks, launches, reason: `resume failed ${consecutiveFailures} times: ${errorMessage(error)}` };
707
+ }
708
+ }
709
+ }
710
+ options.onTick?.({ progress, alive, launched });
711
+ await sleep(intervalMs);
712
+ }
713
+ }