diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index 2a97547a..d36e1bcd 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -17,6 +17,7 @@ //! `PATCH /notifications/threads/{id}` so it does not re-fire. If delivery //! fails the thread is left unread so it resurfaces next tick. +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -80,9 +81,28 @@ pub async fn run(socket: PathBuf, is_manager: bool) { // socket becoming available right at boot. interval.tick().await; + // HIVE_FORGE_KEEP_SUBSCRIPTIONS=1 disables auto-unsubscribe for agents + // that intentionally consume the full repo notification firehose (e.g. triage). + let keep_subscriptions = std::env::var("HIVE_FORGE_KEEP_SUBSCRIPTIONS") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + + // Repos we have already unsubscribed this process lifetime. Persists + // across polls so we don't hammer DELETE on every cycle. + let mut unsubbed_repos: HashSet = HashSet::new(); + loop { interval.tick().await; - poll_once(&client, &forge_url, &token, &socket, is_manager).await; + poll_once( + &client, + &forge_url, + &token, + &socket, + is_manager, + keep_subscriptions, + &mut unsubbed_repos, + ) + .await; } } @@ -222,7 +242,15 @@ async fn format_notification( } } -async fn poll_once(client: &reqwest::Client, forge_url: &str, token: &str, socket: &Path, is_manager: bool) { +async fn poll_once( + client: &reqwest::Client, + forge_url: &str, + token: &str, + socket: &Path, + is_manager: bool, + keep_subscriptions: bool, + unsubbed_repos: &mut HashSet, +) { let url = format!("{forge_url}/api/v1/notifications?all=false&limit=50"); let resp = match client .get(&url) @@ -310,5 +338,35 @@ async fn poll_once(client: &reqwest::Client, forge_url: &str, token: &str, socke debug!(%id, "forge_notify: marked read"); } } + + // Auto-unsubscribe from broad repo watches when the notification + // reason is "subscribed" (agent watching the whole repo). Skipped + // when HIVE_FORGE_KEEP_SUBSCRIPTIONS=1 — triage and other firehose + // consumers set this to retain broad repo visibility. + let reason = notif["reason"].as_str().unwrap_or(""); + if !keep_subscriptions && reason == "subscribed" { + if let Some(repo) = notif["repository"]["full_name"].as_str() { + if !unsubbed_repos.contains(repo) { + let unsub_url = format!("{forge_url}/api/v1/repos/{repo}/subscription"); + match client + .delete(&unsub_url) + .header("Authorization", format!("token {token}")) + .send() + .await + { + Ok(r) if r.status().is_success() || r.status().as_u16() == 404 => { + debug!(%repo, "forge_notify: unsubscribed from repo watch"); + unsubbed_repos.insert(repo.to_owned()); + } + Ok(r) => { + debug!(%repo, status = %r.status(), "forge_notify: unsub non-2xx (ignored)"); + } + Err(e) => { + debug!(%repo, error = ?e, "forge_notify: unsub request failed (ignored)"); + } + } + } + } + } } } diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index e8f43d12..e1bd2879 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -159,6 +159,20 @@ ''; }; + options.hyperhive.forge.keepSubscriptions = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + When true (the default), the forge notification poller will NOT + auto-unsubscribe from repo watches after delivering a + "subscribed"-reason notification. Sub-agents keep their broad + subscriptions so they stay informed about repos they contribute to. + Set to false for agents (e.g. the manager) that use reason-based + filtering and do not need firehose-level repo visibility — they will + auto-unsubscribe after receiving a watched-repo notification. + ''; + }; + options.hyperhive.claudeMarketplaces = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ "anthropics/claude-plugins-official" ]; @@ -283,6 +297,8 @@ # Zero watermark disables proactive compaction; the reactive path # (compact-on-overflow) still fires when the session is truly full. HIVE_COMPACT_WATERMARK_TOKENS = "0"; + } // lib.optionalAttrs config.hyperhive.forge.keepSubscriptions { + HIVE_FORGE_KEEP_SUBSCRIPTIONS = "1"; }; boot.isNspawnContainer = true; diff --git a/nix/templates/manager.nix b/nix/templates/manager.nix index f88e2fba..c040cae5 100644 --- a/nix/templates/manager.nix +++ b/nix/templates/manager.nix @@ -2,6 +2,10 @@ { imports = [ ./harness-base.nix ]; + # Manager auto-unsubscribes from repo watches (uses mention-only filtering + # via HIVE_FORGE_NOTIFY_REASONS). Sub-agents default to keepSubscriptions=true. + hyperhive.forge.keepSubscriptions = false; + # HIVE_PORT/HIVE_LABEL/gitconfig are also injected by the generated # `applied/hm1nd/flake.nix` (see `lifecycle::setup_applied`); the values # here are the base config so the container stays sensible if anyone