hyperhive/docs/forge.md

12 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.

Token scopes

Two scope sets live in hive-c0re::forge:

TOKEN_SCOPES (per-agent tokens):

Scope Why
write:repository Create, clone, push, delete repos; merge PRs.
write:issue Open / comment / review issues and pull requests (Forgejo namespaces PR conversation under issues).
write:user Edit own profile, create repos under own user.
write:organization Create + manage orgs (lets agents share a forge namespace).
read:user Token-owner endpoint used for self-identification at harness startup.
write:misc Hooks, attachments, the rest of the long tail.
read:notification Poll GET /notifications for unread events.
write:notification Mark notifications read via PATCH /notifications/threads/{id}.

CORE_TOKEN_SCOPES (hive-c0re's own core user): everything in TOKEN_SCOPES plus read:admin and write:admin. Site-admin membership alone isn't sufficient — Forgejo's token scope gate runs before the user-permission check, so /api/v1/admin/* returns 403 Forbidden for any token without the admin scope bits, even when the bearer is a site admin.

Migration note: if PATCH /api/v1/admin/users/{name} returns 403 on an existing deploy, the core token predates the admin-scope addition. Delete /var/lib/hyperhive/forge-core-token and restart hive-c0re to re-mint with the new scopes.


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 on read, not on delivery

Delivered conversation threads are deliberately left unread in forge. The hive-forge read-before-comment guard keys off forge's own notification read-state (GET /notifications?all=false) to refuse a comment when a thread has unread activity by others — so the agent reading the thread via the CLI (hive-forge comments / view, which PATCHes /notifications/threads/{id}) is the single mark-read point. If forge_notify marked threads read on delivery, that unread signal would be consumed before the agent acts and the guard could never fire.

Because a delivered thread stays unread, it reappears in every ?all=false poll. An in-memory delivery-dedupe cursor (thread id → last-delivered updated_at, held in the poll loop) stops the same version from re-firing a wake; a new comment bumps updated_at so genuinely new activity re-delivers. The cursor is pure anti-spam, not a correctness oracle: lost on harness restart it just re-delivers currently-unread threads once (harmless — recv tolerates redelivery), so it carries none of the persisted-mirror fragility that ruled out an on-disk seen-cursor. Each poll prunes the cursor to the threads still in the unread set. A failed wake delivery is left unread and out of the cursor, so it resurfaces next tick.

Two paths still mark-read directly (no read-before-comment value): self-echo notifications (the agent's own writes, see below) and HIVE_FORGE_NOTIFY_SKIP_REASONS drop-listed reasons.

Note: the unread list grows for threads the agent never reads via the CLI, since nothing else trims it. This does not affect guard correctness (the guard does a per-thread, repo-scoped query) nor wake delivery (Forgejo orders unread newest-first, so new activity always lands in the polled window). Bounding the unread list via a reason-independent firehose-reduction is a separate follow-up — the existing auto-unsubscribe below is gated on a reason field that this Forgejo's notification API does not actually emit, so it never fires today.

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. The comment/review case is dropped silently (mark-read without delivery):

  • Self-authored comments / reviews — comment payload's user.login matches own_login.

Self-authored new items (an agent opening its own PR/issue) are not filtered and do surface — the notification subject carries no author field to match against without a per-notification fetch.

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

Five 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: ...
Review submission [PR approved #N owner/repo] title\nurl: ...\n\nreviewer: body\nassignee: ...
New issue / PR [new PR #N owner/repo] title\nurl: ...\n\n<body excerpt>\nassignee: ...
Later activity (open, not creation) [activity on PR #N owner/repo] title\nurl: ...\n\n<body excerpt>\nassignee: ...
State change [PR merged #N owner/repo] title\nurl: ...\nassignee: ...

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.

"new" vs "activity on"

A review submitted with no body carries no latest_comment_url, so it misses the comment path and lands on the state-change path with state == "open" — exactly like a freshly opened PR. Labeling that new PR is misleading: agents dismiss it as a duplicate of the original open notification and miss the review (#1637). So the open state only earns the new <kind> label when the notification's event time (updated_at) is within NEW_ITEM_TOLERANCE_SECS (120s) of the subject's created_at. Anything later is labeled activity on <kind> — neutral and non-misleading, since we can't cheaply say what the activity was without an extra reviews fetch. Missing/unparseable timestamps default to new (preserve prior behavior rather than mask a genuine new item). Timestamps are parsed by a small dependency-free RFC 3339 → epoch-seconds helper (parse_rfc3339_secs).

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.

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.