diff --git a/README.md b/README.md index 3ab37d1..b15d728 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ LAN URL to paste into CodeWatch — the upcoming mobile + watch companion app. - [Env Vars (Generic Poller)](#env-vars-generic-poller) - [Env Vars (Codex Smart Poller)](#env-vars-codex-smart-poller) - [Background Consolidation](#background-consolidation) +- [Peer Wake](#peer-wake-same-machine-agents-revive-sleeping-colleagues) - [Integrations](#integrations) - [GitHub Webhooks](#github-webhooks-srcwebhook-servermjs) - [OpenClaw Bot Fleet](#openclaw-bot-fleet-srcopenclaw-mjs) @@ -489,6 +490,87 @@ Latest local verification from the Petrus machine, dogfooded against `v0.6.1`: - `GET /intent/petrus` returned `200` on `2026-04-08`, with `agents=[claudemb, claudemm]` and `devices=[macbook, mac-mini]` both active and zero stale slots after the UIK v0.2.2 deployment. - `GET /api/search/observations?query=thinkoff&limit=3` returned `200` on `2026-03-31` (claude-mem path unchanged since v0.5.0). +## Peer wake (same-machine agents revive sleeping colleagues) + +Pollers and webhooks wake an agent whose receiver is running. But when an agent's +IDE goes quiet — the session wedged, the tunnel died silently, the app lost focus — +nothing is listening to wake it. **Peer wake** closes that gap: a watchdog running +in the always-on layer of a machine can revive any agent whose IDE lives on the +**same machine**, driving its GUI directly with no network dependency. + +`scripts/team-watchdog.mjs` is that watchdog. Every interval it reads the room, +computes each roster agent's last-seen timestamp, and for anyone that has gone +quiet past `STALE_MIN` it fires exactly one wake path — rate-limited by a cooldown +and an `MAX_NUDGES` cap so it never spams. + +**The model — who can wake whom:** + +``` +machine M (always-on watchdog) + ├─ localWake → agent's IDE is ON machine M → GUI-wake it directly, + │ works even with no network + ├─ gate → agent is on ANOTHER machine → POST /wake to its + │ daemon — only lands if that + │ machine is awake + receiver up + └─ (neither) → gateless agent in the room → room @mention its poller catches +``` + +A watchdog only *directly* revives agents whose IDE runs on its own machine +(`localWake`). Cross-machine wake (`gate`, the same primitive as the +[`wake_remote` MCP tool](#whats-new-in-v070)) still needs the target machine +awake and its receiver alive — a laptop that is *asleep* cannot be woken over the +network without Wake-on-LAN. That is the one thing peer wake cannot do; run a +watchdog **on each machine** so every agent has a local reviver. + +**Roster format** — `config/watchdog-roster.json` (gitignored; it holds hosts and +machine paths). Copy [`config/watchdog-roster.example.json`](config/watchdog-roster.example.json) +and edit it for the agents that live on *this* machine. Each entry names one wake path: + +```json +[ + { "handle": "@codex-on-this-mac", "localWake": "/abs/path/ide-agent-kit/tools/codex_gui_nudge.sh" }, + { "handle": "@agent-elsewhere", "gate": "http://192.168.0.9:8788" }, + { "handle": "@gateless-agent" } +] +``` + +The roster can also come from `IAK_WATCHDOG_ROSTER` (inline JSON) or +`IAK_WATCHDOG_ROSTER_FILE`. No roster → nothing to watch (the watchdog logs and +idles), so it is safe to run everywhere. + +**Safety: never types over a human.** `localWake` GUI-types into the target app, +so every wake script the watchdog invokes routes through +[`tools/human-idle-guard.sh`](tools/human-idle-guard.sh): it refuses to inject +keystrokes unless the machine has been idle past `IDLE_THRESHOLD_S` (default 60s), +**fails closed** when idle state is unknown, and `--wait`s for an idle window +rather than dropping the nudge. The shipped wake scripts (`scripts/claude-gui-wake.sh`, +`tools/codex_gui_nudge.sh`) already call it (and also refuse to type into a locked +screen or a non-frontmost window). **Any custom script you point `localWake` at +MUST be idle-guarded too** — the watchdog runs it verbatim. + +**Opt-in install (per machine).** The watchdog is off by default — it needs a +roster. Load it with launchd from the parameterized example: + +```bash +# 1. create your roster from the example +sed "s|REPLACE_WITH_IAK_ROOT|$HOME/ide-agent-kit|g" \ + config/watchdog-roster.example.json > config/watchdog-roster.json +# then edit handles/paths for the agents whose IDEs run on THIS Mac + +# 2. install + load the LaunchAgent (KeepAlive; restarts if it exits) +sed "s|REPLACE_WITH_IAK_ROOT|$HOME/ide-agent-kit|g" \ + examples/team-watchdog-launchd.plist \ + > ~/Library/LaunchAgents/com.thinkoff.iak-team-watchdog.plist +launchctl load ~/Library/LaunchAgents/com.thinkoff.iak-team-watchdog.plist +``` + +Or let the installer do it: `IAK_INSTALL_WATCHDOG=1` makes `scripts/install.sh` +install the LaunchAgent (only if a roster exists). On a laptop that sleeps, prefer +the one-shot variant documented in the plist header (`ONCE=1` + `StartInterval`), +since the in-process interval timer stalls across sleep. Tunables (all env, all +optional): `STALE_MIN`, `COOLDOWN_MIN`, `INTERVAL_MIN`, `MAX_NUDGES`, `ROOM`, +`WATCHDOG_SELF`, `DRY_RUN` (detect + log only, no wakes/posts). + ## Integrations ### MCP server (`src/mcp-server.mjs`) diff --git a/config/watchdog-roster.example.json b/config/watchdog-roster.example.json new file mode 100644 index 0000000..e726cbf --- /dev/null +++ b/config/watchdog-roster.example.json @@ -0,0 +1,17 @@ +[ + { + "handle": "@codex-on-this-mac", + "localWake": "REPLACE_WITH_IAK_ROOT/tools/codex_gui_nudge.sh" + }, + { + "handle": "@claude-on-this-mac", + "localWake": "REPLACE_WITH_IAK_ROOT/scripts/claude-gui-wake.sh" + }, + { + "handle": "@agent-on-another-machine", + "gate": "http://REPLACE_WITH_PEER_HOST:8788" + }, + { + "handle": "@gateless-agent-in-the-room" + } +] diff --git a/examples/team-watchdog-launchd.plist b/examples/team-watchdog-launchd.plist new file mode 100644 index 0000000..9d53bf9 --- /dev/null +++ b/examples/team-watchdog-launchd.plist @@ -0,0 +1,62 @@ + + + + + + Label + com.thinkoff.iak-team-watchdog + ProgramArguments + + /bin/bash + -c + cd REPLACE_WITH_IAK_ROOT && exec node scripts/team-watchdog.mjs + + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + IAK_ROOT + REPLACE_WITH_IAK_ROOT + IAK_CONFIG + REPLACE_WITH_IAK_ROOT/ide-agent-kit.json + + RunAtLoad + + KeepAlive + + ThrottleInterval + 30 + StandardOutPath + /tmp/iak-team-watchdog.launchd.log + StandardErrorPath + /tmp/iak-team-watchdog.launchd.log + + diff --git a/scripts/install.sh b/scripts/install.sh index f5a2b73..46aad26 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -150,6 +150,35 @@ tmux new-session -d -s "$TMUX_SESSION" \ "cd $INSTALL_DIR && node bin/iak-mcp-daemon.mjs" sleep 2 +# 6b. Optional: peer-wake team-watchdog (opt-in; needs a roster). +# OFF by default — enable with IAK_INSTALL_WATCHDOG=1. Revives sleeping +# colleagues whose IDEs run on THIS machine; see README "Peer wake". +if [ "${IAK_INSTALL_WATCHDOG:-0}" = "1" ]; then + ROSTER_FILE="$INSTALL_DIR/config/watchdog-roster.json" + if [ ! -f "$ROSTER_FILE" ]; then + yellow "IAK_INSTALL_WATCHDOG=1 but no roster at $ROSTER_FILE — skipping." + echo " Create one from the example, then re-run with IAK_INSTALL_WATCHDOG=1:" + echo " sed \"s|REPLACE_WITH_IAK_ROOT|$INSTALL_DIR|g\" \\" + echo " $INSTALL_DIR/config/watchdog-roster.example.json > $ROSTER_FILE" + echo " # then edit handles/paths for the agents whose IDEs run on THIS Mac" + else + LA_DIR="$HOME/Library/LaunchAgents" + PLIST="$LA_DIR/com.thinkoff.iak-team-watchdog.plist" + mkdir -p "$LA_DIR" + sed "s|REPLACE_WITH_IAK_ROOT|$INSTALL_DIR|g" \ + "$INSTALL_DIR/examples/team-watchdog-launchd.plist" > "$PLIST" + yellow "Installed peer-wake watchdog LaunchAgent → $PLIST" + if launchctl list 2>/dev/null | grep -q com.thinkoff.iak-team-watchdog; then + echo " Already loaded. Reload after edits:" + echo " launchctl unload \"$PLIST\" && launchctl load \"$PLIST\"" + elif launchctl load "$PLIST" 2>/dev/null; then + green " Loaded (KeepAlive; reads $ROSTER_FILE)." + else + yellow " Load it manually: launchctl load \"$PLIST\"" + fi + fi +fi + # 7. report LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "127.0.0.1") LAN_URL="http://${LAN_IP}:8788" diff --git a/scripts/team-watchdog.mjs b/scripts/team-watchdog.mjs index 79b57a8..721ae63 100644 --- a/scripts/team-watchdog.mjs +++ b/scripts/team-watchdog.mjs @@ -1,22 +1,61 @@ #!/usr/bin/env node -// team-watchdog (claudeMB / MacBook) — keep the team alive in the room. +// team-watchdog — peer-wake watchdog: keep the team alive in the room. // // Runs in the ALWAYS-ON layer (launchd / tmux), independent of any agent IDE. // Every INTERVAL it reads the room, computes each roster agent's last-seen, and -// wakes any that has gone quiet too long: POST /wake (if reachable) + an -// @mention nudge their poller catches. Rate-limited so it never spams. +// wakes any that has gone quiet too long. Each roster entry picks ONE wake path: +// - localWake: a SAME-MACHINE GUI wake script (idle-guarded) — the watchdog +// on machine M can revive any agent whose IDE runs on M, even with no +// network reachability. This is the "same-machine peer wake" primitive. +// - gate: a cross-machine POST /wake to a peer daemon (reachable only +// while that machine is awake and its receiver is running). +// - neither: a room @mention nudge the agent's own poller catches. +// Rate-limited (cooldown + MAX_NUDGES) so it never spams the room. // -// DRY_RUN=1 -> detect + log only, NO room posts / wakes (safe before Tailscale -// to the Mini is up, since unreachable agents would just get spam). -// Flip to active (unset DRY_RUN) once `tailscale up` is done on the Mini. +// localWake targets MUST be idle-guarded scripts (they call tools/human-idle-guard.sh +// so they never type over an active human). The repo's claude-gui-wake.sh and +// tools/codex_gui_nudge.sh already do; any custom script you point localWake at must too. // -// Env: ANTFARM_KEY (defaults to claudeMB posting key), ROOM, STALE_MIN, -// COOLDOWN_MIN, INTERVAL_MIN, MAX_NUDGES. +// DRY_RUN=1 -> detect + log only, NO room posts / wakes (safe while peers are +// unreachable, since they would otherwise just get spam). +// +// Env: GROUPMIND_KEY (or legacy ANTFARM_KEY), ROOM, STALE_MIN, COOLDOWN_MIN, +// INTERVAL_MIN, MAX_NUDGES, WATCHDOG_SELF. Roster: IAK_WATCHDOG_ROSTER +// (inline JSON) or IAK_WATCHDOG_ROSTER_FILE (default config/watchdog-roster.json). -const API = 'https://antfarm.world/api/v1'; +// Every call site uses groupmind.one; antfarm.world is the dead legacy host. +export const API = 'https://groupmind.one/api/v1'; const HOME = process.env.HOME || ''; const REPO = process.env.IAK_ROOT || new URL('..', import.meta.url).pathname.replace(/\/$/, ''); -const KEY = process.env.ANTFARM_KEY || JSON.parse(readFileSync(process.env.IAK_CONFIG || `${REPO}/config/macbook.json`,'utf8')).poller.api_key; +// Resolved lazily so importing this module (e.g. from a test) never touches the +// config file. GROUPMIND_KEY is the current name; ANTFARM_KEY stays as a legacy alias. +let _key; +function KEY() { + if (_key === undefined) { + // Resolve the key from env, else the config the installer actually writes + // (ide-agent-kit.json), falling back to the legacy config/macbook.json so + // existing hand-wired setups keep working (codex review, #34: the opt-in + // install only sets IAK_ROOT, so a macbook.json-only default threw every + // tick on a fresh install). + if (process.env.GROUPMIND_KEY || process.env.ANTFARM_KEY) { + _key = process.env.GROUPMIND_KEY || process.env.ANTFARM_KEY; + } else { + const candidates = [ + process.env.IAK_CONFIG, + `${REPO}/ide-agent-kit.json`, + `${REPO}/config/macbook.json`, + ].filter(Boolean); + let found; + for (const c of candidates) { + try { found = JSON.parse(readFileSync(c, 'utf8'))?.poller?.api_key; } catch { /* next */ } + if (found) break; + } + if (!found) throw new Error(`team-watchdog: no GROUPMIND_KEY and no poller.api_key in ${candidates.join(', ')}`); + _key = found; + } + } + return _key; +} const ROOM = process.env.ROOM || 'thinkoff-development'; const DRY = process.env.DRY_RUN === '1'; const STALE_MS = (Number(process.env.STALE_MIN) || 20) * 60_000; @@ -28,7 +67,7 @@ const MAX_NUDGES = Number(process.env.MAX_NUDGES) || 2; // stop room-posting // it - silence is an accepted state. Only a gateless agent (room @mention is its // wake path) or an UNREACHABLE gate produces a room post. -const SELF = 'claudemb'; +const SELF = process.env.WATCHDOG_SELF || 'claudemb'; // ether + hermes are set to mention_only (Jul 5 2026, after the overnight // flood): the ONLY thing that makes them talk is being @mentioned, so a // watchdog nudge to them re-creates the exact noise petrus complained about. @@ -37,7 +76,7 @@ const SELF = 'claudemb'; // hardcode tailnet IPs or machine paths (#30 gate, B3). File format: // config/watchdog-roster.json = [{"handle":"@x","gate":"http://host:8788"}, // {"handle":"@y","localWake":"/abs/path.sh"}] -function loadRoster() { +export function loadRoster() { const fromEnv = process.env.IAK_WATCHDOG_ROSTER; const file = process.env.IAK_WATCHDOG_ROSTER_FILE || `${REPO}/config/watchdog-roster.json`; try { @@ -48,7 +87,7 @@ function loadRoster() { return []; } } -const ROSTER = loadRoster(); +let ROSTER = []; /* Historical roster notes (Jul 2026): // codex ADDED with a silent LOCAL wake (Jul 13 2026): its webhook-wake tunnel // died silently Jul 9-12 and nobody noticed for three days. The watchdog is @@ -64,21 +103,22 @@ const ROSTER = loadRoster(); // State persists to a file so it survives one-shot (StartInterval) runs and // sleep/wake. In-process setTimeout pauses when the Mac sleeps, so the watchdog // runs as a launchd StartInterval one-shot (ONCE=1) instead of a long loop. -import { readFileSync, writeFileSync } from 'node:fs'; +import { readFileSync, writeFileSync, realpathSync } from 'node:fs'; import { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; const STATE_FILE = '/tmp/team-watchdog-state.json'; let state = {}; -try { state = JSON.parse(readFileSync(STATE_FILE, 'utf8')); } catch {} +function loadState() { try { return JSON.parse(readFileSync(STATE_FILE, 'utf8')); } catch { return {}; } } function saveState() { try { writeFileSync(STATE_FILE, JSON.stringify(state)); } catch {} } async function getMessages() { - const r = await fetch(`${API}/rooms/${ROOM}/messages?limit=80`, { headers: { 'X-API-Key': KEY }, signal: AbortSignal.timeout(15000) }); + const r = await fetch(`${API}/rooms/${ROOM}/messages?limit=80`, { headers: { 'X-API-Key': KEY() }, signal: AbortSignal.timeout(15000) }); const d = await r.json(); return d.messages || []; } async function postRoom(body) { if (DRY) { console.log('[dry] would post:', body); return; } - await fetch(`${API}/messages`, { method: 'POST', headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ room: ROOM, body }), signal: AbortSignal.timeout(15000) }); + await fetch(`${API}/messages`, { method: 'POST', headers: { 'X-API-Key': KEY(), 'Content-Type': 'application/json' }, body: JSON.stringify({ room: ROOM, body }), signal: AbortSignal.timeout(15000) }); } async function wakeGate(gate) { if (!gate) return false; @@ -115,7 +155,7 @@ function wakeLocal(script) { }, (err) => resolve(!err)); }); } -function lastSeen(msgs, handle) { +export function lastSeen(msgs, handle) { const h = handle.replace(/^@/, '').toLowerCase(); let t = 0; for (const m of msgs) { @@ -126,6 +166,14 @@ function lastSeen(msgs, handle) { } return t; } +// Pure staleness helpers (exported for unit tests). `seen` is the epoch-ms +// timestamp from lastSeen(); 0 means "never seen" -> infinitely stale. +export function ageMinutes(seen, now) { + return seen ? Math.round((now - seen) / 60000) : Infinity; +} +export function isStale(seen, now, staleMs) { + return (now - (seen || 0)) > staleMs; +} async function tick() { const msgs = await getMessages(); @@ -133,9 +181,9 @@ async function tick() { const toNudge = []; for (const a of ROSTER) { const seen = lastSeen(msgs, a.handle); - const ageMin = seen ? Math.round((now - seen) / 60000) : Infinity; + const ageMin = ageMinutes(seen, now); const st = (state[a.handle] ||= { lastNudge: 0, misses: 0, silentWakes: 0 }); - const stale = (now - (seen || 0)) > STALE_MS; + const stale = isStale(seen, now, STALE_MS); if (!stale) { st.misses = 0; st.silentWakes = 0; continue; } if (now - st.lastNudge < COOLDOWN_MS) continue; // rate-limit if (st.misses >= MAX_NUDGES) { console.log(`${a.handle} still down (giving room a rest after ${st.misses} nudges)`); continue; } @@ -172,17 +220,28 @@ async function tick() { if (toNudge.length) { const mentions = toNudge.map(n => n.handle).join(' '); const detail = toNudge.map(n => `${n.handle} quiet ${n.ageMin}m${n.wedged ? ' [gate woke but no post - possibly wedged]' : ''}`).join(', '); - await postRoom(`${mentions} [team-watchdog] you have gone quiet - check rooms and reply here. (${detail}; auto from claudeMB)`); + await postRoom(`${mentions} [team-watchdog] you have gone quiet - check rooms and reply here. (${detail}; auto from ${SELF})`); } saveState(); console.log(new Date().toISOString(), `checked ${ROSTER.length}`, toNudge.length ? `nudged: ${toNudge.map(n=>n.handle).join(',')}` : 'all healthy/cooling'); } -(async () => { +async function run() { + ROSTER = loadRoster(); + state = loadState(); console.log(`team-watchdog up. DRY_RUN=${DRY} stale=${STALE_MS/60000}m cooldown=${COOLDOWN_MS/60000}m interval=${INTERVAL_MS/60000}m`); for (;;) { try { await tick(); } catch (e) { console.error('tick error:', e.message); } if (process.env.ONCE === '1') break; await new Promise(r => setTimeout(r, INTERVAL_MS)); } +} + +// Only run the loop when executed directly (node scripts/team-watchdog.mjs), +// never when imported by a test. Keeping import side-effect-free is what lets +// the pure helpers (loadRoster, lastSeen, ageMinutes, isStale) be unit-tested. +const invokedDirectly = (() => { + try { return !!process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); } + catch { return false; } })(); +if (invokedDirectly) run(); diff --git a/test/team-watchdog.test.mjs b/test/team-watchdog.test.mjs new file mode 100644 index 0000000..29f0146 --- /dev/null +++ b/test/team-watchdog.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { API, lastSeen, ageMinutes, isStale, loadRoster } from '../scripts/team-watchdog.mjs'; + +const MIN = 60_000; + +// Guards the stale-host regression: every call site must hit groupmind.one, not +// the dead legacy antfarm.world host. +test('API points at the live groupmind.one host, not antfarm.world', () => { + assert.equal(API, 'https://groupmind.one/api/v1'); + assert.ok(!API.includes('antfarm.world')); +}); + +test('lastSeen returns the newest matching message timestamp, @/case-insensitively', () => { + const msgs = [ + { from: '@Codex', created_at: '2026-07-14T00:00:00Z' }, + { from: 'codex', created_at: '2026-07-14T01:00:00Z' }, // newer, no @, lowercase + { from: '@someone-else', created_at: '2026-07-14T09:00:00Z' }, + ]; + assert.equal(lastSeen(msgs, '@codex'), Date.parse('2026-07-14T01:00:00Z')); + assert.equal(lastSeen(msgs, 'CODEX'), Date.parse('2026-07-14T01:00:00Z')); +}); + +test('lastSeen returns 0 when the agent has never posted', () => { + assert.equal(lastSeen([{ from: '@other', created_at: '2026-07-14T00:00:00Z' }], '@ghost'), 0); + assert.equal(lastSeen([], '@anyone'), 0); +}); + +test('ageMinutes is Infinity for a never-seen agent and rounded minutes otherwise', () => { + const now = 1_000 * MIN; + assert.equal(ageMinutes(0, now), Infinity); + assert.equal(ageMinutes(now - 20 * MIN, now), 20); + assert.equal(ageMinutes(now - 29_000, now), 0); // <30s rounds down to 0 +}); + +test('isStale compares age against the threshold and treats never-seen as stale', () => { + const now = 1_000 * MIN; + const staleMs = 20 * MIN; + assert.equal(isStale(now - 21 * MIN, now, staleMs), true); // older than threshold + assert.equal(isStale(now - 19 * MIN, now, staleMs), false); // fresher than threshold + assert.equal(isStale(0, now, staleMs), true); // never seen -> stale +}); + +test('loadRoster reads inline JSON from IAK_WATCHDOG_ROSTER', () => { + const prev = process.env.IAK_WATCHDOG_ROSTER; + try { + process.env.IAK_WATCHDOG_ROSTER = JSON.stringify([ + { handle: '@codex', localWake: '/abs/path/codex_gui_nudge.sh' }, + { handle: '@peer', gate: 'http://host:8788' }, + ]); + const roster = loadRoster(); + assert.equal(roster.length, 2); + assert.equal(roster[0].localWake, '/abs/path/codex_gui_nudge.sh'); + assert.equal(roster[1].gate, 'http://host:8788'); + } finally { + if (prev === undefined) delete process.env.IAK_WATCHDOG_ROSTER; + else process.env.IAK_WATCHDOG_ROSTER = prev; + } +}); + +test('loadRoster reads a roster file via IAK_WATCHDOG_ROSTER_FILE', () => { + const prevEnv = process.env.IAK_WATCHDOG_ROSTER; + const prevFile = process.env.IAK_WATCHDOG_ROSTER_FILE; + const dir = mkdtempSync(path.join(tmpdir(), 'iak-roster-')); + try { + delete process.env.IAK_WATCHDOG_ROSTER; // env inline must not shadow the file + const file = path.join(dir, 'roster.json'); + writeFileSync(file, JSON.stringify([{ handle: '@gateless' }])); + process.env.IAK_WATCHDOG_ROSTER_FILE = file; + const roster = loadRoster(); + assert.deepEqual(roster, [{ handle: '@gateless' }]); + } finally { + rmSync(dir, { recursive: true, force: true }); + if (prevEnv === undefined) delete process.env.IAK_WATCHDOG_ROSTER; + else process.env.IAK_WATCHDOG_ROSTER = prevEnv; + if (prevFile === undefined) delete process.env.IAK_WATCHDOG_ROSTER_FILE; + else process.env.IAK_WATCHDOG_ROSTER_FILE = prevFile; + } +}); + +test('loadRoster returns [] when the roster is missing (nothing to watch, no crash)', () => { + const prevEnv = process.env.IAK_WATCHDOG_ROSTER; + const prevFile = process.env.IAK_WATCHDOG_ROSTER_FILE; + try { + delete process.env.IAK_WATCHDOG_ROSTER; + process.env.IAK_WATCHDOG_ROSTER_FILE = path.join(tmpdir(), 'iak-does-not-exist-' + Date.now() + '.json'); + assert.deepEqual(loadRoster(), []); + } finally { + if (prevEnv === undefined) delete process.env.IAK_WATCHDOG_ROSTER; + else process.env.IAK_WATCHDOG_ROSTER = prevEnv; + if (prevFile === undefined) delete process.env.IAK_WATCHDOG_ROSTER_FILE; + else process.env.IAK_WATCHDOG_ROSTER_FILE = prevFile; + } +});