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,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
|
+
}
|