feat(#2642): a github.com notification poller alongside the forge one

hive-forge-notify grows a second binary, hive-github-notify. The two
share the notification half of the job — tolerant parse, classification,
formatting, dedupe, todo delivery — and nothing else: each binary owns
its host's protocol outright.

Two binaries rather than one multi-source daemon, and rather than a
cargo feature. A feature would unify across the workspace and cost every
crate its build cache. Two binaries keep the decision in nix: forge.nix
installs the forge unit, github.nix installs the github one under
hyperhive.github.enable, so a hive built without that module has no
github poller in its closure at all — GitHub access is separable (a
tier, a policy boundary), not merely switched off. Both binaries ship
from the existing derivation, so packages.nix is untouched.

The split is real at the code level too, not just at the unit level.
source.rs is a trait; the impls live in the binaries that use them, so
neither binary links the other's protocol code and the library names no
host at all. The forge-only assigned-issue rollup moves into the forge
binary for the same reason: it asks the forge what is assigned to this
agent, which is not a notification-protocol concern.

At runtime the github unit needs a PAT at <state>/github-token, the same
dashboard-provisioned token the gh wrapper and the git credential helper
already use. No PAT: it logs why and exits 0, which is why the unit is
Restart=on-failure and not always.

Forgejo's notifications API is modelled on GitHub's, so one tolerant
parse serves both — the differences (string thread ids, PullRequest vs
Pull) are absorbed by lenient deserializers rather than a second parse
path. Thread ids normalise to String at the parse boundary; they are
only ever opaque keys. Todo keys gain a per-source prefix so the two
hosts cannot collide, and the forge's is deliberately empty to keep
existing forge todo keys stable across the deploy that lands this.

The github loop honours the server's X-Poll-Interval, re-arming only
when the server asks for a slower cadence than ours; the hint is read
before the status check, because it arrives on error and empty pages too
and that is exactly when it matters. Reading the notification stream
needs the notifications scope on the PAT, which a token minted for push
access typically lacks; the failure mode is silence, so docs/github.md
says so explicitly.
This commit is contained in:
atlas 2026-07-31 12:05:02 +02:00 committed by mara
commit 0db83c40a0
15 changed files with 971 additions and 412 deletions

View file

@ -0,0 +1,110 @@
//! `hive-github-notify` binary — long-running per-agent **github.com**
//! notification poller. Same delivery path as its Forgejo sibling: unread
//! list, per-thread summary, todo upsert on the harness's in-agent socket,
//! mark read on the source.
//!
//! Takes no arguments. `HYPERHIVE_STATE_DIR` holds the PAT
//! (`github-token`, provisioned from the dashboard credentials tab — see
//! `docs/github.md`) and `HIVE_AGENT_SOCKET` is the harness's todo socket.
//! Having a PAT *is* the opt-in: with no token the poller logs why and
//! exits 0, so deploying this unit to an agent that never gets one costs a
//! settled process rather than a restart loop.
//!
//! ⚠️ Reading the notification stream needs the **`notifications` scope**
//! on the PAT — a token minted for `gh` + `git push` usually carries `repo`
//! only, which is enough to push and open PRs but not to read (or mark
//! read) notifications. A PAT without it is not fatal: the poller logs the
//! refusal and stays quiet, so the symptom is silence rather than an error.
mod source;
use std::collections::HashMap;
use std::time::Duration;
use hive_forge_notify::notify::{
POLL_INTERVAL_SECS, TOKEN_RETRY_MAX, TOKEN_RETRY_SECS, poll_once, resolve_own_login,
};
use hive_forge_notify::{HTTP_TIMEOUT_SECS, agent_socket, init_tracing};
use source::GithubSource;
use tracing::{debug, info, warn};
#[tokio::main]
async fn main() {
init_tracing();
let socket = agent_socket();
info!(socket = %socket.display(), "hive-github-notify starting");
// Returns only when no PAT ever arrives; otherwise loops forever.
github_loop(hive_forge_notify::state_dir(), socket).await;
}
/// Waits for the PAT to appear: the token is written out of band from the
/// dashboard and takes effect without a rebuild, so an agent that gains a
/// PAT mid-session starts getting notifications on the next tick rather
/// than after a restart. Gives up — returning, so the process exits 0
/// rather than restart-looping — when no PAT ever arrives, which is the
/// common case for an agent that has the unit but no account.
async fn github_loop(state_dir: String, socket: std::path::PathBuf) {
let token_path = format!("{state_dir}/github-token");
let mut attempts = 0u32;
let token = loop {
match tokio::fs::read_to_string(&token_path).await {
Ok(t) if !t.trim().is_empty() => break t.trim().to_owned(),
_ => debug!("forge_notify: no github token at {token_path} yet"),
}
attempts += 1;
if attempts >= TOKEN_RETRY_MAX {
debug!(
"forge_notify: no github token after {TOKEN_RETRY_MAX} retries — github disabled"
);
return;
}
tokio::time::sleep(Duration::from_secs(TOKEN_RETRY_SECS)).await;
};
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
.build()
{
Ok(c) => c,
Err(e) => {
warn!("forge_notify: failed to build github HTTP client: {e}");
return;
}
};
let source = GithubSource::new(&token);
let mut own_login = resolve_own_login(&client, &source).await;
let mut delivered: HashMap<String, String> = HashMap::new();
// GitHub tells callers how often it is willing to be polled
// (`X-Poll-Interval`, 60s in practice) and rate-limits those who
// ignore it. Start at our own cadence and re-arm to whatever the
// server asks for — never faster than it wants, never slower than we
// need.
let mut cadence = POLL_INTERVAL_SECS;
let mut interval = tokio::time::interval(Duration::from_secs(cadence));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
interval.tick().await;
info!("forge_notify: github polling started");
loop {
interval.tick().await;
if own_login.is_empty() {
own_login = resolve_own_login(&client, &source).await;
}
let hint = poll_once(&source, &client, &socket, &mut delivered, &own_login).await;
if let Some(secs) = hint.filter(|s| *s > cadence) {
debug!(
secs,
"forge_notify: github asked for a slower poll — re-arming"
);
cadence = secs;
interval = tokio::time::interval(Duration::from_secs(cadence));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
interval.tick().await;
}
}
}