@ours.network/fleet 0.10.3 → 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 (94) hide show
  1. package/README.md +66 -0
  2. package/dist/application/capabilities.d.ts +6 -0
  3. package/dist/application/capabilities.js +37 -0
  4. package/dist/application/errors.d.ts +31 -0
  5. package/dist/application/errors.js +51 -0
  6. package/dist/application/fleet-query-service.d.ts +42 -0
  7. package/dist/application/fleet-query-service.js +188 -0
  8. package/dist/application/log-service.d.ts +28 -0
  9. package/dist/application/log-service.js +146 -0
  10. package/dist/application/role-command-service.d.ts +37 -0
  11. package/dist/application/role-command-service.js +82 -0
  12. package/dist/application/role-creation-service.d.ts +142 -0
  13. package/dist/application/role-creation-service.js +374 -0
  14. package/dist/application/role-repository.d.ts +20 -0
  15. package/dist/application/role-repository.js +168 -0
  16. package/dist/application/session-control.d.ts +55 -0
  17. package/dist/application/session-control.js +115 -0
  18. package/dist/application/types.d.ts +156 -0
  19. package/dist/application/types.js +1 -0
  20. package/dist/cli.js +341 -3
  21. package/dist/config.d.ts +9 -2
  22. package/dist/config.js +21 -5
  23. package/dist/creation.d.ts +11 -0
  24. package/dist/creation.js +22 -5
  25. package/dist/docs.d.ts +1 -1
  26. package/dist/docs.js +56 -0
  27. package/dist/duration.d.ts +5 -0
  28. package/dist/duration.js +20 -0
  29. package/dist/index.d.ts +10 -1
  30. package/dist/index.js +9 -1
  31. package/dist/ops.d.ts +16 -0
  32. package/dist/ops.js +112 -3
  33. package/dist/paths.d.ts +1 -0
  34. package/dist/paths.js +1 -0
  35. package/dist/resolved-plan.js +7 -0
  36. package/dist/runner.js +10 -2
  37. package/dist/session/control.d.ts +4 -2
  38. package/dist/session/control.js +45 -13
  39. package/dist/spawn.d.ts +20 -2
  40. package/dist/spawn.js +94 -24
  41. package/dist/supervisor/launchd.js +17 -0
  42. package/dist/supervisor/none.js +17 -0
  43. package/dist/supervisor/systemd.js +4 -0
  44. package/dist/supervisor/types.d.ts +6 -0
  45. package/dist/tmux.d.ts +2 -0
  46. package/dist/tmux.js +8 -0
  47. package/dist/watchdog/alerts.d.ts +34 -0
  48. package/dist/watchdog/alerts.js +78 -0
  49. package/dist/watchdog/briefing.d.ts +65 -0
  50. package/dist/watchdog/briefing.js +181 -0
  51. package/dist/watchdog/config.d.ts +49 -0
  52. package/dist/watchdog/config.js +114 -0
  53. package/dist/watchdog/query.d.ts +78 -0
  54. package/dist/watchdog/query.js +124 -0
  55. package/dist/watchdog/report.d.ts +53 -0
  56. package/dist/watchdog/report.js +126 -0
  57. package/dist/watchdog/run.d.ts +61 -0
  58. package/dist/watchdog/run.js +318 -0
  59. package/dist/watchdog/scheduler.d.ts +105 -0
  60. package/dist/watchdog/scheduler.js +244 -0
  61. package/dist/watchdog/service.d.ts +46 -0
  62. package/dist/watchdog/service.js +179 -0
  63. package/dist/watchdog/store.d.ts +85 -0
  64. package/dist/watchdog/store.js +226 -0
  65. package/dist/web/audit.d.ts +22 -0
  66. package/dist/web/audit.js +54 -0
  67. package/dist/web/auth.d.ts +61 -0
  68. package/dist/web/auth.js +186 -0
  69. package/dist/web/control.d.ts +14 -0
  70. package/dist/web/control.js +110 -0
  71. package/dist/web/device-store.d.ts +27 -0
  72. package/dist/web/device-store.js +155 -0
  73. package/dist/web/events.d.ts +15 -0
  74. package/dist/web/events.js +34 -0
  75. package/dist/web/lock.d.ts +5 -0
  76. package/dist/web/lock.js +69 -0
  77. package/dist/web/runtime.d.ts +12 -0
  78. package/dist/web/runtime.js +214 -0
  79. package/dist/web/server.d.ts +37 -0
  80. package/dist/web/server.js +279 -0
  81. package/dist/web/service.d.ts +42 -0
  82. package/dist/web/service.js +180 -0
  83. package/dist/web/terminal/bridge.d.ts +27 -0
  84. package/dist/web/terminal/bridge.js +317 -0
  85. package/dist/web-app/assets/TerminalView-BvcIkuIF.js +9 -0
  86. package/dist/web-app/assets/index-B-jtLAkp.css +1 -0
  87. package/dist/web-app/assets/index-CUN7ksTw.js +9 -0
  88. package/dist/web-app/icons/ours-fleet-maskable.svg +4 -0
  89. package/dist/web-app/icons/ours-fleet.svg +4 -0
  90. package/dist/web-app/index.html +17 -0
  91. package/dist/web-app/manifest.webmanifest +15 -0
  92. package/dist/web-app/offline.html +18 -0
  93. package/dist/web-app/sw.js +51 -0
  94. package/package.json +26 -3
@@ -0,0 +1,126 @@
1
+ export const WATCHDOG_ROLE_STATUSES = ['healthy', 'idle', 'stale', 'blocked', 'off_briefing', 'unreachable', 'unknown'];
2
+ const SUMMARY_KEYS = ['checked', 'healthy', 'idle', 'anomalies'];
3
+ function isPlainObject(v) {
4
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
5
+ }
6
+ export function validateWatchdogReport(v) {
7
+ const errors = [];
8
+ if (!isPlainObject(v)) {
9
+ errors.push('report: expected an object');
10
+ return errors;
11
+ }
12
+ const r = v;
13
+ if (r.schema_version !== 1)
14
+ errors.push('schema_version: expected 1');
15
+ for (const key of ['watchdog', 'run_id', 'started_at', 'finished_at'])
16
+ if (typeof r[key] !== 'string')
17
+ errors.push(`${key}: expected a string`);
18
+ if (r.status !== 'ok' && r.status !== 'anomalies' && r.status !== 'error')
19
+ errors.push(`status: expected ok|anomalies|error, got '${String(r.status)}'`);
20
+ if (!isPlainObject(r.summary)) {
21
+ errors.push('summary: expected an object');
22
+ }
23
+ else {
24
+ for (const key of SUMMARY_KEYS)
25
+ if (typeof r.summary[key] !== 'number')
26
+ errors.push(`summary.${key}: expected a number`);
27
+ }
28
+ if (!Array.isArray(r.roles)) {
29
+ errors.push('roles: expected an array');
30
+ }
31
+ else {
32
+ r.roles.forEach((role, i) => {
33
+ if (!isPlainObject(role)) {
34
+ errors.push(`roles[${i}]: expected an object`);
35
+ return;
36
+ }
37
+ if (typeof role.role !== 'string')
38
+ errors.push(`roles[${i}].role: expected a string`);
39
+ if (!WATCHDOG_ROLE_STATUSES.includes(role.status))
40
+ errors.push(`roles[${i}].status: expected one of ${WATCHDOG_ROLE_STATUSES.join('|')}, got '${String(role.status)}'`);
41
+ if (role.status !== 'healthy' && role.status !== 'idle' && !role.reason)
42
+ errors.push(`roles[${i}]: non-healthy finding requires a reason`);
43
+ if (role.reason !== undefined && typeof role.reason !== 'string')
44
+ errors.push(`roles[${i}].reason: expected a string`);
45
+ if (role.evidence !== undefined) {
46
+ if (!Array.isArray(role.evidence)) {
47
+ errors.push(`roles[${i}].evidence: expected an array`);
48
+ }
49
+ else {
50
+ role.evidence.forEach((ev, j) => {
51
+ if (!isPlainObject(ev)) {
52
+ errors.push(`roles[${i}].evidence[${j}]: expected an object`);
53
+ return;
54
+ }
55
+ for (const key of ['source', 'detail', 'observed_at'])
56
+ if (typeof ev[key] !== 'string')
57
+ errors.push(`roles[${i}].evidence[${j}].${key}: expected a string`);
58
+ });
59
+ }
60
+ }
61
+ });
62
+ }
63
+ if (!Array.isArray(r.alerts)) {
64
+ errors.push('alerts: expected an array');
65
+ }
66
+ else {
67
+ r.alerts.forEach((alert, i) => {
68
+ if (!isPlainObject(alert)) {
69
+ errors.push(`alerts[${i}]: expected an object`);
70
+ return;
71
+ }
72
+ for (const key of ['role', 'code', 'coordinator', 'sent_at'])
73
+ if (typeof alert[key] !== 'string')
74
+ errors.push(`alerts[${i}].${key}: expected a string`);
75
+ });
76
+ }
77
+ if (r.error !== null && typeof r.error !== 'string')
78
+ errors.push('error: expected string or null');
79
+ return errors;
80
+ }
81
+ const cleanEvidence = (value, max = 280) => value.replace(/[\0-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').trim().slice(0, max);
82
+ export function normalizeWatchdogReport(r, ctx) {
83
+ const copy = JSON.parse(JSON.stringify(r));
84
+ copy.watchdog = ctx.watchdog;
85
+ copy.run_id = ctx.run_id;
86
+ copy.roles = copy.roles.map(role => {
87
+ // Rebuilt from ONLY the known fields (final review #6a) — the old
88
+ // `{ ...role }` spread let any unknown per-finding key (e.g. an agent
89
+ // dumping a huge `pane_dump`) ride unbounded into the store. Report-level
90
+ // extras (tail/isolation) are untouched here and stay tolerated; this is
91
+ // a per-finding allowlist only.
92
+ const next = { role: role.role, status: role.status };
93
+ if (role.reason !== undefined)
94
+ next.reason = cleanEvidence(role.reason);
95
+ if (role.evidence !== undefined) {
96
+ next.evidence = role.evidence.slice(0, 3).map(ev => ({
97
+ source: cleanEvidence(ev.source),
98
+ detail: cleanEvidence(ev.detail),
99
+ observed_at: cleanEvidence(ev.observed_at),
100
+ }));
101
+ }
102
+ if (role.alerted !== undefined)
103
+ next.alerted = Boolean(role.alerted);
104
+ return next;
105
+ });
106
+ return copy;
107
+ }
108
+ export function errorReport(ctx) {
109
+ return {
110
+ schema_version: 1,
111
+ watchdog: ctx.watchdog,
112
+ run_id: ctx.run_id,
113
+ started_at: ctx.started_at,
114
+ finished_at: ctx.finished_at,
115
+ status: 'error',
116
+ summary: { checked: 0, healthy: 0, idle: 0, anomalies: 0 },
117
+ roles: [],
118
+ alerts: [],
119
+ // Scheduler-built error strings can embed raw agent-controlled bytes
120
+ // (JSON.parse messages, validator interpolations of agent-supplied
121
+ // values) and are stored+printed as-is elsewhere — clean them the same
122
+ // way finding-level text is cleaned (final review #6b).
123
+ error: cleanEvidence(ctx.error),
124
+ ...(ctx.tail !== undefined ? { tail: ctx.tail.slice(-4096) } : {}),
125
+ };
126
+ }
@@ -0,0 +1,61 @@
1
+ import type { ResolvedWatchdog } from './config.js';
2
+ import type { WatchdogReport } from './report.js';
3
+ import { type IdentityProvisioner } from '../creation.js';
4
+ import { type FleetConfig } from '../config.js';
5
+ /** A minimal handle over the launched child: kill it, or await its natural exit. */
6
+ export interface WatchdogChildHandle {
7
+ kill(): void;
8
+ exited: Promise<void>;
9
+ }
10
+ export interface WatchdogRunDeps {
11
+ binPath: string;
12
+ log(line: string): void;
13
+ now?(): Date;
14
+ sleep?(ms: number): Promise<void>;
15
+ identityProvisioner?: IdentityProvisioner;
16
+ /**
17
+ * Injectable child launcher for tests. Default: spawn
18
+ * `node <binPath> _run-watchdog <roleName>` detached:false, stdio to
19
+ * `<runDir>/run.log`. Returns kill() and an exited promise.
20
+ */
21
+ launchChild?(binPath: string, roleName: string, runDir: string): WatchdogChildHandle;
22
+ /** Pre-loaded config (defaults inheritance). Falls back to `loadConfig()`. */
23
+ cfg?: FleetConfig;
24
+ }
25
+ export interface WatchdogRunOutcome {
26
+ report: WatchdogReport;
27
+ storedPath: string;
28
+ }
29
+ /**
30
+ * Run one watchdog agent end-to-end in a clean-context temp state dir: provision
31
+ * identity, materialize the run's contract (briefing/manifest/role snapshot),
32
+ * launch the child, enforce a deadline against report.json as the completion
33
+ * sentinel, harvest whatever it wrote (or a synthetic error report if it
34
+ * didn't), store the result, and always clean up the temp dir.
35
+ *
36
+ * The scheduler's run-lock guarantees only one run per watchdog at a time;
37
+ * this function itself takes no lock (Task 8).
38
+ */
39
+ export declare function executeWatchdogRun(wd: ResolvedWatchdog, deps: WatchdogRunDeps): Promise<WatchdogRunOutcome>;
40
+ /**
41
+ * Deliver one scheduler-level alert (e.g. held-down) by launching a minimal one-shot temp agent
42
+ * under the watchdog's own identity whose entire mission is: bind identity, send `text` to
43
+ * `wd.coordinator`, write `sent.json`, exit. The fleet process itself cannot send ours messages
44
+ * (owner-approved deviation 4), so this is how a scheduler tick that can't reach an operator any
45
+ * other way still gets a message out.
46
+ *
47
+ * Reuses the Task 7/8 run machinery (temp-dir prep, ensureIdentity, ResolvedRole shape, child
48
+ * launch, kill mechanics) but stores no report, touches no ledger findings, and writes no
49
+ * watch.json manifest — `sent.json` is this run's only completion sentinel. A fixed 2-minute
50
+ * deadline applies regardless of `wd.timeoutMs`.
51
+ *
52
+ * Unlike executeWatchdogRun (which relies on the scheduler's run lock), this function acquires
53
+ * and releases the run lock itself — a notifier run shares the same temp dir name
54
+ * (`agentDir(wd.identity, true)`) as a regular run, and nothing else serializes the two. Refusal
55
+ * to acquire is logged and treated as a no-op: the held-down state already means no regular runs
56
+ * are in flight, so a collision is rare. Any other failure (including a timeout) is logged as a
57
+ * warning and swallowed — a notifier failure must never throw into the scheduler loop.
58
+ */
59
+ export declare function executeNotifierRun(wd: ResolvedWatchdog, text: string, deps: WatchdogRunDeps): Promise<void>;
60
+ /** What `_run-watchdog` calls: one supervised session, no cleanup — the parent harvests. */
61
+ export declare function runWatchdogAgent(name: string): Promise<void>;
@@ -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>;