Compare commits

...
3 changed files with 80 additions and 2 deletions

View file

@ -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<String> = 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<String>,
) {
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)");
}
}
}
}
}
}
}

View file

@ -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;

View file

@ -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