@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.
Files changed (42) hide show
  1. package/dist/application/fleet-query-service.d.ts +11 -0
  2. package/dist/application/fleet-query-service.js +9 -1
  3. package/dist/cli.js +200 -3
  4. package/dist/config.d.ts +2 -0
  5. package/dist/config.js +3 -1
  6. package/dist/docs.d.ts +1 -1
  7. package/dist/docs.js +22 -0
  8. package/dist/duration.d.ts +5 -0
  9. package/dist/duration.js +20 -0
  10. package/dist/ops.d.ts +16 -0
  11. package/dist/ops.js +112 -3
  12. package/dist/paths.d.ts +1 -0
  13. package/dist/paths.js +1 -0
  14. package/dist/resolved-plan.js +7 -0
  15. package/dist/watchdog/alerts.d.ts +34 -0
  16. package/dist/watchdog/alerts.js +78 -0
  17. package/dist/watchdog/briefing.d.ts +65 -0
  18. package/dist/watchdog/briefing.js +181 -0
  19. package/dist/watchdog/config.d.ts +49 -0
  20. package/dist/watchdog/config.js +114 -0
  21. package/dist/watchdog/query.d.ts +78 -0
  22. package/dist/watchdog/query.js +124 -0
  23. package/dist/watchdog/report.d.ts +53 -0
  24. package/dist/watchdog/report.js +126 -0
  25. package/dist/watchdog/run.d.ts +61 -0
  26. package/dist/watchdog/run.js +318 -0
  27. package/dist/watchdog/scheduler.d.ts +105 -0
  28. package/dist/watchdog/scheduler.js +244 -0
  29. package/dist/watchdog/service.d.ts +46 -0
  30. package/dist/watchdog/service.js +179 -0
  31. package/dist/watchdog/store.d.ts +85 -0
  32. package/dist/watchdog/store.js +226 -0
  33. package/dist/web/runtime.js +46 -2
  34. package/dist/web/server.d.ts +2 -0
  35. package/dist/web/server.js +18 -0
  36. package/dist/web-app/assets/{TerminalView-DcImdrI1.js → TerminalView-BvcIkuIF.js} +1 -1
  37. package/dist/web-app/assets/index-B-jtLAkp.css +1 -0
  38. package/dist/web-app/assets/index-CUN7ksTw.js +9 -0
  39. package/dist/web-app/index.html +2 -2
  40. package/package.json +1 -1
  41. package/dist/web-app/assets/index-BokQN1Ao.js +0 -9
  42. 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
+ }