ADR 0018 — Liveness must live outside the process it watches
Context
karani ran nothing for eight days and said nothing about it.
On 2026-09-04 a routine disk cleanup (find ~/Mirror -type d -name .venv | xargs rm -rf) deleted the project virtualenv. Both launchd
agents hardcode .venv/bin/karani, so every hourly tick exited 127
(“command not found”) straight into a log file nobody reads. The
outage surfaced only when the operator noticed no Slack traffic.
ADR 0016 anticipated a dead scheduler and added heartbeat_alert():
if the newest hourly ledger row is older than three hours, post to
Slack. It never fired, and could not have. The heartbeat runs inside
karani notify, launched by the daily agent — which executes the
same .venv/bin/karani binary as the hourly hunt. One deleted
directory took out the watcher and the watched together.
That is a common-mode failure: two components that look independent (separate launchd jobs, separate schedules, separate code paths) share a hidden dependency, so a single fault disables both. Splitting the schedule was never enough; the runtime was still shared.
Decision
Liveness checking moves out of the Python process entirely.
-
A completed pass timestamps a marker file (
logs/.last-pass, overridable viaKARANI_PASS_MARKER). Written after the graph returns, inrun_hunt_once, and never allowed to raise — same rule as the run ledger. It answers exactly one question: did a pass finish? Row-level failures stay the report node’s job (ADR 0016). -
com.karani.watchdogreads that marker and nothing else. It is POSIXshpluscurl, installed to~/.karani/watchdog.sh, outside the repository. It imports no karani code, activates no virtualenv, opens no database connection, and runs under/bin/sh— an interpreter that ships with the OS. If the marker is missing or older thanKARANI_WATCHDOG_MAX_AGE_H(default 4), it posts to Slack directly over the web API. A test asserts no executable line in the script references.venv,uv run,python, or thekaranibinary; that assertion is the ADR. -
The scheduler no longer runs from the repo’s
.venv.karani huntbakes in whateverkaraniresolves on PATH, so the supported install isuv tool install --editable, whose environment lives in~/.local/share/uv/tools/— not a directory that cleanup scripts target by name. -
Every path baked into a launchd job is absolute. launchd supplies no working directory, so the relative
logsthat repo mode yields made it refuse to spawn the job (EX_CONFIG, exit 78) and pointed the marker somewhere unresolvable. Caught by a test that forces repo mode; the installed layout was already absolute and hid the bug.
The in-process heartbeat stays. It catches what it is good at — the hourly job unloaded, crashing on startup, or wedged while the rest of karani still works — and it costs nothing.
Consequences
- The alarm survives deletion of the venv, a broken interpreter, an unparseable config, and a dependency that fails to import. It does not survive the Mac being off, the Slack token being revoked, or launchd itself being unloaded. Those need an off-box check (healthchecks.io or similar); deliberately not taken on yet, because it adds an external dependency and an account to the setup.
- Two alerts can fire for one outage (in-process heartbeat at 3h, watchdog at 4h). Duplicate warnings are cheap; a missed outage cost eight days.
- The watchdog holds a copy of the Slack channel id and reads the bot
token from
.envat run time. The token is never written into the script — same rule as ADR 0015 (secrets in env, never in config). uv tool install --editabletracks source edits, but a dependency change needs a reinstall. Documented indocs/operations.md.