# Daemon Spec (`harnessd`) — run ownership, state machine, decision inbox

Audience: AI coding agents first. Canonical for A1 (daemon), A5 (decision inbox), W10 (trust ramp), X1/X4/X6 (queues + windows), W5 (status cache), W9 (actionable notifications). Plan context: `docs/plans/2026-07-07-design-gap-handoff.md`. Builds ON `src/supervisor.js` (per-run socket) and `src/control-api.js` (HTTP + watchers) — extend, do not replace.

## Invariant: stateless daemon (I8)

ALL daemon state re-derivable at startup from: journals (`runstate/*.db`, JOURNAL-V2), liveness sidecars, lock files, run-registry pointers (`~/.harness/runs/*.json`), config (X10 merge). In-memory maps are caches. Daemon crash + restart MUST converge to identical behavior — chaos scenario C5 proves it.

## Run state machine

States (daemon-level; journal is authority, daemon derives):

```
queued-on-deps ──deps landed──▶ queued-on-window ──window open──▶ preflight
preflight ──clean──▶ launching ──runner spawned──▶ running
preflight ──dirty──▶ blocked-on-decision (unattended) | REFUSED (CLI, exit non-zero)
running ──runner exit 0──▶ landing ──ship ok──▶ reporting ──▶ done
running ──runner exit 4──▶ resolving (accept-quarantine path) ──▶ landing
running ──runner exit ≠0──▶ resolving
running ──watchdog breach──▶ resolving (daemon kills agent pgid first)
running ──pid dead, no exit record──▶ resolving (crash path)
resolving ──action resume / redo-task / env-repair:<id> / reroute──▶ launching (backoff first)
resolving ──park-retry──▶ quota-parked ──retryAt──▶ launching
resolving ──escalate──▶ blocked-on-decision
blocked-on-decision ──answer──▶ launching | done(aborted)
running ──budget run-ceiling──▶ paused ──decision──▶ running | done(aborted)
landing ──ship fail──▶ resolving (failClass land-failed)
autonomyLevel 1: running ──wave boundary──▶ blocked-on-decision (kind wave-gate) ──proceed──▶ running
```

Every transition journals a record (existing kinds where they exist; new: `plan.queued {on: "deps"|"window"}`, `plan.released {by}`, `run.paused {reason}`, `run.resumed`, `decision.requested`, `decision.answered`). No transition without its journal append succeeding — journal first, then act (write-ahead, per `spec/JOURNAL-V2.md` intent rules where side effects follow).

State names above are DERIVED labels — do not store a redundant "state" field that can disagree with the journal; recompute from last relevant records.

## Tick loop (exact order, single-threaded per tick, default 15s interval)

1. Reap: liveness sweep (`src/state/liveness.js` reap) + orphan intent sweep for runs owned by this daemon.
2. Watchdog: per active task, check state age vs per-state budgets + agent idle timeout (config). Breach → SIGTERM pgid → 10s grace → SIGKILL → classify `task-stalled`/`agent-idle` → resolving. Verify pid + start-time identity BEFORE any signal (I5) — mismatch = already dead, treat as crash path.
3. Budgets: sum `task.usage` per active run; enforce V2 ceilings (task → quarantine class `budget-exceeded`; run → paused + decision).
4. Disk floor: below floor → pause new dispatches, journal, notify (continuous version of pre-dispatch check).
5. Parked/queued promotion: `quota-parked` past `retryAt`; `queued-on-deps` whose deps landed; `queued-on-window` whose window opened → launching.
6. Decision inbox: answered decisions → apply + transition.
7. Status cache: rewrite `~/.harness/status-cache.json` if anything changed (W5; also rewritten on every journal event via watcher).
8. Maintenance window (X4): open + zero active user runs → advance housekeeping ladder ONE step.

Ticks MUST be idempotent — running the same tick twice = no double effects (side effects intent-logged or naturally idempotent).

## Processes + systemd

- Daemon: `bin/harnessd` (Node). Unit `harnessd.service` (user): `Restart=on-failure`, `RestartSec=10`, `StartLimitIntervalSec=60`, `StartLimitBurst=5`, `WantedBy=default.target`. The start-limit bounds crash+restart convergence (C5) to 5 attempts/60s — a persistently dead backend fails the unit instead of hot-looping restarts forever.
- Timers: `harnessd-daily.timer` → digest rollup + W12 backup + gc dry-run report.
- Agents spawn via `systemd-run --user --scope` with CPU/memory caps from config (I6); fallback plain `setsid` + ulimit when systemd-run unavailable — capability probed once at startup, journaled.
- Runner is spawned BY the daemon in unattended mode (`runplan <slug> --unattended` = client: validates, then hands to daemon over control socket; foreground mode unchanged, daemon not involved).

## Sockets + API

- Keep per-run supervisor socket + verbs (`register/pause/resume/kill/steer`) as-is; daemon adds a control socket `~/.harness/harnessd.sock` with verbs: `submit {slug, flags}`, `list`, `answer {slug, decisionId, choice}`, `status {slug}`, `shutdown {drain: bool}`.
- Control-api (HTTP) proxies `answer` + `status` for W9 phone actions. Bind rules: localhost or Tailscale interface ONLY. NEVER 0.0.0.0. Token auth ALWAYS — NEW work (P3), `src/control-api.js` has no auth today: token generated at daemon first start into `~/.harness/token` (mode 0600), required as `Authorization: Bearer <token>` on EVERY control-api request, compared constant-time (`crypto.timingSafeEqual`), 401 without it. No token file → daemon generates one; never a default/empty token accepted.

## Decision inbox (A5)

Record: `decision.requested {id, scope: "run"|taskId, kind, reason, options: [{value, label}], default?: {value, afterSec}, links: {handoff, snapshot?}}`.

- `id`: `d-<seq>` of the requesting record — stable, replay-safe.
- Answer paths (all converge): CLI `runplan answer`, control-api POST, ntfy action button (W9). First answer wins; journal `decision.answered {id, choice, by: "user"|"default", via}`. Second answer → 409 + recorded answer, NO re-apply (idempotent, W9).
- `default` present + `afterSec` elapsed + autonomyLevel ≥2 → daemon answers itself with `by: "default"`. Level ≤1 → defaults NEVER auto-fire.
- Dependents of an unanswered decision wait `blocked-on-decision`; independent tasks/runs continue.
- Every escalation ALSO writes handoff bundle (X11) BEFORE notification sends (notification links it).

## Notifications (W9, I11)

- Channel config (X10): ntfy topic URL + token, or generic webhook. Send failures journal `notify.failed` + retry next tick ×3 → give up (decision stays in inbox — channel loss = latency loss, never decision loss).
- ntfy actions: one `http` action per option, POST `{decisionId, choice}` to control-api answer endpoint with auth header.
- Digest (V1): per-run on terminal + daily rollup; content per plan PART VI V1.

## Trust ramp (W10)

Launch gate in `submit`: requested effective mode vs repo `autonomyLevel` (X10 config). `requested > allowed` → refuse, message includes live V4 stats + threshold table. Level definitions per plan PART VII W10. Budget ceilings are optional at every level — when set, daemon tick enforces them; absence never blocks submit. Daemon NEVER edits `autonomyLevel` (recommend-only; promotion = human config edit).

## Re-attach after daemon restart

1. Read run-registry pointers → for each non-terminal journal: acquire/verify lock ownership.
2. Runner pid alive (pid + start-time) → adopt as `running` (re-arm watchdog from liveness timestamps).
3. Pid dead → crash path → resolving.
4. Sweep intents (JOURNAL-V2 rules). 5. Rebuild queues from `plan.queued` records. 6. Resume tick loop.

## Tests

1. State machine: table-driven transition tests (every edge above; illegal transition asserts).
2. Tick idempotence: run tick twice on frozen fixtures → identical journal.
3. Re-attach: kill daemon in EVERY state → restart converges (chaos C5).
4. Decision: double-answer 409; default fires only at level ≥2; blocked dependents wait, siblings proceed.
5. Watchdog: pid-reuse simulation → no signal sent to recycled pid.
6. Notification failure → `notify.failed`, decision still answerable via CLI.
