hive-ag3nt + docs: extract forge_notify prose (#716 batch 4)
forge_notify.rs is the biggest remaining #716 hotspot: ~22 attribution cookies (#110 #230 #231 #253 #256 #455 #518 #539 #544) plus a thick module-level docstring + per-function rationale blocks for the notification pipeline. The substantive prose lives in a new `docs/forge.md` covering the wider forge integration story: - Per-agent forge accounts + agent-configs mirror (was implicit across `hive-c0re/src/forge.rs` rustdocs). - Notification poller: activation gates, self-notification filtering, body excerpt + truncation + ATX heading escape pipeline, wrapper formats (comment / review / new-item / state-change) with shape table, meta suffix shape, review-request override, reason drop-list rationale (drop vs allow), auto-unsubscribe on broad watches. In-code rustdocs reduced to 1-line semantic summaries + doc pointers; inline cookie comments scrubbed. Net diff is ~150 lines removed from the .rs file. All 16 forge_notify unit tests pass. CLAUDE.md gets a new `docs/forge.md` file-map entry + reading-path question entry. The existing `src/forge_notify.rs` file-map blurb keeps its `(#539 / #544)` cookie — consistent with the rest of CLAUDE.md's lineage attributions.
This commit is contained in:
parent
4f9697220e
commit
76d3267b55
3 changed files with 277 additions and 149 deletions
191
docs/forge.md
Normal file
191
docs/forge.md
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
# hive-forge
|
||||
|
||||
Private Forgejo instance running in a nixos-container, used as the
|
||||
swarm's persistent code-collaboration surface (issues, PRs, reviews,
|
||||
attachments). Configured via `services.hyperhive.forge.*`. Container
|
||||
shape, ROOT_URL / sub-domain routing, and operator-vs-in-cluster URL
|
||||
handling live in [`docs/gateway.md`](gateway.md); this file owns the
|
||||
per-agent integration story and the notification pump that wakes
|
||||
each agent on relevant activity.
|
||||
|
||||
## Per-agent forge accounts
|
||||
|
||||
Each agent gets its own Forgejo user + access token, provisioned at
|
||||
boot by `hive-c0re::forge`. The provisioning flow is idempotent:
|
||||
existing accounts + tokens are reused, so container destroy/recreate
|
||||
doesn't lose forge identity. The token is written to
|
||||
`<state>/forge-token` (one line, no trailing newline) inside the
|
||||
agent container so `hive-forge` CLI + `forge_notify` poller can
|
||||
read it without touching c0re's host-side credential store.
|
||||
|
||||
Two things live in the `agent-configs` Forgejo organization:
|
||||
|
||||
- A mirror repo per agent (`agent-configs/<name>`) — c0re pushes the
|
||||
agent's applied config repo on each `↻ R3BU1LD`. Agents are
|
||||
read-only collaborators on `core/meta` (the hive-c0re-owned meta
|
||||
flake) so they can fetch but never push.
|
||||
- The dashboard links each container's "config" anchor to this
|
||||
mirror, so operators can click straight from the SW4RM tab into
|
||||
the rendered repo without an extra `git` step.
|
||||
|
||||
The `hive-forge` CLI (separate workspace crate, see
|
||||
[`README.md`](../README.md) file map) wraps the Forgejo REST API
|
||||
with the per-agent token; agents call it for issue / PR / comment
|
||||
ops as if it were a peer.
|
||||
|
||||
## Notification poller (`hive-ag3nt/src/forge_notify.rs`)
|
||||
|
||||
Background task spawned once per harness boot. Polls
|
||||
`GET /api/v1/notifications?all=false` every 30 seconds (Forgejo's
|
||||
unread-only filter), formats each notification as a broker
|
||||
`Wake { from: "forge" }` message, and delivers it to the agent's own
|
||||
inbox so claude's normal turn loop picks it up. Mark-read happens
|
||||
after successful delivery so a failed-delivery notification
|
||||
resurfaces on the next tick.
|
||||
|
||||
### Activation gates (graceful no-ops)
|
||||
|
||||
The poller starts disabled and stays that way for any of:
|
||||
|
||||
- `HIVE_FORGE_URL` not set (no forge configured for this hive).
|
||||
- `<state>/forge-token` missing or empty (agent has no forge
|
||||
account — pre-provisioning or destroy-without-purge race).
|
||||
- Initial `reqwest::Client::builder` fails (extremely unlikely;
|
||||
treated as fatal-to-the-task only).
|
||||
|
||||
Disabled = the spawned task returns immediately. All other failure
|
||||
modes (HTTP errors, parse errors, mark-read failures) are
|
||||
best-effort: logged at debug/warn and retried next tick.
|
||||
|
||||
### Self-notification filtering
|
||||
|
||||
Forgejo fires notifications for the agent's own actions (it opened a
|
||||
PR, posted a comment, submitted a review). Surfacing those would
|
||||
loop claude on its own writes. Two filter rules drop them silently
|
||||
(mark-read without delivery):
|
||||
|
||||
- **Self-authored new items** — notifications with
|
||||
`reason == "author"` AND subject state `open` (or missing). State
|
||||
transitions (merge / close) on the agent's own PRs DO surface,
|
||||
since those are triggered by someone else.
|
||||
- **Self-authored comments / reviews** — comment payload's
|
||||
`user.login` matches `own_login`.
|
||||
|
||||
`own_login` is fetched once at startup via `GET /api/v1/user`. On
|
||||
fetch failure the filter degrades open (no filtering) rather than
|
||||
crashing the task — a noisy inbox beats a silently-stuck poller.
|
||||
|
||||
### Body excerpt + truncation + heading escape
|
||||
|
||||
The wake message embeds the comment / review / new-item body so the
|
||||
agent sees actual content without a follow-up fetch. Three pipeline
|
||||
steps in order:
|
||||
|
||||
1. **Truncate** to `BODY_TRUNCATE = 500` chars at a char-boundary;
|
||||
appends `…` when cut. Truncation happens BEFORE escape so the
|
||||
mention-overflow diff (next step) compares like-for-like against
|
||||
the raw body.
|
||||
2. **Mention overflow extraction** — when truncation actually
|
||||
trimmed content, walk the full body line-by-line and surface any
|
||||
`@username` lines that fell outside the embed window. Rendered as
|
||||
a trailing `mentions (truncated from body):\n > <line>` block.
|
||||
Mention detection requires the `@` to be at line start or
|
||||
following a non-username byte, so email-style `foo@bar.com` does
|
||||
NOT count.
|
||||
3. **ATX heading escape** — for each line that's a strict
|
||||
`CommonMark` ATX heading (1-6 leading `#`s followed by a space,
|
||||
tab, or end-of-line), prepend `\` so the embedded body doesn't
|
||||
blow into a top-level h1/h2 inside the wrapper message when the
|
||||
dashboard renders it. Lines like `#tag`, `#123`, `#!/bin/bash`
|
||||
are NOT headings — no escape, no cosmetic noise. Indented
|
||||
"headings" inside lists / nested quotes keep their leading
|
||||
whitespace.
|
||||
|
||||
The strict ATX rule is deliberate: `\#tag` and `#tag` render
|
||||
identically, so an over-eager escape just adds visual clutter
|
||||
without changing behavior. Setext-style headings (`title\n====`)
|
||||
are not handled — rarer in practice, would need multi-line
|
||||
lookahead.
|
||||
|
||||
### Wrapper format
|
||||
|
||||
Four shapes, distinguished by the notification's classification:
|
||||
|
||||
| Trigger | Wrapper |
|
||||
| --- | --- |
|
||||
| Comment on issue / PR | `[comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...\nreason: mention` |
|
||||
| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nreviewer: body\nassignee: ...\nreason: review_requested` |
|
||||
| New issue / PR | `[new PR #N owner/repo] title\nurl: ...\n\n<body excerpt>\nassignee: ...\nreason: subscribed` |
|
||||
| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...\nreason: subscribed` |
|
||||
|
||||
Review labels come from the Forgejo `state` field: `APPROVED` →
|
||||
`approved`, `REQUEST_CHANGES` → `changes requested`, `COMMENT` →
|
||||
`review comment`. `PENDING` is dropped (review saved but not
|
||||
submitted yet — no peer-visible event). Unknown states fall back to
|
||||
the generic comment wrapper.
|
||||
|
||||
Number is extracted from `subject.html_url`'s last path segment
|
||||
(strips `#anchor` first); repo slug from `repository.full_name`.
|
||||
Both degrade gracefully when absent (number → blank, repo → blank)
|
||||
so unexpected Forgejo shapes don't crash the formatter.
|
||||
|
||||
### Meta suffix
|
||||
|
||||
Every wrapper ends with one or more of:
|
||||
|
||||
- `assignee: <list>` — always present; `unassigned` when empty so
|
||||
the line shape is stable.
|
||||
- `reviewer: <list>` — PR notifications only, present only when
|
||||
`requested_reviewers` is non-empty.
|
||||
- `reason: <forgejo-reason>` — always present when the notification
|
||||
carries a reason; absent when the field is null/missing.
|
||||
|
||||
The `reason` line distinguishes otherwise-identical messages: Forgejo
|
||||
emits one notification per applicable reason for the same event
|
||||
(e.g. both `mention` and `subscribed` arrive for a PR comment that
|
||||
tags the agent). Without the suffix, the agent would see duplicated
|
||||
wrapper text with no signal which Forgejo path triggered each copy.
|
||||
|
||||
### Review-request override
|
||||
|
||||
For new PRs, the kind label flips to `[review requested #N
|
||||
owner/repo]` when `own_login` appears in `requested_reviewers`,
|
||||
regardless of the Forgejo `reason` field. Forgejo doesn't reliably
|
||||
set `reason == "review_requested"` (often null instead), so the
|
||||
fallback checks the subject payload directly. Detection is gated on
|
||||
`is_new` so the label only fires once on PR creation, not on every
|
||||
subsequent comment.
|
||||
|
||||
### Reason drop-list
|
||||
|
||||
`HIVE_FORGE_NOTIFY_SKIP_REASONS` (comma-separated) suppresses
|
||||
notifications whose Forgejo `reason` matches an entry. Marked-read
|
||||
silently, no delivery. Drop-list is intentionally chosen over an
|
||||
allow-list:
|
||||
|
||||
- Allow-list would silently miss any directed signal Forgejo adds
|
||||
later (`review_requested`, `mention`, future kinds).
|
||||
- Drop-list explicitly identifies the noisy paths
|
||||
(`subscribed`, `participating`) and lets unknown / null reasons
|
||||
pass through.
|
||||
|
||||
Configured per-agent via `hyperhive.forge.skipNotifyReasons` in
|
||||
`agent.nix`. Default is empty (deliver everything).
|
||||
|
||||
### Auto-unsubscribe on broad watches
|
||||
|
||||
Default behavior: after delivering a `reason == "subscribed"`
|
||||
notification, `DELETE /api/v1/repos/<owner>/<repo>/subscription` is
|
||||
called to drop the agent's broad-watch on that repo. The agent
|
||||
remains subscribed to specific issues/PRs it interacts with, but
|
||||
stops receiving the firehose of every commit / new issue.
|
||||
|
||||
`HIVE_FORGE_KEEP_SUBSCRIPTIONS=1` disables this — triage agents and
|
||||
other firehose consumers need to keep watching every repo activity.
|
||||
Set via `hyperhive.forge.keepSubscriptions = true` in `agent.nix`.
|
||||
|
||||
The auto-unsub set is process-local (a `HashSet<String>` keyed by
|
||||
`owner/repo`), so a single repo only gets one DELETE per harness
|
||||
boot. After harness restart the agent might re-watch the repo via
|
||||
some other path; the next `subscribed` notification re-triggers the
|
||||
unsubscribe.
|
||||
Loading…
Reference in a new issue