Why
Restart=always in the xmrig unit only covers a dead process. The failure mode it can't see: xmrig wedges — process alive, 0 H/s, HTTP API unresponsive — and systemd happily reports active (running) while the rig mines nothing until a human notices. On a fleet that runs 24/7 unattended, "notices" can be days.
Second unattended gap: sustained thermal stress. #62 (closed) made tune-time measurements thermal-aware — reject throttled readings while picking a config; it deliberately did not touch steady-state runtime protection. A failed case fan in July shouldn't cook a rig because nothing watches temperature between tune runs.
Both checks are the same shape as the existing periodic-autotune machinery (#46/#95): a systemd timer firing a oneshot that runs a rigforge verb. Copy that pattern; invent nothing.
Scope
- New verb
watchdog: one health check per invocation (the timer provides cadence). Logic:
- Service not active → reset state, exit 0 (systemd
Restart= owns dead-process recovery; we own wedged-process recovery).
- Thermal hold active (marker file exists) → read temp; below
max_temp_c - 5 → clear marker, start the service, log; else exit (stay stopped).
max_temp_c set and temp above it → stop the service, write the marker, log, exit.
- Local API (
127.0.0.1:8080/2/summary) unreachable or hashrate 0 → bump a consecutive-fail counter (state file in $WORKER_ROOT); at 2 consecutive fails → systemctl restart, reset counter, log. Healthy → reset counter.
rigforge-watchdog.timer + oneshot .service templates, installed/removed by a new install_watchdog() copied from install_autotune() (rigforge.sh:772-796), wired into main() (after line 996), apply() (rigforge.sh:2700-2706 — apply is the config-change path), and uninstall() (next to the autotune-timer removal, rigforge.sh:1106-1109).
- Config keys (reference file only;
config.minimal.json stays minimal): watchdog = "disabled" (default) | "enabled"; watchdog_interval_min = 5; max_temp_c = "" (empty = no thermal cutoff).
- Linux-only, like
install_autotune ([ "$OS_TYPE" == "Linux" ] || return 0) — macOS has no systemd and is the dev/light-use platform.
Out of scope: notifications/alerting (a future issue can tail the journal), per-pool health, temperature tuning (that's #62's domain — this only stops/starts), configurable hysteresis (fixed 5°C — one less knob; revisit only if a real rig oscillates).
Acceptance criteria
Implementation notes
Numbered, mechanical. All design decisions are made — do not reopen them.
- parse_config (after rigforge.sh:374): parse the three keys.
watchdog: copy the autotune case-normalization shape (rigforge.sh:363-368): disabled|false|off|none|null|"" → WATCHDOG_MODE=disabled, enabled|true|on → enabled, *) error (typo must not silently disable a recovery mechanism).
WATCHDOG_INTERVAL_MIN=$(jq -r '.watchdog_interval_min // 5' ...); validate integer 1-1440 with the [[ =~ ^[0-9]+$ ]] pattern used for DONATION (rigforge.sh:288).
MAX_TEMP_C=$(jq -r '.max_temp_c // empty' ...); empty allowed; else integer 40-110 (below 40 you'd never restart; above 110 is meaningless). Default empty because a wrong cutoff on unknown sensors (thermal_zone0 varies by board) is worse than no cutoff — it's opt-in for operators who've checked their zone.
- Templates in
systemd/, copied from the autotune pair:
rigforge-watchdog.service.template: [Unit] Description=RigForge miner watchdog check, After=$SERVICE_NAME.service; [Service] Type=oneshot, Environment=RIGFORGE_OPERATOR=$RIGFORGE_OPERATOR (same re-own rationale as the autotune unit), ExecStart=$SCRIPT_DIR/rigforge.sh watchdog. Do NOT bake ACCESS_TOKEN or max_temp_c into the unit — the verb re-reads config.json via parse_config, so apply after a config edit needs no unit rewrite (only the interval lives in the timer).
rigforge-watchdog.timer.template: [Timer] OnBootSec=${WATCHDOG_INTERVAL_MIN}min, OnUnitActiveSec=${WATCHDOG_INTERVAL_MIN}min, [Install] WantedBy=timers.target. Interval semantics ("every N minutes"), so OnUnitActiveSec not OnCalendar — the autotune timer's calendar cadence doesn't fit minutes.
install_watchdog() directly below install_autotune() (after rigforge.sh:796): literal copy of its shape — Linux guard, disabled → systemctl disable --now + rm -f both units + daemon-reload (idempotent, only if present), enabled → envsubst-render both templates ('$SERVICE_NAME $RIGFORGE_OPERATOR $SCRIPT_DIR' for the service, '$WATCHDOG_INTERVAL_MIN' for the timer) via sudo tee, daemon-reload, enable --now.
- Wiring:
main() — add CURRENT_STEP="configuring the watchdog"; install_watchdog after the install_autotune call (rigforge.sh:995-996). apply() — add install_watchdog >/dev/null 2>&1 || true beside install_autotune (rigforge.sh:2703). uninstall() — add the watchdog pair to step 1 next to the autotune-timer block (rigforge.sh:1106-1109). Dispatcher (bottom case, rigforge.sh:~3087): watchdog) watchdog ;;. usage(): one line under "Day to day" or "Tuning" ("watchdog — one health check: restart a wedged miner, enforce max_temp_c; scheduled via watchdog:"enabled"").
watchdog() verb (place near apply()/bench()): parse_config first (gets WORKER_ROOT, ACCESS_TOKEN, MAX_TEMP_C). State: $WORKER_ROOT/watchdog.fails holding a bare integer (plain file, not JSON — bash 3.2, no parsing needed), thermal marker $WORKER_ROOT/watchdog.thermal-hold. Steps:
systemctl is-active --quiet "$SERVICE_NAME" false and no thermal marker → rm -f the fails file, log, return 0.
- Temp: reuse
_read_temp (rigforge.sh:1290-1299) verbatim — it already handles the thermal-zone read, THERMAL_ZONE override, and the TUNE_TEMP_CMD test override. TUNE_TEMP_CMD stays env-only; never read a command string from config.json and eval it (config is operator-writable without root; eval'ing it is privilege escalation).
- Thermal hold: marker exists → if
MAX_TEMP_C set and temp non-empty and awk says temp < MAX_TEMP_C - 5 → rm -f marker, systemctl start, log "watchdog: temp ${t}C below $((MAX_TEMP_C-5))C — restarting the miner."; else return 0. Hysteresis fixed at 5°C: big enough to outlast the post-restart heat-up on every rig we run (7800X3D/EPYC), small enough to not strand the miner; not configurable (one less knob to typo).
- Cutoff: no marker,
MAX_TEMP_C set, temp non-empty, temp > MAX_TEMP_C (awk — temps are floats) → systemctl stop, touch marker, warn, return 0. Unreadable temp = skip thermal (a missing sensor must not stop a healthy miner).
- Hashrate: reuse
_read_api_hashrate (rigforge.sh:2328-2343) verbatim — it already handles the ACCESS_TOKEN Bearer branch, the no-token default, and the API_CMD test override. Empty (unreachable) or awk-zero → increment the fails file (f=$(cat ... 2>/dev/null || echo 0)); f >= 2 → systemctl restart, reset file to 0, warn "watchdog: miner wedged (0 H/s / API dead twice) — restarted."; else write the bumped count and log. Healthy → reset to 0. Two consecutive fails, decided: one 5-min sample can be a restart-in-progress or dataset re-init; two misses = ~10 min wedged, unambiguous. Logging = the existing log/warn helpers; a oneshot's stdout/stderr lands in the journal — no logger needed. Never print ACCESS_TOKEN.
- Config/docs:
config.reference.json — add "watchdog": "disabled", "watchdog_interval_min": 5, "max_temp_c": "" next to autotune, and extend the _docs sentence. config.minimal.json untouched. docs/configuration.md + docs/operations.md: a short "Watchdog" section (what it checks, the 2-strike rule, the 5°C hysteresis, that max_temp_c needs a sane thermal_zone0). Coordinate with the config-key-lint issue (filed alongside): whichever lands second adds the three keys to parse_config's allowlist.
- Tests (tests/run.sh):
- envsubst stub (tests/run.sh:143-149) must learn
$WATCHDOG_INTERVAL_MIN — add one -e "s|\$WATCHDOG_INTERVAL_MIN|${WATCHDOG_INTERVAL_MIN:-}|g" clause, or every rendered timer keeps the literal $WATCHDOG_INTERVAL_MIN.
- Install/remove toggle: copy the
install_autotune block at tests/run.sh:1228-1262 wholesale (mktemp sandbox, copy both templates, subshell that sources $SCRIPT, sets OS_TYPE=Linux SCRIPT_DIR SYSTEMD_DIR REAL_USER WATCHDOG_MODE WATCHDOG_INTERVAL_MIN, runs install_watchdog with PATH="$STUBS:$PATH"); assert both units appear, the timer contains OnUnitActiveSec=7min for an override and 5min for the default, the service bakes RIGFORGE_OPERATOR and invokes rigforge.sh watchdog, and disabled removes both.
- Check logic: unit-test
watchdog() in a subshell with API_CMD='echo "{\"hashrate\":{\"total\":[0]}}"' (and API_CMD='false' for unreachable), TUNE_TEMP_CMD='echo 92', a temp WORKER_ROOT, and the systemctl stub; assert via CALL_LOG grep (the stubs already append every call, tests/run.sh:~155) that restart fires only on the second failed run, that stop + marker happen above max_temp_c, and that start happens once below max_temp_c - 5. Note systemctl is-active via the stub exits 0 (= active) — exactly what the wedged-miner scenario needs.
- Verification:
make lint && make test locally; on miner-0 (release e2e rig): enable the watchdog, kill -STOP the xmrig main thread's hashing (or firewall :8080) and watch two timer ticks restart it; set max_temp_c just below the current temp and watch the stop/marker/restart cycle; journalctl -u rigforge-watchdog.service shows every decision.
Pitfalls: bash 3.2 — no associative arrays, no ${var,,}; set -Eeuo pipefail — every cat/curl/systemctl that may fail needs || true/2>/dev/null handling like _read_api_hashrate does; temps are floats → compare with awk, not [ -gt ]; jq --arg for any JSON writes; never echo ACCESS_TOKEN; the fails/marker files live in $WORKER_ROOT which _reown_worker (rigforge.sh:960-970) re-owns — root-written state there is already handled; make lint = pinned shellcheck 0.11 + shfmt -i 4.
Related
Why
Restart=alwaysin the xmrig unit only covers a dead process. The failure mode it can't see: xmrig wedges — process alive, 0 H/s, HTTP API unresponsive — and systemd happily reportsactive (running)while the rig mines nothing until a human notices. On a fleet that runs 24/7 unattended, "notices" can be days.Second unattended gap: sustained thermal stress. #62 (closed) made tune-time measurements thermal-aware — reject throttled readings while picking a config; it deliberately did not touch steady-state runtime protection. A failed case fan in July shouldn't cook a rig because nothing watches temperature between tune runs.
Both checks are the same shape as the existing periodic-autotune machinery (#46/#95): a systemd timer firing a oneshot that runs a rigforge verb. Copy that pattern; invent nothing.
Scope
watchdog: one health check per invocation (the timer provides cadence). Logic:Restart=owns dead-process recovery; we own wedged-process recovery).max_temp_c - 5→ clear marker, start the service, log; else exit (stay stopped).max_temp_cset and temp above it → stop the service, write the marker, log, exit.127.0.0.1:8080/2/summary) unreachable or hashrate 0 → bump a consecutive-fail counter (state file in$WORKER_ROOT); at 2 consecutive fails →systemctl restart, reset counter, log. Healthy → reset counter.rigforge-watchdog.timer+ oneshot.servicetemplates, installed/removed by a newinstall_watchdog()copied frominstall_autotune()(rigforge.sh:772-796), wired intomain()(after line 996),apply()(rigforge.sh:2700-2706 — apply is the config-change path), anduninstall()(next to the autotune-timer removal, rigforge.sh:1106-1109).config.minimal.jsonstays minimal):watchdog="disabled"(default) |"enabled";watchdog_interval_min=5;max_temp_c=""(empty = no thermal cutoff).install_autotune([ "$OS_TYPE" == "Linux" ] || return 0) — macOS has no systemd and is the dev/light-use platform.Out of scope: notifications/alerting (a future issue can tail the journal), per-pool health, temperature tuning (that's #62's domain — this only stops/starts), configurable hysteresis (fixed 5°C — one less knob; revisit only if a real rig oscillates).
Acceptance criteria
"watchdog": "enabled"+ setup/apply installsrigforge-watchdog.timerfiring everywatchdog_interval_minminutes;"disabled"(or absent) removes both units cleanly;uninstallremoves them.max_temp_cset, temp above it stops the miner and leaves a marker; a later run belowmax_temp_c - 5starts it again exactly once.max_temp_cempty/absent = thermal logic fully skipped.parse_config(typo ≠ silently disabled), matching theautotunetri-state precedent at rigforge.sh:363-368.make lint+make testpass (macOS bash 3.2 and Linux).config.reference.json,docs/configuration.md,docs/operations.md;usage()gets the verb.Implementation notes
Numbered, mechanical. All design decisions are made — do not reopen them.
watchdog: copy theautotunecase-normalization shape (rigforge.sh:363-368):disabled|false|off|none|null|"" → WATCHDOG_MODE=disabled,enabled|true|on → enabled,*) error(typo must not silently disable a recovery mechanism).WATCHDOG_INTERVAL_MIN=$(jq -r '.watchdog_interval_min // 5' ...); validate integer 1-1440 with the[[ =~ ^[0-9]+$ ]]pattern used for DONATION (rigforge.sh:288).MAX_TEMP_C=$(jq -r '.max_temp_c // empty' ...); empty allowed; else integer 40-110 (below 40 you'd never restart; above 110 is meaningless). Default empty because a wrong cutoff on unknown sensors (thermal_zone0 varies by board) is worse than no cutoff — it's opt-in for operators who've checked their zone.systemd/, copied from the autotune pair:rigforge-watchdog.service.template:[Unit] Description=RigForge miner watchdog check,After=$SERVICE_NAME.service;[Service] Type=oneshot,Environment=RIGFORGE_OPERATOR=$RIGFORGE_OPERATOR(same re-own rationale as the autotune unit),ExecStart=$SCRIPT_DIR/rigforge.sh watchdog. Do NOT bake ACCESS_TOKEN or max_temp_c into the unit — the verb re-reads config.json viaparse_config, soapplyafter a config edit needs no unit rewrite (only the interval lives in the timer).rigforge-watchdog.timer.template:[Timer] OnBootSec=${WATCHDOG_INTERVAL_MIN}min,OnUnitActiveSec=${WATCHDOG_INTERVAL_MIN}min,[Install] WantedBy=timers.target. Interval semantics ("every N minutes"), soOnUnitActiveSecnotOnCalendar— the autotune timer's calendar cadence doesn't fit minutes.install_watchdog()directly belowinstall_autotune()(after rigforge.sh:796): literal copy of its shape — Linux guard, disabled →systemctl disable --now+rm -fboth units +daemon-reload(idempotent, only if present), enabled → envsubst-render both templates ('$SERVICE_NAME $RIGFORGE_OPERATOR $SCRIPT_DIR'for the service,'$WATCHDOG_INTERVAL_MIN'for the timer) viasudo tee,daemon-reload,enable --now.main()— addCURRENT_STEP="configuring the watchdog"; install_watchdogafter theinstall_autotunecall (rigforge.sh:995-996).apply()— addinstall_watchdog >/dev/null 2>&1 || truebesideinstall_autotune(rigforge.sh:2703).uninstall()— add the watchdog pair to step 1 next to the autotune-timer block (rigforge.sh:1106-1109). Dispatcher (bottomcase, rigforge.sh:~3087):watchdog) watchdog ;;.usage(): one line under "Day to day" or "Tuning" ("watchdog — one health check: restart a wedged miner, enforce max_temp_c; scheduled via watchdog:"enabled"").watchdog()verb (place nearapply()/bench()):parse_configfirst (gets WORKER_ROOT, ACCESS_TOKEN, MAX_TEMP_C). State:$WORKER_ROOT/watchdog.failsholding a bare integer (plain file, not JSON — bash 3.2, no parsing needed), thermal marker$WORKER_ROOT/watchdog.thermal-hold. Steps:systemctl is-active --quiet "$SERVICE_NAME"false and no thermal marker →rm -fthe fails file,log, return 0._read_temp(rigforge.sh:1290-1299) verbatim — it already handles the thermal-zone read,THERMAL_ZONEoverride, and theTUNE_TEMP_CMDtest override. TUNE_TEMP_CMD stays env-only; never read a command string from config.json and eval it (config is operator-writable without root; eval'ing it is privilege escalation).MAX_TEMP_Cset and temp non-empty andawksaystemp < MAX_TEMP_C - 5→rm -fmarker,systemctl start,log "watchdog: temp ${t}C below $((MAX_TEMP_C-5))C — restarting the miner."; else return 0. Hysteresis fixed at 5°C: big enough to outlast the post-restart heat-up on every rig we run (7800X3D/EPYC), small enough to not strand the miner; not configurable (one less knob to typo).MAX_TEMP_Cset, temp non-empty,temp > MAX_TEMP_C(awk — temps are floats) →systemctl stop,touchmarker,warn, return 0. Unreadable temp = skip thermal (a missing sensor must not stop a healthy miner)._read_api_hashrate(rigforge.sh:2328-2343) verbatim — it already handles the ACCESS_TOKEN Bearer branch, the no-token default, and theAPI_CMDtest override. Empty (unreachable) orawk-zero → increment the fails file (f=$(cat ... 2>/dev/null || echo 0));f >= 2→systemctl restart, reset file to 0,warn "watchdog: miner wedged (0 H/s / API dead twice) — restarted."; else write the bumped count andlog. Healthy → reset to 0. Two consecutive fails, decided: one 5-min sample can be a restart-in-progress or dataset re-init; two misses = ~10 min wedged, unambiguous. Logging = the existinglog/warnhelpers; a oneshot's stdout/stderr lands in the journal — nologgerneeded. Never print ACCESS_TOKEN.config.reference.json— add"watchdog": "disabled","watchdog_interval_min": 5,"max_temp_c": ""next toautotune, and extend the_docssentence.config.minimal.jsonuntouched.docs/configuration.md+docs/operations.md: a short "Watchdog" section (what it checks, the 2-strike rule, the 5°C hysteresis, thatmax_temp_cneeds a sane thermal_zone0). Coordinate with the config-key-lint issue (filed alongside): whichever lands second adds the three keys to parse_config's allowlist.$WATCHDOG_INTERVAL_MIN— add one-e "s|\$WATCHDOG_INTERVAL_MIN|${WATCHDOG_INTERVAL_MIN:-}|g"clause, or every rendered timer keeps the literal$WATCHDOG_INTERVAL_MIN.install_autotuneblock at tests/run.sh:1228-1262 wholesale (mktemp sandbox, copy both templates, subshell that sources$SCRIPT, setsOS_TYPE=Linux SCRIPT_DIR SYSTEMD_DIR REAL_USER WATCHDOG_MODE WATCHDOG_INTERVAL_MIN, runsinstall_watchdogwithPATH="$STUBS:$PATH"); assert both units appear, the timer containsOnUnitActiveSec=7minfor an override and5minfor the default, the service bakesRIGFORGE_OPERATORand invokesrigforge.sh watchdog, anddisabledremoves both.watchdog()in a subshell withAPI_CMD='echo "{\"hashrate\":{\"total\":[0]}}"'(andAPI_CMD='false'for unreachable),TUNE_TEMP_CMD='echo 92', a temp WORKER_ROOT, and the systemctl stub; assert viaCALL_LOGgrep (the stubs already append every call, tests/run.sh:~155) that restart fires only on the second failed run, that stop + marker happen abovemax_temp_c, and that start happens once belowmax_temp_c - 5. Notesystemctl is-activevia the stub exits 0 (= active) — exactly what the wedged-miner scenario needs.make lint && make testlocally; on miner-0 (release e2e rig): enable the watchdog,kill -STOPthe xmrig main thread's hashing (or firewall :8080) and watch two timer ticks restart it; setmax_temp_cjust below the current temp and watch the stop/marker/restart cycle;journalctl -u rigforge-watchdog.serviceshows every decision.Pitfalls: bash 3.2 — no associative arrays, no
${var,,};set -Eeuo pipefail— everycat/curl/systemctlthat may fail needs|| true/2>/dev/nullhandling like_read_api_hashratedoes; temps are floats → compare withawk, not[ -gt ];jq --argfor any JSON writes; never echo ACCESS_TOKEN; the fails/marker files live in$WORKER_ROOTwhich_reown_worker(rigforge.sh:960-970) re-owns — root-written state there is already handled;make lint= pinned shellcheck 0.11 +shfmt -i 4.Related
install_autotune, rigforge.sh:772-796).:8080API contract the health probe reuses (_read_api_hashrate, rigforge.sh:2328-2343).