refactor(hive-agent): split the forge notification poller into its own crate

The poller was a `tokio::spawn` inside the `hive-agent` serve loop. It
never needed anything from that loop except a socket path, so being
in-process bought nothing and cost two things: a harness restart took
forge notifications down with it, and the whole forge/HTTP dependency
tree was linked into the serve-loop binary.

It is now `hive-forge-notify`, a per-agent daemon with its own systemd
unit, a sibling of `hive-bash-daemon` and `hive-matrix-daemon`. Same
contract as those two: it reaches the harness only by upserting todos on
the in-agent socket, and nowhere else.

The module moves verbatim (`notify.rs`) — the formatters, the activation
gates, the dedupe map and all 33 tests are unchanged. Only the socket
call sites are rewritten, onto a small local `todo_client` rather than
the harness's. That mirrors what both sibling daemons already do, and
the etiquette differs on purpose: the harness's client carries a 60s
backoff schedule sized to ride out a hive-c0re restart, which its
callers need because they have no retry of their own. This poller's two
call sites both sit inside the 30s poll loop and both treat a failure as
"leave the thread unread, try next tick", so the poll interval already
is the retry; a second backoff would only stack sleeps and delay the
rest of the batch.

The unit is `Restart=on-failure`, not `always`. An agent with no forge
account is a supported configuration and the poller reports it by
logging why and exiting 0 — under `always` that clean exit would be a
restart loop on every forge-less agent.

`forgejo-api`, `url` and `time` drop out of `hive-agent`'s dependencies
with the module.

Also corrects docs that outlived the code they described: the persisted
`forge_cursor` field is long gone (forge's own read-state is the durable
record of what has been delivered), but `docs/persistence.md` and the
`harness_state` module docs still documented it as live.
This commit is contained in:
atlas 2026-07-26 20:44:54 +02:00 committed by mara
commit 246c9471b1
18 changed files with 301 additions and 48 deletions

View file

@ -142,7 +142,7 @@ Consolidated harness state file written atomically (`.tmp` + rename) by
Shape:
```json
{ "rate_limited": false, "needs_login": false, "active_model": "…", "forge_cursor": { "42": "2026-07-01T18:00:00Z" } }
{ "rate_limited": false, "needs_login": false, "active_model": "…" }
```
- `rate_limited` — set when the harness detects a 429 from the Claude
@ -152,16 +152,16 @@ Shape:
cleared by `"online"` status (re-auth completed). Drives the
`needs_login` flag alongside the `claude_has_session` check.
- `active_model` — the resolved Claude model for the dashboard badge.
- `forge_cursor` — the `forge_notify` delivery-dedupe cursor
(notification thread id → last-delivered `updated_at`), so a
rebuild/restart doesn't re-deliver the whole currently-unread forge
backlog. See [`forge.md`](forge.md).
Multiple harness tasks write this file (the turn loop for the first
three fields, the `forge_notify` poller for `forge_cursor`), so every
writer goes read-modify-write under a shared in-process lock — each
preserves the fields it doesn't own rather than reconstructing the file
from scratch.
The turn loop is the only writer today, but it still goes
read-modify-write under a shared in-process lock and merges into the
existing object rather than reconstructing it — so a second writer
preserves fields it doesn't own, and the lock closes the lost-update
window between a writer's read and its rename. (The forge notification
poller used to be that second writer, for a delivery-dedupe cursor. It
persists nothing now — forge's own read-state is the durable record of
what has been delivered — and it is a separate process, which an
in-process lock could not have serialised anyway.)
hive-c0re reads this file on each `build_all` sweep (~10s) via
`container_view::read_harness_flags`. Falls back to the legacy individual
@ -258,7 +258,7 @@ Under `/var/lib/hyperhive/agents/<name>/`:
hourly and deletes terminal task trios older than 48 hours;
non-terminal (still-running) tasks are never deleted by vacuum.
- `hyperhive-todos.sqlite` — loose-ends-v2 todo store. In-container
MCP daemons (`hive-bash-daemon`, `hive-matrix-daemon`) and `forge_notify`
daemons (`hive-bash-daemon`, `hive-matrix-daemon`, `hive-forge-notify`)
upsert keyed todos here over the harness's in-agent socket
(`HIVE_AGENT_SOCKET`); the harness merges them into `get_loose_ends`
output and clears a row on `mark_todo_done`. Replaced the old