@ours.network/fleet 0.10.4 → 0.11.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/dist/application/fleet-query-service.d.ts +11 -0
- package/dist/application/fleet-query-service.js +9 -1
- package/dist/cli.js +200 -3
- package/dist/config.d.ts +2 -0
- package/dist/config.js +3 -1
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +22 -0
- package/dist/duration.d.ts +5 -0
- package/dist/duration.js +20 -0
- package/dist/ops.d.ts +16 -0
- package/dist/ops.js +112 -3
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +1 -0
- package/dist/resolved-plan.js +7 -0
- package/dist/watchdog/alerts.d.ts +34 -0
- package/dist/watchdog/alerts.js +78 -0
- package/dist/watchdog/briefing.d.ts +65 -0
- package/dist/watchdog/briefing.js +181 -0
- package/dist/watchdog/config.d.ts +49 -0
- package/dist/watchdog/config.js +114 -0
- package/dist/watchdog/query.d.ts +78 -0
- package/dist/watchdog/query.js +124 -0
- package/dist/watchdog/report.d.ts +53 -0
- package/dist/watchdog/report.js +126 -0
- package/dist/watchdog/run.d.ts +61 -0
- package/dist/watchdog/run.js +318 -0
- package/dist/watchdog/scheduler.d.ts +105 -0
- package/dist/watchdog/scheduler.js +244 -0
- package/dist/watchdog/service.d.ts +46 -0
- package/dist/watchdog/service.js +179 -0
- package/dist/watchdog/store.d.ts +85 -0
- package/dist/watchdog/store.js +226 -0
- package/dist/web/runtime.js +46 -2
- package/dist/web/server.d.ts +2 -0
- package/dist/web/server.js +18 -0
- package/dist/web-app/assets/{TerminalView-DcImdrI1.js → TerminalView-BvcIkuIF.js} +1 -1
- package/dist/web-app/assets/index-B-jtLAkp.css +1 -0
- package/dist/web-app/assets/index-CUN7ksTw.js +9 -0
- package/dist/web-app/index.html +2 -2
- package/package.json +1 -1
- package/dist/web-app/assets/index-BokQN1Ao.js +0 -9
- package/dist/web-app/assets/index-lAXzaOZM.css +0 -1
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { spawn as spawnChild, execFile } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { stringify } from 'yaml';
|
|
6
|
+
import { errorReport, normalizeWatchdogReport, validateWatchdogReport } from './report.js';
|
|
7
|
+
import { acquireRunLock, formatRunId, pruneReports, releaseRunLock, writeReport } from './store.js';
|
|
8
|
+
import { generateNotifierBriefing, generateWatchdogBriefing } from './briefing.js';
|
|
9
|
+
import { computeDigest, reconcileLedger, readLedger, writeLedger } from './alerts.js';
|
|
10
|
+
import { applyRole } from '../ops.js';
|
|
11
|
+
import { daemonIdentityProvisioner, ensureIdentity, } from '../creation.js';
|
|
12
|
+
import { runOnce, START_STAGGER_FILE } from '../runner.js';
|
|
13
|
+
import { agentDir } from '../paths.js';
|
|
14
|
+
import { loadConfig, resolveMonitorConfig, resolveWorklogPolicy, } from '../config.js';
|
|
15
|
+
import { getAdapter } from '../harness/registry.js';
|
|
16
|
+
import { redactLogLine } from '../application/log-service.js';
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
/** How often the deadline loop polls for a completed report or a dead child. */
|
|
19
|
+
const POLL_MS = 1000;
|
|
20
|
+
/** A `report.json` this old, with no further writes, is treated as finished. */
|
|
21
|
+
const WRITE_STABLE_MS = 2000;
|
|
22
|
+
/** Grace given to the agent after a stable report before the session is killed. */
|
|
23
|
+
const HARVEST_GRACE_MS = 5000;
|
|
24
|
+
/** Fixed deadline for a one-shot notifier run — always 2 minutes, never wd.timeoutMs (Task 14). */
|
|
25
|
+
const NOTIFIER_TIMEOUT_MS = 120_000;
|
|
26
|
+
/**
|
|
27
|
+
* Poll for a completion sentinel file (report.json for an inspection run, sent.json for a
|
|
28
|
+
* notifier run) going write-stable, or the child exiting first, or the deadline passing —
|
|
29
|
+
* whichever comes first. Shared by executeWatchdogRun and executeNotifierRun so the two run
|
|
30
|
+
* flavors' wait loops can never drift apart.
|
|
31
|
+
*/
|
|
32
|
+
async function waitForSentinel(sentinelPath, child, deadlineMs, start, now, sleep) {
|
|
33
|
+
let exited = false;
|
|
34
|
+
// Attached now so a natural exit is observed even though the loop below never
|
|
35
|
+
// awaits the promise itself (its resolution races real time in the real
|
|
36
|
+
// launcher, and is driven purely by test fixtures in tests).
|
|
37
|
+
child.exited.then(() => { exited = true; }).catch(() => { exited = true; });
|
|
38
|
+
for (;;) {
|
|
39
|
+
if (exited)
|
|
40
|
+
return 'exited';
|
|
41
|
+
if (existsSync(sentinelPath)) {
|
|
42
|
+
const age = now().getTime() - statSync(sentinelPath).mtimeMs;
|
|
43
|
+
if (age >= WRITE_STABLE_MS)
|
|
44
|
+
return 'stable';
|
|
45
|
+
}
|
|
46
|
+
if (now().getTime() - start.getTime() > deadlineMs)
|
|
47
|
+
return 'timeout';
|
|
48
|
+
await sleep(POLL_MS);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** After waitForSentinel settles, harvest the grace period (if stable) then kill the child+session. */
|
|
52
|
+
async function killIfNeeded(reason, child, roleName, sleep) {
|
|
53
|
+
if (reason === 'stable' || reason === 'timeout') {
|
|
54
|
+
if (reason === 'stable')
|
|
55
|
+
await sleep(HARVEST_GRACE_MS);
|
|
56
|
+
child.kill();
|
|
57
|
+
await killTmuxSession(roleName);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function defaultLaunchChild(binPath, roleName, runDir) {
|
|
61
|
+
const out = openSync(join(runDir, 'run.log'), 'a');
|
|
62
|
+
const child = spawnChild(process.execPath, [binPath, '_run-watchdog', roleName], {
|
|
63
|
+
detached: false, stdio: ['ignore', out, out],
|
|
64
|
+
});
|
|
65
|
+
// spawn() dup's the fd into the child; Node never closes our copy on its own,
|
|
66
|
+
// so a long-running scheduler launching many watchdog runs would leak one fd
|
|
67
|
+
// per run and eventually hit EMFILE. Safe to close immediately — the child
|
|
68
|
+
// keeps writing to its own dup'd descriptor.
|
|
69
|
+
closeSync(out);
|
|
70
|
+
return {
|
|
71
|
+
kill: () => { child.kill(); },
|
|
72
|
+
exited: new Promise(resolve => {
|
|
73
|
+
child.once('exit', () => resolve());
|
|
74
|
+
child.once('error', () => resolve());
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Best-effort: the tmux session outlives the supervisor child (`runOnce` created it). */
|
|
79
|
+
async function killTmuxSession(roleName) {
|
|
80
|
+
try {
|
|
81
|
+
await execFileAsync('tmux', ['kill-session', '-t', roleName]);
|
|
82
|
+
}
|
|
83
|
+
catch { /* best effort */ }
|
|
84
|
+
}
|
|
85
|
+
/** Last 4096 chars of run.log, redacted — attached to error reports as diagnostic tail. */
|
|
86
|
+
function readTail(runDir) {
|
|
87
|
+
try {
|
|
88
|
+
const raw = readFileSync(join(runDir, 'run.log'), 'utf8');
|
|
89
|
+
return redactLogLine(raw.slice(-4096)).text;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The temp role every watchdog-family run (inspection and notifier alike) launches under.
|
|
97
|
+
* `network: 'broker'` keeps ours messaging available; no write binds beyond stateDir/cwd, which
|
|
98
|
+
* resolveIsolation adds itself. ~/fleet.yaml and fleet.d are deliberately NOT bound — they're on
|
|
99
|
+
* the isolation blocklist, and everything either run flavor needs is written into its own dir.
|
|
100
|
+
*
|
|
101
|
+
* Read access is scoped to exactly `wd.watch` (finding #3): a watchdog configured to watch one
|
|
102
|
+
* role must not be able to read every other role's state dir just because they all live under
|
|
103
|
+
* the same agents root. `wd.watch` defaults to every role (watchdog/config.ts's
|
|
104
|
+
* resolveWatchdogs), so a watchdog that watches everything still sees everything — this only
|
|
105
|
+
* narrows visibility for a watchdog scoped to fewer roles. A watched role whose state dir doesn't
|
|
106
|
+
* exist (e.g. a role removed after the watchdog was configured) is simply absent from the
|
|
107
|
+
* bwrap ro-bind-try set — the agent reports it unreachable from status evidence, same as any
|
|
108
|
+
* other missing state dir.
|
|
109
|
+
*/
|
|
110
|
+
function buildWatchdogRole(wd, cfg) {
|
|
111
|
+
return {
|
|
112
|
+
name: wd.identity, sourceFile: '(watchdog)',
|
|
113
|
+
harness: wd.harness, session: wd.session,
|
|
114
|
+
identity: wd.identity, model: wd.model,
|
|
115
|
+
permissions: { approval: 'allow', filesystem: 'workspace', unattended: 'deny' },
|
|
116
|
+
permissionsDeclared: true,
|
|
117
|
+
monitor: resolveMonitorConfig(cfg.defaults.monitor, undefined),
|
|
118
|
+
worklog: resolveWorklogPolicy(cfg.defaults.worklog, undefined),
|
|
119
|
+
isolation: { fs: { read: wd.watch.map(r => agentDir(r)) }, network: 'broker' },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Run one watchdog agent end-to-end in a clean-context temp state dir: provision
|
|
124
|
+
* identity, materialize the run's contract (briefing/manifest/role snapshot),
|
|
125
|
+
* launch the child, enforce a deadline against report.json as the completion
|
|
126
|
+
* sentinel, harvest whatever it wrote (or a synthetic error report if it
|
|
127
|
+
* didn't), store the result, and always clean up the temp dir.
|
|
128
|
+
*
|
|
129
|
+
* The scheduler's run-lock guarantees only one run per watchdog at a time;
|
|
130
|
+
* this function itself takes no lock (Task 8).
|
|
131
|
+
*/
|
|
132
|
+
export async function executeWatchdogRun(wd, deps) {
|
|
133
|
+
const now = deps.now ?? (() => new Date());
|
|
134
|
+
const sleep = deps.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
|
|
135
|
+
const start = now();
|
|
136
|
+
const runId = formatRunId(start);
|
|
137
|
+
const startedAt = start.toISOString();
|
|
138
|
+
const roleName = wd.identity;
|
|
139
|
+
const runDir = agentDir(roleName, true);
|
|
140
|
+
// Read once, before the manifest is written: the same snapshot both seeds the
|
|
141
|
+
// digest the agent sees and is the base the post-run reconcile folds into.
|
|
142
|
+
const ledger = readLedger(wd.name);
|
|
143
|
+
// A crashed previous run can leave the temp dir behind; start clean.
|
|
144
|
+
if (existsSync(runDir))
|
|
145
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
146
|
+
let report;
|
|
147
|
+
try {
|
|
148
|
+
const guarantee = await ensureIdentity(wd.identity, {}, deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
|
|
149
|
+
const cfg = deps.cfg ?? loadConfig();
|
|
150
|
+
const role = buildWatchdogRole(wd, cfg);
|
|
151
|
+
const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
|
|
152
|
+
const reportPath = join(dir, 'report.json');
|
|
153
|
+
const manifestPath = join(dir, 'watch.json');
|
|
154
|
+
const promptFocus = wd.promptFile ? readFileSync(wd.promptFile, 'utf8') : undefined;
|
|
155
|
+
// applyRole wrote the generic role briefing; the watchdog contract replaces it.
|
|
156
|
+
writeFileSync(join(dir, 'briefing.md'), generateWatchdogBriefing({
|
|
157
|
+
wd, manifestPath, reportPath,
|
|
158
|
+
vocabulary: getAdapter(wd.harness).vocabulary,
|
|
159
|
+
identityGuarantee: guarantee.state,
|
|
160
|
+
promptFocus,
|
|
161
|
+
}));
|
|
162
|
+
// loadTempRole (runner.ts) needs this to run the child via `_run-watchdog`.
|
|
163
|
+
writeFileSync(join(dir, 'role.yaml'), stringify(role));
|
|
164
|
+
const manifest = {
|
|
165
|
+
watchdog: wd.name, run_id: runId, coordinator: wd.coordinator, started_at: startedAt,
|
|
166
|
+
roles: wd.watch.map(r => ({ name: r, stateDir: agentDir(r) })),
|
|
167
|
+
digest: computeDigest(ledger, wd.alertCooldownMs, now()),
|
|
168
|
+
};
|
|
169
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
170
|
+
// Snapshot the fleet start-stagger so the detached run (no config path
|
|
171
|
+
// threaded through `_run-watchdog`) honors the same host-wide launch gate
|
|
172
|
+
// as every other role (spec §3) — mirrors src/spawn.ts:503-504.
|
|
173
|
+
if (cfg.startStaggerMs > 0)
|
|
174
|
+
writeFileSync(join(dir, START_STAGGER_FILE), String(cfg.startStaggerMs));
|
|
175
|
+
const launch = deps.launchChild ?? defaultLaunchChild;
|
|
176
|
+
const child = launch(deps.binPath, roleName, dir);
|
|
177
|
+
const reason = await waitForSentinel(reportPath, child, wd.timeoutMs, start, now, sleep);
|
|
178
|
+
await killIfNeeded(reason, child, roleName, sleep);
|
|
179
|
+
const timedOut = reason === 'timeout';
|
|
180
|
+
if (timedOut) {
|
|
181
|
+
report = errorReport({
|
|
182
|
+
watchdog: wd.name, run_id: runId, started_at: startedAt, finished_at: now().toISOString(),
|
|
183
|
+
error: 'timeout', tail: readTail(dir),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
let parsed;
|
|
188
|
+
let parseError;
|
|
189
|
+
try {
|
|
190
|
+
parsed = JSON.parse(readFileSync(reportPath, 'utf8'));
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
parseError = e instanceof Error ? e.message : String(e);
|
|
194
|
+
}
|
|
195
|
+
const validationErrors = parseError === undefined ? validateWatchdogReport(parsed) : [];
|
|
196
|
+
if (parseError !== undefined || validationErrors.length) {
|
|
197
|
+
const detail = parseError ?? validationErrors.slice(0, 3).join('; ');
|
|
198
|
+
report = errorReport({
|
|
199
|
+
watchdog: wd.name, run_id: runId, started_at: startedAt, finished_at: now().toISOString(),
|
|
200
|
+
error: `invalid report: ${detail}`, tail: readTail(dir),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
report = normalizeWatchdogReport(parsed, { watchdog: wd.name, run_id: runId });
|
|
205
|
+
// Scheduler clock truth overrides whatever the agent wrote.
|
|
206
|
+
report.started_at = startedAt;
|
|
207
|
+
report.finished_at = now().toISOString();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Isolation degradation (spec §7): a weaker guarantee must never look like
|
|
211
|
+
// the strong one, so stamp it on every outcome, error reports included.
|
|
212
|
+
if (existsSync(join(dir, '.isolation-degraded')))
|
|
213
|
+
report.isolation = 'degraded';
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
const storedPath = writeReport(wd.name, report);
|
|
219
|
+
// Reconciles against the FINAL stored report (normalized or error) — reconcileLedger
|
|
220
|
+
// itself no-ops on error-status reports, so a timeout/invalid run leaves the ledger
|
|
221
|
+
// byte-for-byte unchanged.
|
|
222
|
+
writeLedger(wd.name, reconcileLedger(ledger, report, now()));
|
|
223
|
+
pruneReports(wd.name, wd.keepReports);
|
|
224
|
+
return { report, storedPath };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Deliver one scheduler-level alert (e.g. held-down) by launching a minimal one-shot temp agent
|
|
228
|
+
* under the watchdog's own identity whose entire mission is: bind identity, send `text` to
|
|
229
|
+
* `wd.coordinator`, write `sent.json`, exit. The fleet process itself cannot send ours messages
|
|
230
|
+
* (owner-approved deviation 4), so this is how a scheduler tick that can't reach an operator any
|
|
231
|
+
* other way still gets a message out.
|
|
232
|
+
*
|
|
233
|
+
* Reuses the Task 7/8 run machinery (temp-dir prep, ensureIdentity, ResolvedRole shape, child
|
|
234
|
+
* launch, kill mechanics) but stores no report, touches no ledger findings, and writes no
|
|
235
|
+
* watch.json manifest — `sent.json` is this run's only completion sentinel. A fixed 2-minute
|
|
236
|
+
* deadline applies regardless of `wd.timeoutMs`.
|
|
237
|
+
*
|
|
238
|
+
* Unlike executeWatchdogRun (which relies on the scheduler's run lock), this function acquires
|
|
239
|
+
* and releases the run lock itself — a notifier run shares the same temp dir name
|
|
240
|
+
* (`agentDir(wd.identity, true)`) as a regular run, and nothing else serializes the two. Refusal
|
|
241
|
+
* to acquire is logged and treated as a no-op: the held-down state already means no regular runs
|
|
242
|
+
* are in flight, so a collision is rare. Any other failure (including a timeout) is logged as a
|
|
243
|
+
* warning and swallowed — a notifier failure must never throw into the scheduler loop.
|
|
244
|
+
*/
|
|
245
|
+
export async function executeNotifierRun(wd, text, deps) {
|
|
246
|
+
const now = deps.now ?? (() => new Date());
|
|
247
|
+
const sleep = deps.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
|
|
248
|
+
const fail = (e) => {
|
|
249
|
+
deps.log(`notifier run for '${wd.name}' failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
250
|
+
};
|
|
251
|
+
// acquireRunLock can itself throw (EACCES/EIO, not just EEXIST — store.ts) — guarded
|
|
252
|
+
// separately from the try/finally below so a throw here never reaches the caller either;
|
|
253
|
+
// nothing was acquired, so there is nothing to release.
|
|
254
|
+
let acquired = false;
|
|
255
|
+
try {
|
|
256
|
+
acquired = acquireRunLock(wd.name);
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
fail(e);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (!acquired) {
|
|
263
|
+
deps.log(`notifier run for '${wd.name}' skipped: run lock held`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const roleName = wd.identity;
|
|
267
|
+
const runDir = agentDir(roleName, true);
|
|
268
|
+
try {
|
|
269
|
+
// A crashed previous run can leave the temp dir behind; start clean.
|
|
270
|
+
if (existsSync(runDir))
|
|
271
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
272
|
+
const guarantee = await ensureIdentity(wd.identity, {}, deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
|
|
273
|
+
const cfg = deps.cfg ?? loadConfig();
|
|
274
|
+
const role = buildWatchdogRole(wd, cfg);
|
|
275
|
+
const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
|
|
276
|
+
const sentinelPath = join(dir, 'sent.json');
|
|
277
|
+
// applyRole wrote the generic role briefing; the notifier contract replaces it.
|
|
278
|
+
writeFileSync(join(dir, 'briefing.md'), generateNotifierBriefing({
|
|
279
|
+
wd, vocabulary: getAdapter(wd.harness).vocabulary,
|
|
280
|
+
identityGuarantee: guarantee.state, text,
|
|
281
|
+
}));
|
|
282
|
+
// loadTempRole (runner.ts) needs this to run the child via `_run-watchdog`.
|
|
283
|
+
writeFileSync(join(dir, 'role.yaml'), stringify(role));
|
|
284
|
+
// Same host-wide launch-gate snapshot executeWatchdogRun writes (spec §3).
|
|
285
|
+
if (cfg.startStaggerMs > 0)
|
|
286
|
+
writeFileSync(join(dir, START_STAGGER_FILE), String(cfg.startStaggerMs));
|
|
287
|
+
const launch = deps.launchChild ?? defaultLaunchChild;
|
|
288
|
+
const start = now();
|
|
289
|
+
const child = launch(deps.binPath, roleName, dir);
|
|
290
|
+
const reason = await waitForSentinel(sentinelPath, child, NOTIFIER_TIMEOUT_MS, start, now, sleep);
|
|
291
|
+
await killIfNeeded(reason, child, roleName, sleep);
|
|
292
|
+
if (reason === 'timeout')
|
|
293
|
+
deps.log(`notifier run for '${wd.name}' timed out`);
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
fail(e);
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
// Same "never escape" rule as the lock acquire above: cleanup and release are each
|
|
300
|
+
// guarded individually so neither can turn a handled failure into an unhandled one.
|
|
301
|
+
try {
|
|
302
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
303
|
+
}
|
|
304
|
+
catch (e) {
|
|
305
|
+
fail(e);
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
releaseRunLock(wd.name);
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
fail(e);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/** What `_run-watchdog` calls: one supervised session, no cleanup — the parent harvests. */
|
|
316
|
+
export async function runWatchdogAgent(name) {
|
|
317
|
+
await runOnce(name, { temp: true });
|
|
318
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { FleetConfig } from '../config.js';
|
|
2
|
+
import type { ResolvedWatchdog } from './config.js';
|
|
3
|
+
import { executeNotifierRun, executeWatchdogRun } from './run.js';
|
|
4
|
+
export interface WatchdogSchedulerState {
|
|
5
|
+
version: 1;
|
|
6
|
+
consecutiveFailures: number;
|
|
7
|
+
heldDown: boolean;
|
|
8
|
+
heldSince?: string;
|
|
9
|
+
lastRunAt?: string;
|
|
10
|
+
nextRunAt?: string;
|
|
11
|
+
lastError?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Read a watchdog's scheduler state; a missing or corrupt file starts clean (mirrors readRestartLedger). */
|
|
14
|
+
export declare function readSchedulerState(name: string): WatchdogSchedulerState;
|
|
15
|
+
/** Write never throws: scheduler diagnostics must never take the loop down. */
|
|
16
|
+
export declare function writeSchedulerState(name: string, s: WatchdogSchedulerState): void;
|
|
17
|
+
/**
|
|
18
|
+
* Operator release (Task 15): clears failures and heldDown so a held-down
|
|
19
|
+
* loop's next held-down poll sees a clean state and resumes running. Also
|
|
20
|
+
* clears the ledger's `heldDownAlerted` flag — that flag is what makes "alert
|
|
21
|
+
* once per hold-down" durable across scheduler restarts (it lives in
|
|
22
|
+
* alerts.json, not state.json), so a release must reset it too or a
|
|
23
|
+
* subsequent hold-down would silently alert zero times.
|
|
24
|
+
*
|
|
25
|
+
* Also reclaims a STALE run lock (final review #2, tightened by finding #2):
|
|
26
|
+
* a watchdog SIGKILLed mid-run (e.g. systemd's TimeoutStopSec on a
|
|
27
|
+
* fleet-wide stop) leaves `.run-lock` behind forever — the loop's `finally`
|
|
28
|
+
* that would normally release it never runs. Without this, every future tick
|
|
29
|
+
* sees the lock held and reports `skipped_overlap` indefinitely, and skips
|
|
30
|
+
* never alert. `ours-fleet restart <watchdog>` is the documented recovery, so
|
|
31
|
+
* it must clear a stale lock too, not just the failure/hold-down bookkeeping.
|
|
32
|
+
* But only a DEMONSTRABLY stale lock (dead owner pid, or legacy lock with no
|
|
33
|
+
* owner metadata) — a lock genuinely held by a live run (foreground
|
|
34
|
+
* `watchdog-run`, another scheduler instance) must survive an operator's
|
|
35
|
+
* `restart` of a DIFFERENT problem (e.g. releasing hold-down) unrelated to
|
|
36
|
+
* that live run; two runs sharing the same temp dir would corrupt each
|
|
37
|
+
* other's output. Best-effort: a reclaim failure here must not turn an
|
|
38
|
+
* operator's recovery action into a crash.
|
|
39
|
+
*/
|
|
40
|
+
export declare function resetSchedulerState(name: string): void;
|
|
41
|
+
export declare const WATCHDOG_HOLD_THRESHOLD = 3;
|
|
42
|
+
export declare const WATCHDOG_BACKOFF_MAX_MS = 3600000;
|
|
43
|
+
/** Bounded exponential backoff: 1x, 2x, 4x, ... capped at WATCHDOG_BACKOFF_MAX_MS (spec §3). */
|
|
44
|
+
export declare function watchdogBackoffMs(intervalMs: number, failures: number): number;
|
|
45
|
+
export interface SchedulerDeps {
|
|
46
|
+
now(): Date;
|
|
47
|
+
sleep(ms: number): Promise<void>;
|
|
48
|
+
log(line: string): void;
|
|
49
|
+
binPath: string;
|
|
50
|
+
/**
|
|
51
|
+
* The fleet config `runScheduler` loaded from the `-c FILE`/default path
|
|
52
|
+
* (final review #1). Threaded into both `runOnceFor`'s and the notifier's
|
|
53
|
+
* deps so a run under a non-default config doesn't silently fall back to
|
|
54
|
+
* `loadConfig()`'s default `~/fleet.yaml`. `runWatchdogLoop` callers that
|
|
55
|
+
* bypass `runScheduler` (tests) may omit it — the run/notifier machinery
|
|
56
|
+
* falls back to `loadConfig()` itself when `cfg` is undefined.
|
|
57
|
+
*/
|
|
58
|
+
cfg?: FleetConfig;
|
|
59
|
+
/** Injectable for tests. */
|
|
60
|
+
runOnceFor?: typeof executeWatchdogRun;
|
|
61
|
+
/** Loop exit for tests + SIGTERM (wired by the CLI, not here). */
|
|
62
|
+
shouldStop?(): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Scheduler-level alert hook (Task 14, spec §5.5): fired once per hold-down
|
|
65
|
+
* transition (guarded in `settle` by `ledger.heldDownAlerted`, cleared by
|
|
66
|
+
* `resetSchedulerState`). Default: `executeNotifierRun` — the fleet
|
|
67
|
+
* process can't message on its own (deviation 4), so the default delivers
|
|
68
|
+
* the alert via a one-shot notifier agent under the watchdog's identity.
|
|
69
|
+
*/
|
|
70
|
+
onSchedulerAlert?(wd: ResolvedWatchdog, text: string): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Injectable notifier launcher backing the default `onSchedulerAlert` —
|
|
73
|
+
* consulted only when `onSchedulerAlert` itself is not supplied. Default:
|
|
74
|
+
* `executeNotifierRun`. Lets tests observe/short-circuit the default alert
|
|
75
|
+
* path (binPath/log/now/sleep wiring) without replacing onSchedulerAlert
|
|
76
|
+
* wholesale.
|
|
77
|
+
*/
|
|
78
|
+
notifierRun?: typeof executeNotifierRun;
|
|
79
|
+
/** Poll cadence while held down. Default 5000. */
|
|
80
|
+
heldPollMs?: number;
|
|
81
|
+
/**
|
|
82
|
+
* Injectable run-lock primitives. Default: store's acquireRunLock /
|
|
83
|
+
* releaseRunLock. Both are mkdir/rmdir-backed and can throw (EACCES, EIO,
|
|
84
|
+
* ENOTEMPTY on release) — the loop always classifies such a throw as a
|
|
85
|
+
* failed tick rather than letting it escape (review finding #1).
|
|
86
|
+
*/
|
|
87
|
+
locks?: {
|
|
88
|
+
acquire(name: string): boolean;
|
|
89
|
+
release(name: string): void;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* One watchdog's scheduling loop: run immediately, then repeatedly sleep the
|
|
94
|
+
* backed-off interval and run again, until `shouldStop()`. No overlap (a run
|
|
95
|
+
* lock guards each attempt), bounded exponential backoff on failure, and a
|
|
96
|
+
* hold-down circuit breaker after WATCHDOG_HOLD_THRESHOLD consecutive
|
|
97
|
+
* failures (released externally via resetSchedulerState).
|
|
98
|
+
*/
|
|
99
|
+
export declare function runWatchdogLoop(wd: ResolvedWatchdog, deps: SchedulerDeps): Promise<void>;
|
|
100
|
+
/**
|
|
101
|
+
* Run every enabled watchdog's loop concurrently until deps.shouldStop().
|
|
102
|
+
* SIGTERM wiring into shouldStop is the CLI's job (Task 10), not this
|
|
103
|
+
* function's.
|
|
104
|
+
*/
|
|
105
|
+
export declare function runScheduler(configPath: string | undefined, deps: SchedulerDeps): Promise<void>;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { loadConfig } from '../config.js';
|
|
4
|
+
import { errorReport } from './report.js';
|
|
5
|
+
import { executeNotifierRun, executeWatchdogRun } from './run.js';
|
|
6
|
+
import { acquireRunLock, formatRunId, readRunLockOwner, reclaimStaleRunLock, releaseRunLock, watchdogDir, writeReport, } from './store.js';
|
|
7
|
+
import { readLedger, writeLedger } from './alerts.js';
|
|
8
|
+
const STATE_FILE = 'state.json';
|
|
9
|
+
const cleanState = () => ({ version: 1, consecutiveFailures: 0, heldDown: false });
|
|
10
|
+
/** Read a watchdog's scheduler state; a missing or corrupt file starts clean (mirrors readRestartLedger). */
|
|
11
|
+
export function readSchedulerState(name) {
|
|
12
|
+
try {
|
|
13
|
+
const raw = JSON.parse(readFileSync(join(watchdogDir(name), STATE_FILE), 'utf8'));
|
|
14
|
+
if (raw.version !== 1)
|
|
15
|
+
return cleanState();
|
|
16
|
+
return { ...cleanState(), ...raw, version: 1 };
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return cleanState();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Write never throws: scheduler diagnostics must never take the loop down. */
|
|
23
|
+
export function writeSchedulerState(name, s) {
|
|
24
|
+
try {
|
|
25
|
+
writeFileSync(join(watchdogDir(name), STATE_FILE), JSON.stringify(s, null, 2) + '\n', { mode: 0o600 });
|
|
26
|
+
}
|
|
27
|
+
catch { /* diagnostics must never take the loop down */ }
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Operator release (Task 15): clears failures and heldDown so a held-down
|
|
31
|
+
* loop's next held-down poll sees a clean state and resumes running. Also
|
|
32
|
+
* clears the ledger's `heldDownAlerted` flag — that flag is what makes "alert
|
|
33
|
+
* once per hold-down" durable across scheduler restarts (it lives in
|
|
34
|
+
* alerts.json, not state.json), so a release must reset it too or a
|
|
35
|
+
* subsequent hold-down would silently alert zero times.
|
|
36
|
+
*
|
|
37
|
+
* Also reclaims a STALE run lock (final review #2, tightened by finding #2):
|
|
38
|
+
* a watchdog SIGKILLed mid-run (e.g. systemd's TimeoutStopSec on a
|
|
39
|
+
* fleet-wide stop) leaves `.run-lock` behind forever — the loop's `finally`
|
|
40
|
+
* that would normally release it never runs. Without this, every future tick
|
|
41
|
+
* sees the lock held and reports `skipped_overlap` indefinitely, and skips
|
|
42
|
+
* never alert. `ours-fleet restart <watchdog>` is the documented recovery, so
|
|
43
|
+
* it must clear a stale lock too, not just the failure/hold-down bookkeeping.
|
|
44
|
+
* But only a DEMONSTRABLY stale lock (dead owner pid, or legacy lock with no
|
|
45
|
+
* owner metadata) — a lock genuinely held by a live run (foreground
|
|
46
|
+
* `watchdog-run`, another scheduler instance) must survive an operator's
|
|
47
|
+
* `restart` of a DIFFERENT problem (e.g. releasing hold-down) unrelated to
|
|
48
|
+
* that live run; two runs sharing the same temp dir would corrupt each
|
|
49
|
+
* other's output. Best-effort: a reclaim failure here must not turn an
|
|
50
|
+
* operator's recovery action into a crash.
|
|
51
|
+
*/
|
|
52
|
+
export function resetSchedulerState(name) {
|
|
53
|
+
writeSchedulerState(name, cleanState());
|
|
54
|
+
const ledger = readLedger(name);
|
|
55
|
+
if (ledger.heldDownAlerted)
|
|
56
|
+
writeLedger(name, { ...ledger, heldDownAlerted: false });
|
|
57
|
+
try {
|
|
58
|
+
reclaimStaleRunLock(name);
|
|
59
|
+
}
|
|
60
|
+
catch { /* best effort */ }
|
|
61
|
+
}
|
|
62
|
+
export const WATCHDOG_HOLD_THRESHOLD = 3;
|
|
63
|
+
export const WATCHDOG_BACKOFF_MAX_MS = 3_600_000;
|
|
64
|
+
/** Bounded exponential backoff: 1x, 2x, 4x, ... capped at WATCHDOG_BACKOFF_MAX_MS (spec §3). */
|
|
65
|
+
export function watchdogBackoffMs(intervalMs, failures) {
|
|
66
|
+
if (failures <= 0)
|
|
67
|
+
return intervalMs;
|
|
68
|
+
return Math.min(intervalMs * 2 ** (failures - 1), WATCHDOG_BACKOFF_MAX_MS);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* One watchdog's scheduling loop: run immediately, then repeatedly sleep the
|
|
72
|
+
* backed-off interval and run again, until `shouldStop()`. No overlap (a run
|
|
73
|
+
* lock guards each attempt), bounded exponential backoff on failure, and a
|
|
74
|
+
* hold-down circuit breaker after WATCHDOG_HOLD_THRESHOLD consecutive
|
|
75
|
+
* failures (released externally via resetSchedulerState).
|
|
76
|
+
*/
|
|
77
|
+
export async function runWatchdogLoop(wd, deps) {
|
|
78
|
+
const runOnceFor = deps.runOnceFor ?? executeWatchdogRun;
|
|
79
|
+
const shouldStop = deps.shouldStop ?? (() => false);
|
|
80
|
+
const notifierRun = deps.notifierRun ?? executeNotifierRun;
|
|
81
|
+
const onSchedulerAlert = deps.onSchedulerAlert ?? ((wd, text) => notifierRun(wd, text, {
|
|
82
|
+
binPath: deps.binPath, log: deps.log, now: deps.now, sleep: deps.sleep, cfg: deps.cfg,
|
|
83
|
+
}));
|
|
84
|
+
const heldPollMs = deps.heldPollMs ?? 5_000;
|
|
85
|
+
const locks = deps.locks ?? { acquire: acquireRunLock, release: releaseRunLock };
|
|
86
|
+
/**
|
|
87
|
+
* Apply one tick's outcome to state.json: update the failure streak,
|
|
88
|
+
* transition into hold-down on the Nth consecutive failure (firing the
|
|
89
|
+
* alert at most once per transition via the ledger's `heldDownAlerted`
|
|
90
|
+
* guard — durable across restarts, unlike an in-memory flag), then sleep
|
|
91
|
+
* before the next tick. On the tick that transitions into hold-down, sleep
|
|
92
|
+
* `heldPollMs` rather than the (possibly hour-long) backoff delay — an
|
|
93
|
+
* operator's resetSchedulerState must be noticed within one poll cycle,
|
|
94
|
+
* not after the last backoff finishes (review finding #2).
|
|
95
|
+
*/
|
|
96
|
+
const settle = async (isFailure, errorMessage, startedAt) => {
|
|
97
|
+
const s = readSchedulerState(wd.name);
|
|
98
|
+
if (isFailure) {
|
|
99
|
+
s.consecutiveFailures += 1;
|
|
100
|
+
s.lastError = errorMessage;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
s.consecutiveFailures = 0;
|
|
104
|
+
s.lastError = undefined;
|
|
105
|
+
}
|
|
106
|
+
s.lastRunAt = startedAt.toISOString();
|
|
107
|
+
let justHeld = false;
|
|
108
|
+
if (s.consecutiveFailures >= WATCHDOG_HOLD_THRESHOLD && !s.heldDown) {
|
|
109
|
+
s.heldDown = true;
|
|
110
|
+
s.heldSince = deps.now().toISOString();
|
|
111
|
+
justHeld = true;
|
|
112
|
+
}
|
|
113
|
+
const delay = justHeld ? heldPollMs : watchdogBackoffMs(wd.intervalMs, s.consecutiveFailures);
|
|
114
|
+
s.nextRunAt = new Date(deps.now().getTime() + delay).toISOString();
|
|
115
|
+
writeSchedulerState(wd.name, s);
|
|
116
|
+
if (justHeld) {
|
|
117
|
+
const msg = `watchdog ${wd.name} held down after ${WATCHDOG_HOLD_THRESHOLD} consecutive failed runs: ${s.lastError}`;
|
|
118
|
+
deps.log(msg);
|
|
119
|
+
// "Once per state change" (spec §5.5) must survive a scheduler restart, so the guard
|
|
120
|
+
// lives in the ledger (alerts.json), not in memory — resetSchedulerState clears it.
|
|
121
|
+
const ledger = readLedger(wd.name);
|
|
122
|
+
if (!ledger.heldDownAlerted) {
|
|
123
|
+
await onSchedulerAlert(wd, msg);
|
|
124
|
+
writeLedger(wd.name, { ...ledger, heldDownAlerted: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
await deps.sleep(delay);
|
|
128
|
+
};
|
|
129
|
+
for (;;) {
|
|
130
|
+
if (shouldStop())
|
|
131
|
+
return;
|
|
132
|
+
if (readSchedulerState(wd.name).heldDown) {
|
|
133
|
+
await deps.sleep(heldPollMs);
|
|
134
|
+
if (shouldStop())
|
|
135
|
+
return;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const tickStart = deps.now();
|
|
139
|
+
// The run-lock is a filesystem mutex (mkdir/rmdir) and can throw
|
|
140
|
+
// (EACCES, EIO, ENOTEMPTY on release — store.ts's release deliberately
|
|
141
|
+
// rethrows non-ENOENT failures). Never let that throw escape this loop:
|
|
142
|
+
// runScheduler drives every watchdog's loop via Promise.all, so an
|
|
143
|
+
// uncaught throw here would kill every OTHER watchdog's loop too.
|
|
144
|
+
// Classify it as this tick's failure instead (review finding #1).
|
|
145
|
+
let acquired = false;
|
|
146
|
+
let acquireError;
|
|
147
|
+
try {
|
|
148
|
+
acquired = locks.acquire(wd.name);
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
acquireError = e instanceof Error ? e.message : String(e);
|
|
152
|
+
}
|
|
153
|
+
if (acquireError !== undefined) {
|
|
154
|
+
await settle(true, acquireError, tickStart);
|
|
155
|
+
if (shouldStop())
|
|
156
|
+
return;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!acquired) {
|
|
160
|
+
writeReport(wd.name, errorReport({
|
|
161
|
+
watchdog: wd.name, run_id: formatRunId(tickStart),
|
|
162
|
+
started_at: tickStart.toISOString(), finished_at: tickStart.toISOString(),
|
|
163
|
+
error: 'skipped_overlap',
|
|
164
|
+
}));
|
|
165
|
+
deps.log(`watchdog ${wd.name}: skipped run (previous run still holds the lock)`);
|
|
166
|
+
// Failure count is untouched by a skip; only lastRunAt/nextRunAt move.
|
|
167
|
+
// The backoff cadence (not the raw interval) still governs during an
|
|
168
|
+
// active failure streak: a skip isn't a finished run, so it shouldn't
|
|
169
|
+
// reset the retry cadence to full speed either (spec §3).
|
|
170
|
+
const skipState = readSchedulerState(wd.name);
|
|
171
|
+
const delay = watchdogBackoffMs(wd.intervalMs, skipState.consecutiveFailures);
|
|
172
|
+
writeSchedulerState(wd.name, {
|
|
173
|
+
...skipState,
|
|
174
|
+
lastRunAt: tickStart.toISOString(),
|
|
175
|
+
nextRunAt: new Date(tickStart.getTime() + delay).toISOString(),
|
|
176
|
+
});
|
|
177
|
+
await deps.sleep(delay);
|
|
178
|
+
if (shouldStop())
|
|
179
|
+
return;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
let outcome;
|
|
183
|
+
let tickError;
|
|
184
|
+
try {
|
|
185
|
+
outcome = await runOnceFor(wd, {
|
|
186
|
+
binPath: deps.binPath, log: deps.log, now: deps.now, sleep: deps.sleep, cfg: deps.cfg,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
tickError = e instanceof Error ? e.message : String(e);
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
// Same "never escape" rule applies to release: fold a throw into this
|
|
194
|
+
// tick's failure rather than letting it propagate out of the loop.
|
|
195
|
+
try {
|
|
196
|
+
locks.release(wd.name);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
tickError = tickError ?? (e instanceof Error ? e.message : String(e));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
let errorMessage;
|
|
203
|
+
if (outcome === undefined) {
|
|
204
|
+
errorMessage = tickError;
|
|
205
|
+
}
|
|
206
|
+
else if (outcome.report.status === 'error' && outcome.report.error !== 'skipped_overlap') {
|
|
207
|
+
errorMessage = outcome.report.error ?? tickError ?? 'unknown error';
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
errorMessage = tickError;
|
|
211
|
+
}
|
|
212
|
+
await settle(errorMessage !== undefined, errorMessage, tickStart);
|
|
213
|
+
if (shouldStop())
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Run every enabled watchdog's loop concurrently until deps.shouldStop().
|
|
219
|
+
* SIGTERM wiring into shouldStop is the CLI's job (Task 10), not this
|
|
220
|
+
* function's.
|
|
221
|
+
*/
|
|
222
|
+
export async function runScheduler(configPath, deps) {
|
|
223
|
+
const cfg = loadConfig(configPath);
|
|
224
|
+
const watchdogs = cfg.watchdogs.filter(w => w.enabled);
|
|
225
|
+
// Recover from a SIGKILLed prior run (final review #2a, tightened by
|
|
226
|
+
// finding #2): a scheduler restart is EVIDENCE, not proof, that no
|
|
227
|
+
// scheduler-owned run is in flight — a foreground `watchdog-run`, or
|
|
228
|
+
// another scheduler instance, may genuinely still hold a watchdog's lock.
|
|
229
|
+
// Only a DEMONSTRABLY stale lock (dead owner pid, or a legacy lock with no
|
|
230
|
+
// owner metadata) may be reclaimed; a live lock is left alone so the two
|
|
231
|
+
// runs never share the same temp dir — the loop then simply observes
|
|
232
|
+
// skipped_overlap on its first tick, same as any other overlap. Best-effort:
|
|
233
|
+
// a reclaim failure here must not prevent the scheduler from starting.
|
|
234
|
+
for (const wd of watchdogs) {
|
|
235
|
+
try {
|
|
236
|
+
if (!reclaimStaleRunLock(wd.name)) {
|
|
237
|
+
const owner = readRunLockOwner(wd.name);
|
|
238
|
+
deps.log(`watchdog '${wd.name}': run lock held by live pid ${owner?.pid ?? 'unknown'} — not reclaimed`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch { /* best effort */ }
|
|
242
|
+
}
|
|
243
|
+
await Promise.all(watchdogs.map(wd => runWatchdogLoop(wd, { ...deps, cfg })));
|
|
244
|
+
}
|