Watch
0
0
Fork
You've already forked hyperhive
0

export hyperhive.agent.needs_login and alert per agent

This commit is contained in:
atlas 2026-11-09 10:35:16 +01:00
commit 9043be4a61
6 changed files with 117 additions and 12 deletions

View file

@ -354,6 +354,7 @@ filters those series out and the host dashboard's top-10 panels group by
| `hyperhive.container.cpu.percent` | `%` | gauge | host-normalised percent (custom — no `rate()` needed) | | `hyperhive.container.cpu.percent` | `%` | gauge | host-normalised percent (custom — no `rate()` needed) |
| `hyperhive.agent.unrestricted_internet` | `1` | gauge | the agent's `unrestricted_internet` flag in the swarm permissions this hive last read: `1` on, `0` off; a flag never set reads `1`. Omitted while the stored permissions file is unreadable | | `hyperhive.agent.unrestricted_internet` | `1` | gauge | the agent's `unrestricted_internet` flag in the swarm permissions this hive last read: `1` on, `0` off; a flag never set reads `1`. Omitted while the stored permissions file is unreadable |
| `hyperhive.agent.closure_pullable` | `1` | gauge | `1` when this hive can fetch the agent's latest closure from the swarm's binary cache and verify its signature, else `0` with a `reason` label (`no-record`, `not-cached`, `no-credentials`, `fetch-failed`, `bad-signature`; see [the builder docs](../swarm/builder.md#what-hives-do-with-the-cache)). Omitted before the first probe pass, every 5 minutes | | `hyperhive.agent.closure_pullable` | `1` | gauge | `1` when this hive can fetch the agent's latest closure from the swarm's binary cache and verify its signature, else `0` with a `reason` label (`no-record`, `not-cached`, `no-credentials`, `fetch-failed`, `bad-signature`; see [the builder docs](../swarm/builder.md#what-hives-do-with-the-cache)). Omitted before the first probe pass, every 5 minutes |
| `hyperhive.agent.needs_login` | `1` | gauge | `1` when a running agent needs a claude login (the dashboard's needs-login flag), `0` when it is logged in. Omitted for a stopped agent and before the first container scan |
The `hyperhive.`-prefixed metrics have no semconv equivalent (memory The `hyperhive.`-prefixed metrics have no semconv equivalent (memory
limit + peak, on-disk footprint, and an instantaneous cpu percent kept limit + peak, on-disk footprint, and an instantaneous cpu percent kept
@ -552,15 +553,16 @@ failing sweep such as `swarm_status_publish`, `pending_logins`), and the message
The swarm Grafana (`nix/host-modules/swarm-grafana.nix`) alerts on these: The swarm Grafana (`nix/host-modules/swarm-grafana.nix`) alerts on these:
| Alert | Fires when | | Alert | Fires when |
| ----------------------- | ----------------------------------------------------------------------------------- | | ----------------------- | -------------------------------------------------------------------------------------------------------- |
| `hh-hive-degraded` | `hyperhive.hive.degraded` is `1` for 2 minutes | | `hh-hive-degraded` | `hyperhive.hive.degraded` is `1` for 2 minutes |
| `hh-hive-warning` | any `hyperhive.hive.warning` series other than `disk_pressure` exists for 2 minutes | | `hh-hive-warning` | any `hyperhive.hive.warning` series other than `disk_pressure` and `pending_logins` exists for 2 minutes |
| `hh-host-disk-high` | `hyperhive.hive.nix_store.utilization` stays above 0.85 for 10 minutes | | `hh-agent-needs-login` | `hyperhive.agent.needs_login` is `1` for an agent for 5 minutes, one alert per agent |
| `hh-host-disk-critical` | `hyperhive.hive.nix_store.utilization` stays above 0.95 for 2 minutes | | `hh-host-disk-high` | `hyperhive.hive.nix_store.utilization` stays above 0.85 for 10 minutes |
| `hh-host-disk-critical` | `hyperhive.hive.nix_store.utilization` stays above 0.95 for 2 minutes |
The disk alerts replace the old dashboard banner for the nix store, and The disk alerts replace the old dashboard banner for the nix store, and
`hh-hive-warning` replaces it for every other warning. Disk pressure has its `hh-hive-warning` replaces it for every other warning. `pending_logins` has its own per-agent alert, `hh-agent-needs-login`, so a login never pages twice. Disk pressure has its
own pair so the thresholds sit on the metric rather than on the registry own pair so the thresholds sit on the metric rather than on the registry
entry. entry.

View file

@ -496,6 +496,7 @@ impl Coordinator {
} }
}; };
crate::host_stats::refresh_pending_logins(&fresh); crate::host_stats::refresh_pending_logins(&fresh);
crate::host_stats::record_login_states(&fresh);
let mut last = self.last_containers.lock().await; let mut last = self.last_containers.lock().await;
let mut changed_or_new = Vec::new(); let mut changed_or_new = Vec::new();
let mut removed = Vec::new(); let mut removed = Vec::new();

View file

@ -113,6 +113,40 @@ pub fn refresh_pending_logins(containers: &[ContainerView]) {
} }
} }
/// Per-agent login state of the last container snapshot; see
/// [`record_login_states`].
static LOGIN_STATES: std::sync::Mutex<Vec<(String, u64)>> = std::sync::Mutex::new(Vec::new());
/// `(agent, 1 if it needs a login else 0)` for every running agent. A stopped
/// agent has no entry: `needs_login` is already running-gated in
/// `container_view::build_all`, so its `false` would read as "logged in".
#[must_use]
pub fn login_readings(containers: &[ContainerView]) -> Vec<(String, u64)> {
containers
.iter()
.filter(|c| c.running)
.map(|c| (c.name.clone(), u64::from(c.needs_login)))
.collect()
}
/// Hold the login state of the current container snapshot for the
/// `hyperhive.agent.needs_login` gauge. Driven by the container rescan, next
/// to [`refresh_pending_logins`].
pub fn record_login_states(containers: &[ContainerView]) {
*LOGIN_STATES
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = login_readings(containers);
}
/// The held readings of [`record_login_states`]; empty before the first rescan.
#[must_use]
pub fn held_login_states() -> Vec<(String, u64)> {
LOGIN_STATES
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
/// Agent-state warnings derived from the live container snapshot: agents /// Agent-state warnings derived from the live container snapshot: agents
/// that need a claude login. Takes the container list from its caller /// that need a claude login. Takes the container list from its caller
/// because that is where the snapshot lives. /// because that is where the snapshot lives.
@ -307,6 +341,20 @@ mod tests {
assert!(current().is_none(), "no agent needing a login clears it"); assert!(current().is_none(), "no agent needing a login clears it");
} }
#[test]
fn login_readings_are_per_running_agent_by_name() {
let mut stopped = cv("zed", false);
stopped.running = false;
let mut machine = cv("amy", true);
machine.container = "h-amy".to_owned();
let readings = login_readings(&[machine, cv("bob", false), stopped]);
assert_eq!(
readings,
vec![("amy".to_owned(), 1), ("bob".to_owned(), 0)],
"agent names, 1 for needs-login, 0 for logged in, nothing for stopped"
);
}
/// The gauge value is a fraction, so a percent leaking through would put /// The gauge value is a fraction, so a percent leaking through would put
/// every alert threshold 100x off. /// every alert threshold 100x off.
#[test] #[test]

View file

@ -259,6 +259,7 @@ fn register_instruments(provider: &SdkMeterProvider, snapshot: Arc<Mutex<Vec<Con
.build(); .build();
register_closure_pullable(&meter); register_closure_pullable(&meter);
register_needs_login(&meter);
register_hive_instruments(&meter); register_hive_instruments(&meter);
} }
@ -281,6 +282,20 @@ fn register_closure_pullable(meter: &opentelemetry::metrics::Meter) {
.build(); .build();
} }
/// 1 when a running agent needs a claude login, else 0, from the container
/// snapshot the dashboard shows. A stopped agent reports nothing.
fn register_needs_login(meter: &opentelemetry::metrics::Meter) {
meter
.u64_observable_gauge("hyperhive.agent.needs_login")
.with_unit("1")
.with_callback(|obs| {
for (agent, value) in crate::host_stats::held_login_states() {
obs.observe(value, &[KeyValue::new("agent", agent)]);
}
})
.build();
}
/// The gauge value for a sampled container: `None` for one that belongs to no /// The gauge value for a sampled container: `None` for one that belongs to no
/// agent (the CI runner), else [`unrestricted_value`] for its agent. /// agent (the CI runner), else [`unrestricted_value`] for its agent.
fn unrestricted_reading(stored: &permissions::Stored, c: &ContainerResource) -> Option<u64> { fn unrestricted_reading(stored: &permissions::Stored, c: &ContainerResource) -> Option<u64> {

View file

@ -1720,18 +1720,29 @@ in
# pressure is left out: it has its own thresholds below, on # pressure is left out: it has its own thresholds below, on
# the filesystem metric rather than the registry entry. # the filesystem metric rather than the registry entry.
# Sweeps raise their warning only after several misses, so # Sweeps raise their warning only after several misses, so
# `for` is short. `pending_logins` pages too, on purpose: # `for` is short. `pending_logins` is left out: it has no
# an agent that waits more than 2m for its claude login # `agent` label, and `hh-agent-needs-login` below pages
# needs a human to act, which is what the banner it # once per agent instead.
# replaces was for.
(metricThresholdRule { (metricThresholdRule {
uid = "hh-hive-warning"; uid = "hh-hive-warning";
title = "hive warning raised"; title = "hive warning raised";
expr = ''max by (hive, kind, level) ({__name__="hyperhive.hive.warning", kind!="disk_pressure"})''; expr = ''max by (hive, kind, level) ({__name__="hyperhive.hive.warning", kind!~"disk_pressure|pending_logins"})'';
threshold = 0; threshold = 0;
for = "2m"; for = "2m";
summary = "hive {{ $labels.hive }} has a {{ $labels.level }} warning raised: {{ $labels.kind }}; the message is the `hive warning raised` line in the hive-c0re log"; summary = "hive {{ $labels.hive }} has a {{ $labels.level }} warning raised: {{ $labels.kind }}; the message is the `hive warning raised` line in the hive-c0re log";
}) })
# `hyperhive.agent.needs_login` is `1` for a running agent
# without a usable claude login; stopped agents report
# nothing. One alert instance per `(hive, agent)`; `for`
# lets a login in progress finish first.
(metricThresholdRule {
uid = "hh-agent-needs-login";
title = "agent needs a claude login";
expr = ''max by (hive, agent) ({__name__="hyperhive.agent.needs_login"}) == 1'';
threshold = 0;
for = "5m";
summary = "agent {{ $labels.agent }} on hive {{ $labels.hive }} has needed a claude login for 5 minutes; open its page in the dashboard to complete it";
})
# `hyperhive.hive.nix_store.utilization` is the used fraction of the # `hyperhive.hive.nix_store.utilization` is the used fraction of the
# filesystem holding the nix store, the one that failed CI # filesystem holding the nix store, the one that failed CI
# with ENOSPC before the GC guardrails. # with ENOSPC before the GC guardrails.

View file

@ -940,6 +940,34 @@ let
&& lib.hasInfix "/ 18000" expr && lib.hasInfix "/ 18000" expr
&& lib.hasInfix "max by (account, window)" expr; && lib.hasInfix "max by (account, window)" expr;
} }
{
# The needs-login alert is one instance per (hive, agent), fires after
# 5 minutes on the gauge hive-c0re exports, and `hh-hive-warning` leaves
# `pending_logins` to it so a login never pages twice. The disk rule is
# the presence control: it goes through the same lookup.
# 🔬 Invert-proof: change `for`, `max by (hive, agent)` or the metric on
# `hh-agent-needs-login`, drop `pending_logins` from `hh-hive-warning`,
# or rename the gauge in `otel_metrics.rs`.
name = "the needs-login alert is per agent, fires after 5 minutes and the hive warning rule skips pending_logins";
ok =
let
groups =
grafanaHere.containers.swarm-grafana.config.services.grafana.provision.alerting.rules.settings.groups;
rules = lib.concatMap (g: g.rules) groups;
source = builtins.readFile ../../hive-c0re/src/stats/otel_metrics.rs;
rule = uid: lib.findFirst (r: r.uid == uid) null rules;
login = rule "hh-agent-needs-login";
expr = uid: (lib.head (rule uid).data).model.expr;
in
login != null
&& login.for == "5m"
&& rule "hh-host-disk-high" != null
&& lib.hasInfix ''{__name__="hyperhive.agent.needs_login"}'' (expr "hh-agent-needs-login")
&& lib.hasInfix ''"hyperhive.agent.needs_login"'' source
&& lib.hasInfix "max by (hive, agent)" (expr "hh-agent-needs-login")
&& lib.hasInfix "pending_logins" (expr "hh-hive-warning")
&& lib.hasInfix "disk_pressure" (expr "hh-hive-warning");
}
{ {
# The held-deploys alert fires on any agent held for 30 minutes and # The held-deploys alert fires on any agent held for 30 minutes and
# selects the gauge name `held_deploys.rs` spells in Rust. The disk rule # selects the gauge name `held_deploys.rs` spells in Rust. The disk rule