diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index 21977f7..c9969a1 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -104,8 +104,6 @@ pub async fn run(socket: PathBuf, is_manager: bool) { debug!(%own_login, "forge_notify: own login resolved"); } - info!(forge_url = %forge_url, "forge_notify: polling started"); - let mut interval = tokio::time::interval(Duration::from_secs(POLL_INTERVAL_SECS)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); // First tick fires immediately — skip it so we don't race the broker @@ -118,6 +116,29 @@ pub async fn run(socket: PathBuf, is_manager: bool) { .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); + // Optional reason drop-list. `HIVE_FORGE_NOTIFY_SKIP_REASONS` is a + // comma-separated list of Forgejo notification `reason` values to + // suppress (e.g. `subscribed,participating`). Notifications with + // those reasons are marked read and silently dropped; everything + // else -- including notifications with a null/unrecognised reason -- + // is delivered. Drop-list is safer than an allow-list: it kills the + // firehose without risking silent misses of directed signals + // (review_requested, assigned) or future unknown reason strings. + // Configurable per-agent via `hyperhive.forge.skipNotifyReasons` in agent.nix. + let skip_reasons: Vec = std::env::var("HIVE_FORGE_NOTIFY_SKIP_REASONS") + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + + if skip_reasons.is_empty() { + info!(forge_url = %forge_url, "forge_notify: polling started (all reasons)"); + } else { + info!(forge_url = %forge_url, skip = ?skip_reasons, "forge_notify: polling started"); + } + // 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(); @@ -133,6 +154,7 @@ pub async fn run(socket: PathBuf, is_manager: bool) { keep_subscriptions, &mut unsubbed_repos, &own_login, + &skip_reasons, ) .await; } @@ -444,6 +466,7 @@ async fn poll_once( keep_subscriptions: bool, unsubbed_repos: &mut HashSet, own_login: &str, + skip_reasons: &[String], ) { let url = format!("{forge_url}/api/v1/notifications?all=false&limit=50"); let resp = match client @@ -481,6 +504,18 @@ async fn poll_once( for notif in ¬ifications { let Some(id) = notif["id"].as_u64() else { continue }; + // Reason drop-list: suppress noisy reasons (subscribed/participating). + // null/unknown reasons pass through — directed signals are never + // silently dropped even if Forgejo returns an unexpected value. + if !skip_reasons.is_empty() { + let reason = notif["reason"].as_str().unwrap_or(""); + if !reason.is_empty() && skip_reasons.iter().any(|r| r == reason) { + debug!(%id, %reason, "forge_notify: skipping (reason in drop-list)"); + mark_read(client, forge_url, token, id).await; + continue; + } + } + let body_opt = format_notification(client, token, notif, own_login).await; // None means self-echo — mark read silently, no delivery. diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 62d7fe3..a1948ac 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -173,6 +173,31 @@ ''; }; + options.hyperhive.forge.skipNotifyReasons = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ + "subscribed" + "participating" + ]; + description = '' + Forgejo notification `reason` values to suppress in the forge + notification poller. Notifications with these reasons are marked + read and silently dropped; all others — including notifications + with a null or unrecognised reason — are delivered. + + Drop-list is safer than an allow-list: directed signals + (`review_requested`, `assigned`, `mention`) are never silently + missed even if Forgejo returns an unexpected reason string. + + Empty list (the default) delivers all notifications. Set to + `[ "subscribed" "participating" ]` for agents like the manager + that want only direct mentions and reviews, not the full repo + firehose. Rendered to the `HIVE_FORGE_NOTIFY_SKIP_REASONS` + environment variable consumed by the harness poller at runtime. + ''; + }; + options.hyperhive.dashboardLinks = lib.mkOption { type = lib.types.listOf (lib.types.submodule { options = { @@ -332,6 +357,8 @@ HIVE_COMPACT_WATERMARK_TOKENS = "0"; } // lib.optionalAttrs config.hyperhive.forge.keepSubscriptions { HIVE_FORGE_KEEP_SUBSCRIPTIONS = "1"; + } // lib.optionalAttrs (config.hyperhive.forge.skipNotifyReasons != [ ]) { + HIVE_FORGE_NOTIFY_SKIP_REASONS = lib.concatStringsSep "," config.hyperhive.forge.skipNotifyReasons; }; boot.isNspawnContainer = true; diff --git a/nix/templates/manager.nix b/nix/templates/manager.nix index c040cae..5a4544c 100644 --- a/nix/templates/manager.nix +++ b/nix/templates/manager.nix @@ -2,9 +2,15 @@ { 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. + # Manager auto-unsubscribes from repo watches and skips the subscription/ + # participation firehose — only direct mentions, reviews, and assignments + # land in the inbox. Sub-agents default to keepSubscriptions=true and + # skipNotifyReasons=[]. hyperhive.forge.keepSubscriptions = false; + hyperhive.forge.skipNotifyReasons = [ + "subscribed" + "participating" + ]; # HIVE_PORT/HIVE_LABEL/gitconfig are also injected by the generated # `applied/hm1nd/flake.nix` (see `lifecycle::setup_applied`); the values