hyperhive/docs/forge.md
iris 76d3267b55 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.
2026-05-31 17:17:24 +02:00

8.6 KiB

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; 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 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: APPROVEDapproved, REQUEST_CHANGESchanges requested, COMMENTreview 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.