@ours.network/fleet 0.10.4 → 0.11.1

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 (48) 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 +202 -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 +32 -0
  8. package/dist/duration.d.ts +5 -0
  9. package/dist/duration.js +20 -0
  10. package/dist/harness/codex.js +16 -1
  11. package/dist/isolation/policy.js +5 -0
  12. package/dist/isolation/runtime.d.ts +16 -0
  13. package/dist/isolation/runtime.js +136 -0
  14. package/dist/isolation/types.d.ts +2 -0
  15. package/dist/ops.d.ts +16 -0
  16. package/dist/ops.js +112 -3
  17. package/dist/paths.d.ts +1 -0
  18. package/dist/paths.js +1 -0
  19. package/dist/resolved-plan.js +8 -0
  20. package/dist/runner.js +11 -5
  21. package/dist/watchdog/alerts.d.ts +34 -0
  22. package/dist/watchdog/alerts.js +78 -0
  23. package/dist/watchdog/briefing.d.ts +65 -0
  24. package/dist/watchdog/briefing.js +181 -0
  25. package/dist/watchdog/config.d.ts +53 -0
  26. package/dist/watchdog/config.js +120 -0
  27. package/dist/watchdog/query.d.ts +78 -0
  28. package/dist/watchdog/query.js +124 -0
  29. package/dist/watchdog/report.d.ts +53 -0
  30. package/dist/watchdog/report.js +126 -0
  31. package/dist/watchdog/run.d.ts +63 -0
  32. package/dist/watchdog/run.js +345 -0
  33. package/dist/watchdog/scheduler.d.ts +105 -0
  34. package/dist/watchdog/scheduler.js +244 -0
  35. package/dist/watchdog/service.d.ts +46 -0
  36. package/dist/watchdog/service.js +179 -0
  37. package/dist/watchdog/store.d.ts +85 -0
  38. package/dist/watchdog/store.js +226 -0
  39. package/dist/web/runtime.js +46 -2
  40. package/dist/web/server.d.ts +2 -0
  41. package/dist/web/server.js +18 -0
  42. package/dist/web-app/assets/{TerminalView-DcImdrI1.js → TerminalView-BvcIkuIF.js} +1 -1
  43. package/dist/web-app/assets/index-B-jtLAkp.css +1 -0
  44. package/dist/web-app/assets/index-CUN7ksTw.js +9 -0
  45. package/dist/web-app/index.html +2 -2
  46. package/package.json +1 -1
  47. package/dist/web-app/assets/index-BokQN1Ao.js +0 -9
  48. package/dist/web-app/assets/index-lAXzaOZM.css +0 -1
@@ -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,63 @@
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
+ /** Live temporary-role discovery override for focused tests. */
25
+ discoverLiveTemporaryRoles?(): Promise<string[]>;
26
+ }
27
+ export interface WatchdogRunOutcome {
28
+ report: WatchdogReport;
29
+ storedPath: string;
30
+ }
31
+ /**
32
+ * Run one watchdog agent end-to-end in a clean-context temp state dir: provision
33
+ * identity, materialize the run's contract (briefing/manifest/role snapshot),
34
+ * launch the child, enforce a deadline against report.json as the completion
35
+ * sentinel, harvest whatever it wrote (or a synthetic error report if it
36
+ * didn't), store the result, and always clean up the temp dir.
37
+ *
38
+ * The scheduler's run-lock guarantees only one run per watchdog at a time;
39
+ * this function itself takes no lock (Task 8).
40
+ */
41
+ export declare function executeWatchdogRun(wd: ResolvedWatchdog, deps: WatchdogRunDeps): Promise<WatchdogRunOutcome>;
42
+ /**
43
+ * Deliver one scheduler-level alert (e.g. held-down) by launching a minimal one-shot temp agent
44
+ * under the watchdog's own identity whose entire mission is: bind identity, send `text` to
45
+ * `wd.coordinator`, write `sent.json`, exit. The fleet process itself cannot send ours messages
46
+ * (owner-approved deviation 4), so this is how a scheduler tick that can't reach an operator any
47
+ * other way still gets a message out.
48
+ *
49
+ * Reuses the Task 7/8 run machinery (temp-dir prep, ensureIdentity, ResolvedRole shape, child
50
+ * launch, kill mechanics) but stores no report, touches no ledger findings, and writes no
51
+ * watch.json manifest — `sent.json` is this run's only completion sentinel. A fixed 2-minute
52
+ * deadline applies regardless of `wd.timeoutMs`.
53
+ *
54
+ * Unlike executeWatchdogRun (which relies on the scheduler's run lock), this function acquires
55
+ * and releases the run lock itself — a notifier run shares the same temp dir name
56
+ * (`agentDir(wd.identity, true)`) as a regular run, and nothing else serializes the two. Refusal
57
+ * to acquire is logged and treated as a no-op: the held-down state already means no regular runs
58
+ * are in flight, so a collision is rare. Any other failure (including a timeout) is logged as a
59
+ * warning and swallowed — a notifier failure must never throw into the scheduler loop.
60
+ */
61
+ export declare function executeNotifierRun(wd: ResolvedWatchdog, text: string, deps: WatchdogRunDeps): Promise<void>;
62
+ /** What `_run-watchdog` calls: one supervised session, no cleanup — the parent harvests. */
63
+ export declare function runWatchdogAgent(name: string): Promise<void>;
@@ -0,0 +1,345 @@
1
+ import { closeSync, existsSync, openSync, readFileSync, readdirSync, 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, tmpRoot } from '../paths.js';
14
+ import { loadConfig, resolveMonitorConfig, resolveWorklogPolicy, ROLE_NAME_RE, } from '../config.js';
15
+ import { getAdapter } from '../harness/registry.js';
16
+ import { redactLogLine } from '../application/log-service.js';
17
+ import { controlRequest, controlSocketPath } from '../session/control.js';
18
+ import { Tmux } from '../tmux.js';
19
+ const execFileAsync = promisify(execFile);
20
+ /** How often the deadline loop polls for a completed report or a dead child. */
21
+ const POLL_MS = 1000;
22
+ /** A `report.json` this old, with no further writes, is treated as finished. */
23
+ const WRITE_STABLE_MS = 2000;
24
+ /** Grace given to the agent after a stable report before the session is killed. */
25
+ const HARVEST_GRACE_MS = 5000;
26
+ /** Fixed deadline for a one-shot notifier run — always 2 minutes, never wd.timeoutMs (Task 14). */
27
+ const NOTIFIER_TIMEOUT_MS = 120_000;
28
+ /**
29
+ * Poll for a completion sentinel file (report.json for an inspection run, sent.json for a
30
+ * notifier run) going write-stable, or the child exiting first, or the deadline passing —
31
+ * whichever comes first. Shared by executeWatchdogRun and executeNotifierRun so the two run
32
+ * flavors' wait loops can never drift apart.
33
+ */
34
+ async function waitForSentinel(sentinelPath, child, deadlineMs, start, now, sleep) {
35
+ let exited = false;
36
+ // Attached now so a natural exit is observed even though the loop below never
37
+ // awaits the promise itself (its resolution races real time in the real
38
+ // launcher, and is driven purely by test fixtures in tests).
39
+ child.exited.then(() => { exited = true; }).catch(() => { exited = true; });
40
+ for (;;) {
41
+ if (exited)
42
+ return 'exited';
43
+ if (existsSync(sentinelPath)) {
44
+ const age = now().getTime() - statSync(sentinelPath).mtimeMs;
45
+ if (age >= WRITE_STABLE_MS)
46
+ return 'stable';
47
+ }
48
+ if (now().getTime() - start.getTime() > deadlineMs)
49
+ return 'timeout';
50
+ await sleep(POLL_MS);
51
+ }
52
+ }
53
+ /** After waitForSentinel settles, harvest the grace period (if stable) then kill the child+session. */
54
+ async function killIfNeeded(reason, child, roleName, sleep) {
55
+ if (reason === 'stable' || reason === 'timeout') {
56
+ if (reason === 'stable')
57
+ await sleep(HARVEST_GRACE_MS);
58
+ child.kill();
59
+ await killTmuxSession(roleName);
60
+ }
61
+ }
62
+ function defaultLaunchChild(binPath, roleName, runDir) {
63
+ const out = openSync(join(runDir, 'run.log'), 'a');
64
+ const child = spawnChild(process.execPath, [binPath, '_run-watchdog', roleName], {
65
+ detached: false, stdio: ['ignore', out, out],
66
+ });
67
+ // spawn() dup's the fd into the child; Node never closes our copy on its own,
68
+ // so a long-running scheduler launching many watchdog runs would leak one fd
69
+ // per run and eventually hit EMFILE. Safe to close immediately — the child
70
+ // keeps writing to its own dup'd descriptor.
71
+ closeSync(out);
72
+ return {
73
+ kill: () => { child.kill(); },
74
+ exited: new Promise(resolve => {
75
+ child.once('exit', () => resolve());
76
+ child.once('error', () => resolve());
77
+ }),
78
+ };
79
+ }
80
+ /** Best-effort: the tmux session outlives the supervisor child (`runOnce` created it). */
81
+ async function killTmuxSession(roleName) {
82
+ try {
83
+ await execFileAsync('tmux', ['kill-session', '-t', roleName]);
84
+ }
85
+ catch { /* best effort */ }
86
+ }
87
+ /** Last 4096 chars of run.log, redacted — attached to error reports as diagnostic tail. */
88
+ function readTail(runDir) {
89
+ try {
90
+ const raw = readFileSync(join(runDir, 'run.log'), 'utf8');
91
+ return redactLogLine(raw.slice(-4096)).text;
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ }
97
+ /** The temp role every watchdog-family run launches under. Isolation is opt-in. */
98
+ function buildWatchdogRole(wd, cfg) {
99
+ return {
100
+ name: wd.identity, sourceFile: '(watchdog)',
101
+ harness: wd.harness, session: wd.session,
102
+ identity: wd.identity, model: wd.model,
103
+ // Watchdogs are observe-only by contract, but their sanctioned status commands
104
+ // must reach host control sockets. Keep approvals/unattended escalation denied
105
+ // while disabling the harness's native filesystem/network sandbox.
106
+ permissions: { approval: 'deny', filesystem: 'unrestricted', unattended: 'deny' },
107
+ permissionsDeclared: true,
108
+ monitor: resolveMonitorConfig(cfg.defaults.monitor, undefined),
109
+ worklog: resolveWorklogPolicy(cfg.defaults.worklog, undefined),
110
+ isolation: wd.isolation,
111
+ };
112
+ }
113
+ /** Discover temporary sessions that are live now, not merely stale dirs on disk. */
114
+ async function discoverLiveTemporaryRoles() {
115
+ let names;
116
+ try {
117
+ names = readdirSync(tmpRoot(), { withFileTypes: true })
118
+ .filter(entry => entry.isDirectory() && !entry.isSymbolicLink() && ROLE_NAME_RE.test(entry.name))
119
+ .map(entry => entry.name);
120
+ }
121
+ catch {
122
+ return [];
123
+ }
124
+ const tmux = new Tmux();
125
+ const live = await Promise.all(names.map(async (name) => {
126
+ const dir = agentDir(name, true);
127
+ const [acpAlive, tmuxAlive] = await Promise.all([
128
+ existsSync(controlSocketPath(dir))
129
+ ? controlRequest(dir, { command: 'status' }, 2_000)
130
+ .then(response => response.ok
131
+ && response.result?.alive === true)
132
+ .catch(() => false)
133
+ : Promise.resolve(false),
134
+ tmux.has(name).catch(() => false),
135
+ ]);
136
+ return acpAlive || tmuxAlive ? name : undefined;
137
+ }));
138
+ return live.filter((name) => name !== undefined);
139
+ }
140
+ /**
141
+ * Run one watchdog agent end-to-end in a clean-context temp state dir: provision
142
+ * identity, materialize the run's contract (briefing/manifest/role snapshot),
143
+ * launch the child, enforce a deadline against report.json as the completion
144
+ * sentinel, harvest whatever it wrote (or a synthetic error report if it
145
+ * didn't), store the result, and always clean up the temp dir.
146
+ *
147
+ * The scheduler's run-lock guarantees only one run per watchdog at a time;
148
+ * this function itself takes no lock (Task 8).
149
+ */
150
+ export async function executeWatchdogRun(wd, deps) {
151
+ const now = deps.now ?? (() => new Date());
152
+ const sleep = deps.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
153
+ const start = now();
154
+ const runId = formatRunId(start);
155
+ const startedAt = start.toISOString();
156
+ const roleName = wd.identity;
157
+ const runDir = agentDir(roleName, true);
158
+ // Read once, before the manifest is written: the same snapshot both seeds the
159
+ // digest the agent sees and is the base the post-run reconcile folds into.
160
+ const ledger = readLedger(wd.name);
161
+ // A crashed previous run can leave the temp dir behind; start clean.
162
+ if (existsSync(runDir))
163
+ rmSync(runDir, { recursive: true, force: true });
164
+ let report;
165
+ try {
166
+ const guarantee = await ensureIdentity(wd.identity, {}, deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
167
+ const cfg = deps.cfg ?? loadConfig();
168
+ const discovered = wd.watchExplicit
169
+ ? []
170
+ : await (deps.discoverLiveTemporaryRoles ?? discoverLiveTemporaryRoles)();
171
+ const watch = [...new Set([...wd.watch, ...discovered])]
172
+ .filter(name => name !== wd.identity);
173
+ const runWd = { ...wd, watch };
174
+ const role = buildWatchdogRole(runWd, cfg);
175
+ const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
176
+ const reportPath = join(dir, 'report.json');
177
+ const manifestPath = join(dir, 'watch.json');
178
+ const promptFocus = wd.promptFile ? readFileSync(wd.promptFile, 'utf8') : undefined;
179
+ // applyRole wrote the generic role briefing; the watchdog contract replaces it.
180
+ writeFileSync(join(dir, 'briefing.md'), generateWatchdogBriefing({
181
+ wd: runWd, manifestPath, reportPath,
182
+ vocabulary: getAdapter(wd.harness).vocabulary,
183
+ identityGuarantee: guarantee.state,
184
+ promptFocus,
185
+ }));
186
+ // loadTempRole (runner.ts) needs this to run the child via `_run-watchdog`.
187
+ writeFileSync(join(dir, 'role.yaml'), stringify(role));
188
+ const manifest = {
189
+ watchdog: wd.name, run_id: runId, coordinator: wd.coordinator, started_at: startedAt,
190
+ roles: runWd.watch.map(r => ({
191
+ name: r,
192
+ stateDir: wd.watch.includes(r) ? agentDir(r) : agentDir(r, true),
193
+ })),
194
+ digest: computeDigest(ledger, wd.alertCooldownMs, now()),
195
+ };
196
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
197
+ // Snapshot the fleet start-stagger so the detached run (no config path
198
+ // threaded through `_run-watchdog`) honors the same host-wide launch gate
199
+ // as every other role (spec §3) — mirrors src/spawn.ts:503-504.
200
+ if (cfg.startStaggerMs > 0)
201
+ writeFileSync(join(dir, START_STAGGER_FILE), String(cfg.startStaggerMs));
202
+ const launch = deps.launchChild ?? defaultLaunchChild;
203
+ const child = launch(deps.binPath, roleName, dir);
204
+ const reason = await waitForSentinel(reportPath, child, wd.timeoutMs, start, now, sleep);
205
+ await killIfNeeded(reason, child, roleName, sleep);
206
+ const timedOut = reason === 'timeout';
207
+ if (timedOut) {
208
+ report = errorReport({
209
+ watchdog: wd.name, run_id: runId, started_at: startedAt, finished_at: now().toISOString(),
210
+ error: 'timeout', tail: readTail(dir),
211
+ });
212
+ }
213
+ else {
214
+ let parsed;
215
+ let parseError;
216
+ try {
217
+ parsed = JSON.parse(readFileSync(reportPath, 'utf8'));
218
+ }
219
+ catch (e) {
220
+ parseError = e instanceof Error ? e.message : String(e);
221
+ }
222
+ const validationErrors = parseError === undefined ? validateWatchdogReport(parsed) : [];
223
+ if (parseError !== undefined || validationErrors.length) {
224
+ const detail = parseError ?? validationErrors.slice(0, 3).join('; ');
225
+ report = errorReport({
226
+ watchdog: wd.name, run_id: runId, started_at: startedAt, finished_at: now().toISOString(),
227
+ error: `invalid report: ${detail}`, tail: readTail(dir),
228
+ });
229
+ }
230
+ else {
231
+ report = normalizeWatchdogReport(parsed, { watchdog: wd.name, run_id: runId });
232
+ // Scheduler clock truth overrides whatever the agent wrote.
233
+ report.started_at = startedAt;
234
+ report.finished_at = now().toISOString();
235
+ }
236
+ }
237
+ // Isolation degradation (spec §7): a weaker guarantee must never look like
238
+ // the strong one, so stamp it on every outcome, error reports included.
239
+ if (existsSync(join(dir, '.isolation-degraded')))
240
+ report.isolation = 'degraded';
241
+ }
242
+ finally {
243
+ rmSync(runDir, { recursive: true, force: true });
244
+ }
245
+ const storedPath = writeReport(wd.name, report);
246
+ // Reconciles against the FINAL stored report (normalized or error) — reconcileLedger
247
+ // itself no-ops on error-status reports, so a timeout/invalid run leaves the ledger
248
+ // byte-for-byte unchanged.
249
+ writeLedger(wd.name, reconcileLedger(ledger, report, now()));
250
+ pruneReports(wd.name, wd.keepReports);
251
+ return { report, storedPath };
252
+ }
253
+ /**
254
+ * Deliver one scheduler-level alert (e.g. held-down) by launching a minimal one-shot temp agent
255
+ * under the watchdog's own identity whose entire mission is: bind identity, send `text` to
256
+ * `wd.coordinator`, write `sent.json`, exit. The fleet process itself cannot send ours messages
257
+ * (owner-approved deviation 4), so this is how a scheduler tick that can't reach an operator any
258
+ * other way still gets a message out.
259
+ *
260
+ * Reuses the Task 7/8 run machinery (temp-dir prep, ensureIdentity, ResolvedRole shape, child
261
+ * launch, kill mechanics) but stores no report, touches no ledger findings, and writes no
262
+ * watch.json manifest — `sent.json` is this run's only completion sentinel. A fixed 2-minute
263
+ * deadline applies regardless of `wd.timeoutMs`.
264
+ *
265
+ * Unlike executeWatchdogRun (which relies on the scheduler's run lock), this function acquires
266
+ * and releases the run lock itself — a notifier run shares the same temp dir name
267
+ * (`agentDir(wd.identity, true)`) as a regular run, and nothing else serializes the two. Refusal
268
+ * to acquire is logged and treated as a no-op: the held-down state already means no regular runs
269
+ * are in flight, so a collision is rare. Any other failure (including a timeout) is logged as a
270
+ * warning and swallowed — a notifier failure must never throw into the scheduler loop.
271
+ */
272
+ export async function executeNotifierRun(wd, text, deps) {
273
+ const now = deps.now ?? (() => new Date());
274
+ const sleep = deps.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
275
+ const fail = (e) => {
276
+ deps.log(`notifier run for '${wd.name}' failed: ${e instanceof Error ? e.message : String(e)}`);
277
+ };
278
+ // acquireRunLock can itself throw (EACCES/EIO, not just EEXIST — store.ts) — guarded
279
+ // separately from the try/finally below so a throw here never reaches the caller either;
280
+ // nothing was acquired, so there is nothing to release.
281
+ let acquired = false;
282
+ try {
283
+ acquired = acquireRunLock(wd.name);
284
+ }
285
+ catch (e) {
286
+ fail(e);
287
+ return;
288
+ }
289
+ if (!acquired) {
290
+ deps.log(`notifier run for '${wd.name}' skipped: run lock held`);
291
+ return;
292
+ }
293
+ const roleName = wd.identity;
294
+ const runDir = agentDir(roleName, true);
295
+ try {
296
+ // A crashed previous run can leave the temp dir behind; start clean.
297
+ if (existsSync(runDir))
298
+ rmSync(runDir, { recursive: true, force: true });
299
+ const guarantee = await ensureIdentity(wd.identity, {}, deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
300
+ const cfg = deps.cfg ?? loadConfig();
301
+ const role = buildWatchdogRole(wd, cfg);
302
+ const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
303
+ const sentinelPath = join(dir, 'sent.json');
304
+ // applyRole wrote the generic role briefing; the notifier contract replaces it.
305
+ writeFileSync(join(dir, 'briefing.md'), generateNotifierBriefing({
306
+ wd, vocabulary: getAdapter(wd.harness).vocabulary,
307
+ identityGuarantee: guarantee.state, text,
308
+ }));
309
+ // loadTempRole (runner.ts) needs this to run the child via `_run-watchdog`.
310
+ writeFileSync(join(dir, 'role.yaml'), stringify(role));
311
+ // Same host-wide launch-gate snapshot executeWatchdogRun writes (spec §3).
312
+ if (cfg.startStaggerMs > 0)
313
+ writeFileSync(join(dir, START_STAGGER_FILE), String(cfg.startStaggerMs));
314
+ const launch = deps.launchChild ?? defaultLaunchChild;
315
+ const start = now();
316
+ const child = launch(deps.binPath, roleName, dir);
317
+ const reason = await waitForSentinel(sentinelPath, child, NOTIFIER_TIMEOUT_MS, start, now, sleep);
318
+ await killIfNeeded(reason, child, roleName, sleep);
319
+ if (reason === 'timeout')
320
+ deps.log(`notifier run for '${wd.name}' timed out`);
321
+ }
322
+ catch (e) {
323
+ fail(e);
324
+ }
325
+ finally {
326
+ // Same "never escape" rule as the lock acquire above: cleanup and release are each
327
+ // guarded individually so neither can turn a handled failure into an unhandled one.
328
+ try {
329
+ rmSync(runDir, { recursive: true, force: true });
330
+ }
331
+ catch (e) {
332
+ fail(e);
333
+ }
334
+ try {
335
+ releaseRunLock(wd.name);
336
+ }
337
+ catch (e) {
338
+ fail(e);
339
+ }
340
+ }
341
+ }
342
+ /** What `_run-watchdog` calls: one supervised session, no cleanup — the parent harvests. */
343
+ export async function runWatchdogAgent(name) {
344
+ await runOnce(name, { temp: true });
345
+ }
@@ -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>;