# 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. ## 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 `/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/`). 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`](../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 `PATCH`es `/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). - `/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 > ` 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\nassignee: ...` | | Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\n\n\nassignee: ...` | | State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...` | 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. ### "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 ` 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 ` — 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: ` — always present; `unassigned` when empty so the line shape is stable. - `reviewer: ` — 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`](tools/forge.md).