hyperhive/docs/forge.md

13 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 config repo per agent (agent-configs/<name>). As of #1787 the agent is a write collaborator on its own repo — it can push config-change branches and (once #1838 P2 lands) open config PRs — but main is branch-protected core-only: only hive-c0re's verify-and-ff-push merge handler lands on main, an operator-team approval is required, and the agent can neither push main directly nor self-merge. main is fast-forward-only — no auto force-push (the merge handler's ff push lands fine; the legacy push_config mirror, which force-pushes to re-point status tags and rewind on rollback, runs best-effort until the PR flow retires it). Repos stay private, so an agent can't read another agent's config. (Agents remain read-only collaborators on core/meta.)
  • The dashboard links each container's "config" anchor to this config repo, 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 creations (an agent opening its own PR/issue) — the already-fetched subject payload's poster user.login matches own_login. Only creations are dropped; a later state change on the agent's own subject is driven by someone else and still surfaces.

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.