hyperhive/docs/forge.md

17 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 — hive-c0re never force-pushes (the merge handler's ff push lands fine; the push_config mirror pushes main + the add-only status tags without force, and treats a non-fast-forward rejection of main after a rolled-back deploy as expected — the forge keeps the approved history, the failed/<id> tag records the divergence). 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. All REST calls across the workspace (hive-forge verbs, hive-c0re provisioning, this poller) go through the typed forgejo-api crate; only non-/api/v1 web-router routes (attachment / artifact downloads, log streaming) and the poller's enrichment fetches of server-provided subject URLs stay on raw reqwest.

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. A delivery-dedupe cursor (thread id → last-delivered updated_at) 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. Each poll prunes it 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.

Size bound: the per-poll prune retains only ids present in the single limit=UNREAD_FETCH_LIMIT (50) fetch page, so the cursor never exceeds that many entries — it tracks the unread window, not the all-time notification count. The fetch limit and the bound are the same constant in forge_notify.rs (with a debug assertion), so a future pagination change grows the ceiling visibly rather than silently. This is why the cursor stays a small JSON field rather than a db table — see the storage discussion on the tracker (issue 2117).

The cursor is persisted as the forge_cursor field of the harness's consolidated hyperhive-harness.json state file (atomic tmp+rename, flushed only when it changed) and reloaded on boot, so a container rebuild/restart doesn't re-deliver the whole currently-unread backlog (#2106 — previously the in-memory-only cursor was lost on restart and every old still-unread thread re-fired a wake). The poller runs in the same harness process that owns that file, so it's one daemon → one state file rather than a second json; both writers (turn-loop fields + this cursor) go read-modify-write under a shared lock so neither clobbers the other's fields. This is safe because a thread is recorded after a successful broker delivery, and the broker inbox is durable sqlite — so a persisted "delivered" entry can never swallow a wake the agent never received. Crucially the cursor is a private dedup mirror, not forge's read-state: it does not reintroduce the read-before-comment coupling that ruled out the old mark-read-on-delivery approach. A missing (first boot) or malformed cursor degrades to empty — re-deliver the unread set once — never an abort.

Self-echo notifications (the agent's own writes, see below) are the one path still marked-read directly (no read-before-comment value).

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 is a separate follow-up — explicit subscription management via a hive-forge CLI verb, rather than the poller second-guessing which repo watches to drop.

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), or not parseable as a URL.
  • <state>/forge-token missing or empty (agent has no forge account — pre-provisioning or destroy-without-purge race).
  • Initial client construction fails (the typed forgejo-api client for the API calls, or the plain reqwest client kept for the best-effort enrichment fetches of server-provided subject URLs; both 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 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 — but the fetch is re-attempted on each poll tick until it succeeds, so a boot-time failure (the forge not yet reachable) self-heals instead of leaving self-echo filtering off for the whole process lifetime.

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\nauthor: 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.

A review submitted with no body renders reviewed by: <author> in place of the <author>: <body> line — deliberately worded to not collide with the meta-suffix reviewer: line (requested reviewers, below).

Merge/close vs a later comment

A notification carrying a latest_comment_url normally takes the comment path. But a merged/closed subject keeps its latest_comment_url set, so a just-merged PR that had any prior discussion would route to the comment path and render [comment on PR] (with a stale pre-merge comment body) instead of [PR merged] — the agent never learns its PR merged (#2495). So when the notification IS the merge/close transition — its event time (updated_at) is within NEW_ITEM_TOLERANCE_SECS of the subject's closed_at (set for both merged and closed) — the state-change path wins even with a comment url present (state_change_is_current). A genuine later comment on an already-closed subject bumps updated_at well past closed_at, so it stays on the comment path and keeps its comment body. Missing/unparseable timestamps default to the state-change path, so a merge is never silently hidden behind a stale comment.

Merge racing a comment

The one gap the timestamp cut leaves: a genuine comment posted within NEW_ITEM_TOLERANCE_SECS of the merge bumps updated_at close enough to closed_at that state_change_is_current returns true — so it takes the state-change path and its body would be dropped. Best of both worlds: on the merge/close path we fetch the latest_comment_url comment and, when its created_at is strictly after the subject's closed_at (comment_is_after_close) — i.e. it raced the merge rather than being the pre-merge last comment the subject keeps — append it as a comment by <author>: <excerpt> block before the meta suffix (fresh_post_close_comment_tail). So the wake carries both [PR merged] and the racing comment. The kept pre-merge comment (created before closed_at) is left off, a self-authored racing comment is dropped (don't echo the agent's own write), and a missing/unparseable created_at/ closed_at appends nothing (conservative — only surface a comment we can positively place after the close). Cost: one extra comment fetch on merge/close notifications, acceptable given how rare they are.

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

Subscription management

The poller does not auto-unsubscribe from repo watches — it delivers every unread notification it's handed. Bounding the firehose (dropping broad repo watches an agent doesn't need) is done explicitly via a hive-forge CLI subscription verb, not by the poller guessing which watches to drop. See the subscription verb in docs/tools/forge.md.