Watch
0
0
Fork
You've already forked hyperhive
0

hive-c0re: replace the agents_crashing banner with a crash-watcher log line + grafana alert

This commit is contained in:
damocles 2026-10-12 22:21:04 +02:00 • committed by mara
commit 02831fb5e9
13 changed files with 214 additions and 224 deletions

View file

@ -359,42 +359,42 @@ the system prompt template.
## Helper events ## Helper events
`Coordinator::notify_agent(agent, &HelperEvent)` pushes the event into `hive_sh4re::manager::HelperEvent` is the wire type for an event urgent
the agent's inbox, and the harness delivers it as a regular `system` enough to drive an immediate claude turn (delivered as a regular
inbox message so it drives a normal claude turn. A "FYI, check when `system` inbox message, same `recv` path as any other message). It
convenient" event doesn't need a message — hive-c0re publishes those as currently has one variant, `NeedsUpdate { agent }` (sub-agent's
swarm lifecycle notices instead (`swarm_notices::notify`). Variants recorded flake rev is stale, operator rebuilds from the dashboard) —
(`hive_sh4re::manager::HelperEvent`): and nothing in the tree constructs it; the push path
(`Coordinator::notify_agent`) was removed once its last caller
(`notify_manager`, used only for the crash event below) went away.
`HelperEvent` is kept as the type for whatever comes along that
genuinely needs to interrupt an agent's turn loop, not because
anything live uses it today.
- `ContainerCrash { agent, note }` — `crash_watch`: a previously- **A previously-running container crashing** used to be exactly that
running container went away with no operator-initiated transient case (`ContainerCrash`, routed to the root agent via
state (Stopping / Restarting / Destroying / Rebuilding) AND nothing `Coordinator::notify_manager`) — it no longer drives any agent turn at
cleared that transient in the last 30s (`RECENT_TRANSIENT_GRACE` all. `crash_watch` now only `tracing::warn!`s `"container crash
tombstone, three `POLL_INTERVAL`s — closes the race where a detected"`; a swarm-level grafana alert matches that log line directly
lifecycle op finishes between two crash-watch polls and the and pages the operator, with no agent (and no per-hive dashboard
container shows briefly as "stopped without transient" before banner) in the loop. See
the next start). When `systemctl is-active` fails or runs past its [`docs/scheduler/observability.md`](../scheduler/observability.md#container-crash-alerting-hive-c0re).
10s bound on a poll, the container keeps its previous state for that
poll, so an unreadable unit never counts as a crash.
The root agent escalates to the operator, who starts it again from
the dashboard.
- `NeedsUpdate { agent }` — sub-agent's recorded flake rev is
stale. The operator rebuilds it from the dashboard.
The remaining lower-urgency lifecycle notices — `Rebuilt`, `Killed`, The remaining lower-urgency lifecycle notices — `Rebuilt`, `Killed`,
`Destroyed`, `NeedsLogin`, `LoggedIn` — are "FYI, check `Destroyed`, `NeedsLogin`, `LoggedIn` — are "FYI, check
when convenient" events with no reason to drive an immediate turn, so when convenient" events with no reason to drive an immediate turn, so
they go out via `swarm_notices::notify` (see above) instead they go out via `swarm_notices::notify` instead
of a `HelperEvent`: `subsystem = "core"`, `key = "<event>:<agent>"`, of a `HelperEvent`: `subsystem = "core"`, `key = "<event>:<agent>"`,
and a single free-text `summary` (`rebuilt_todo_summary` renders and a single free-text `summary` (`rebuilt_todo_summary` renders
`Rebuilt`'s `ok`/`note`/`sha`/`tag` fields into that string). `Rebuilt`'s `ok`/`note`/`sha`/`tag` fields into that string).
To add a new lifecycle notice: if it needs to drive an immediate turn To add a new lifecycle notice: if it's "FYI, check when convenient,"
(something genuinely urgent, like `ContainerCrash`), add a call `swarm_notices::notify` — no new wire type needed. If it needs to
`HelperEvent` variant + call sites + update `prompts/system.md`'s drive an immediate turn, decide first whether it actually needs an
message-event list. If it's "FYI, check when convenient," call *agent* in the loop at all (the crash case above didn't, once a
`swarm_notices::notify` instead — no new wire type swarm-level alert could carry the same signal) — only reach for a new
needed. `HelperEvent` variant + `notify_agent`-equivalent call site +
`prompts/system.md` update when the answer is yes.
## Autoupdate on startup ## Autoupdate on startup

View file

@ -5,8 +5,10 @@ swarm's metrics and log stores and, if you name one, exports upstream; each
hive runs a collector of its own that forwards its agents' telemetry there. hive runs a collector of its own that forwards its agents' telemetry there.
What flows through it: each agent's Claude Code statistics (token usage, What flows through it: each agent's Claude Code statistics (token usage,
cost, tool call counts) via Claude Code's built-in OpenTelemetry cost, tool call counts) via Claude Code's built-in OpenTelemetry
integration, each agent container's journal, and the hyperhive metrics integration, each agent container's journal, the hive tier's own
catalogued below. `hive-c0re.service` journal line for a detected container crash (see
[Container-crash alerting](#container-crash-alerting-hive-c0re)), and the
hyperhive metrics catalogued below.
## The two collectors ## The two collectors
@ -327,6 +329,31 @@ credential stays on the swarm tier.
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->
## Container-crash alerting (hive-c0re)
The crash watcher (`crash_watch.rs`, a 10s poll of every managed container)
classifies a container that stopped without an operator-initiated action as
a crash and `tracing::warn!`s `"container crash detected"` — nothing else:
no broker event, no per-hive dashboard banner. Both of those used to exist;
they were removed in favour of this.
When `otel.enable` is set, the hive tier's own collector runs a `journald`
receiver scoped to `hive-c0re.service` (no bind mount needed — unlike an
agent's journal, this collector already shares a filesystem with the unit it
reads, being a host systemd service rather than a container) and forwards
that one log line alongside the rest of the hive's telemetry. A
swarm-level grafana alert, `hh-container-crashed` in
[`nix/host-modules/swarm-grafana.nix`](../../nix/host-modules/swarm-grafana.nix),
matches it directly and pages the operator — no agent, and no per-hive
dashboard, in the loop.
Requires `services.journald.storage = "persistent"` (the NixOS default) on
every hive host with `otel.enable` set — asserted at eval time.
⚠️ **A hive with `otel.enable` off has no operator-visible crash signal at
all** — the per-hive dashboard banner this replaces is gone, and there is no
fallback path. Turning on `otel.enable` is what makes a crash reach anyone.
## Agent-emitted per-turn metrics (`hive-agent`) ## Agent-emitted per-turn metrics (`hive-agent`)
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->

View file

@ -62,12 +62,13 @@ is opt-in via the P3RM1SS10NS tab.
Transient pings (sentinel id 0) have nothing to ack and show no marker. Transient pings (sentinel id 0) have nothing to ack and show no marker.
**System messages** (from sender `system`): the broker delivers **System messages** (from sender `system`): the broker delivers
`hive_sh4re::manager::HelperEvent` (two variants: `ContainerCrash`, `hive_sh4re::manager::HelperEvent` (one variant, `NeedsUpdate` —
`NeedsUpdate` — declared, but no call site constructs it) as regular declared, but no call site constructs it) as regular inbox messages
inbox messages (same `recv` path; body is a JSON object with an `event` (same `recv` path; body is a JSON object with an `event` discriminant
discriminant field). field). A container crash used to be the other variant
`ContainerCrash` always goes to `ruth` (`Coordinator::notify_manager`, (`ContainerCrash`, routed to `ruth`) but that path is gone — a crash
hardcoded — `hive-c0re/src/workers/crash_watch.rs`). Lifecycle now only produces a log line + swarm-level grafana alert, no agent
turn at all (`docs/scheduler/observability.md`). Lifecycle
transitions the job-queue scheduler or crash watcher drive directly — transitions the job-queue scheduler or crash watcher drive directly —
stop/kill, destroy, a flake-rev login or logout state change — reach stop/kill, destroy, a flake-rev login or logout state change — reach
no individual agent: they publish onto a swarm-wide NATS no individual agent: they publish onto a swarm-wide NATS

View file

@ -40,9 +40,13 @@ from the dashboard tab strip.
that registry. Producers today include the host disk-pressure check that registry. Producers today include the host disk-pressure check
(a `statvfs` probe of `/nix`: ≥85% used → `warn`, ≥95% → `crit`, e.g. (a `statvfs` probe of `/nix`: ≥85% used → `warn`, ≥95% → `crit`, e.g.
`⚠ host nix store N% full (G GiB free) — garbage-collect …`), forge `⚠ host nix store N% full (G GiB free) — garbage-collect …`), forge
provisioning/CI-runner boot failures, and agent-state warnings provisioning/CI-runner boot failures, and an agent-state warning
(`pending_logins`, `agents_crashing`) computed alongside the container (`pending_logins`) computed alongside the container snapshot. A
snapshot. Hidden when there are no warnings. container crash used to have a sibling `agents_crashing` entry here —
removed in favour of a swarm-level grafana alert on the crash
watcher's own log line (`docs/scheduler/observability.md`), so a
hive with no otel export now has no operator-visible crash signal at
all. Hidden when there are no warnings.
- **Browser tab title** — `hyperhive // h1ve-c0re` by default; updated - **Browser tab title** — `hyperhive // h1ve-c0re` by default; updated
to `<swarm> / <hive> // h1ve-c0re` once `hive_name` / `swarm_name` to `<swarm> / <hive> // h1ve-c0re` once `hive_name` / `swarm_name`
arrive in the state snapshot. arrive in the state snapshot.

View file

@ -118,8 +118,9 @@ async fn main() -> Result<()> {
// ---------- shared turn helpers ---------- // ---------- shared turn helpers ----------
/// Surface a `SYSTEM_SENDER` message in the live event bus + tracing /// Surface a `SYSTEM_SENDER` message in the live event bus + tracing
/// log. Both agents and the manager receive `ContainerCrash`, /// log. Both agents and the manager receive `HelperEvent` notifications
/// notifications, and friends; the parse and log path is /// (just `NeedsUpdate`, now that a container crash is a log line instead
/// of a `ContainerCrash` event); the parse and log path is
/// identical. Quiet no-op when `from` isn't /// identical. Quiet no-op when `from` isn't
/// `SYSTEM_SENDER`. /// `SYSTEM_SENDER`.
fn log_system_event(bus: &Bus, from: &str, body: &str) { fn log_system_event(bus: &Bus, from: &str, body: &str) {

View file

@ -128,14 +128,6 @@ pub struct Coordinator {
/// `StopForUpdate` (`true`) leaves the tombstone reading `false`, and the /// `StopForUpdate` (`true`) leaves the tombstone reading `false`, and the
/// crash watcher then reports an intentional stop as a **crash**. /// crash watcher then reports an intentional stop as a **crash**.
recent_transient: Mutex<HashMap<(String, String), (bool, std::time::Instant)>>, recent_transient: Mutex<HashMap<(String, String), (bool, std::time::Instant)>>,
/// Timestamps of recent unexpected container crashes, keyed by agent.
/// Fed by `crash_watch` each time it classifies a stop as a crash (so
/// a crash-looping container — which `Restart=on-failure` flips back
/// to running between polls — accumulates one entry per down-transition,
/// not just whatever its point-in-time state happens to be). Read by
/// the dashboard's `agents_crashing` banner warning via
/// `recent_crash_counts`, which prunes entries older than its window.
recent_crashes: Mutex<HashMap<String, Vec<std::time::Instant>>>,
/// Agents with a graceful stop in progress. Set by the `GracefulStop` /// Agents with a graceful stop in progress. Set by the `GracefulStop`
/// orchestration; read by `socket_server::handle_recv`, which returns /// orchestration; read by `socket_server::handle_recv`, which returns
/// `Response::GracefulStop` (instead of polling the broker) while an /// `Response::GracefulStop` (instead of polling the broker) while an
@ -490,7 +482,6 @@ impl Coordinator {
model_prices, model_prices,
agents: Mutex::new(HashMap::new()), agents: Mutex::new(HashMap::new()),
recent_transient: Mutex::new(HashMap::new()), recent_transient: Mutex::new(HashMap::new()),
recent_crashes: Mutex::new(HashMap::new()),
graceful_stop_pending: Mutex::new(HashSet::new()), graceful_stop_pending: Mutex::new(HashSet::new()),
pause_pending: Mutex::new(HashSet::new()), pause_pending: Mutex::new(HashSet::new()),
last_stopped_running: Mutex::new(None), last_stopped_running: Mutex::new(None),
@ -833,7 +824,7 @@ impl Coordinator {
/// watcher reads**, for the job-queue scheduler. /// watcher reads**, for the job-queue scheduler.
/// ///
/// 🚨 The stamp is not bookkeeping. Without it the clear-then-poll race /// 🚨 The stamp is not bookkeeping. Without it the clear-then-poll race
/// produced a spurious `ContainerCrash` on **every** operator stop/restart: /// produced a spurious crash warning on **every** operator stop/restart:
/// the transient is gone by the time the 10s poll looks, so a deliberate /// the transient is gone by the time the 10s poll looks, so a deliberate
/// stop is indistinguishable from a crash. `recent_transient_within` is what /// stop is indistinguishable from a crash. `recent_transient_within` is what
/// closes that window, which is why the clear has to be an *event* — a /// closes that window, which is why the clear has to be an *event* — a
@ -955,33 +946,6 @@ impl Coordinator {
fold_tombstones_by_agent(map.iter().map(|((a, _), (d, _))| (a.as_str(), *d))) fold_tombstones_by_agent(map.iter().map(|((a, _), (d, _))| (a.as_str(), *d)))
} }
/// Record an unexpected crash for `agent`. Called by the crash
/// watcher whenever it classifies a container stop as a crash (not an
/// operator action). Append-only here; pruning happens lazily on read
/// in `recent_crash_counts`.
pub fn record_crash(&self, agent: &str) {
self.recent_crashes
.lock()
.unwrap()
.entry(agent.to_owned())
.or_default()
.push(std::time::Instant::now());
}
/// Per-agent count of crashes within the last `window`. Lazily reaps
/// older timestamps and drops agents with none left, so the map stays
/// bounded and only lists agents actively crashing. Powers the
/// dashboard's `agents_crashing` banner warning.
pub fn recent_crash_counts(&self, window: std::time::Duration) -> HashMap<String, usize> {
let now = std::time::Instant::now();
let mut map = self.recent_crashes.lock().unwrap();
map.retain(|_, times| {
times.retain(|ts| now.duration_since(*ts) <= window);
!times.is_empty()
});
map.iter().map(|(k, v)| (k.clone(), v.len())).collect()
}
/// Every live transient, keyed by agent. /// Every live transient, keyed by agent.
/// ///
/// **Derived on read, stored nowhere.** Straight off the running graph, so /// **Derived on read, stored nowhere.** Straight off the running graph, so
@ -1036,48 +1000,6 @@ impl Coordinator {
} }
} }
/// Push a `HelperEvent` into the manager's inbox. Encoded as JSON in
/// `Message::body`; sender = `SYSTEM_SENDER`. The manager harness
/// recognises the sender and parses the body. Best-effort: a serde or
/// broker error is logged but does not propagate.
pub fn notify_manager(&self, event: &hive_sh4re::manager::HelperEvent) {
self.notify_agent(hive_sh4re::manager::MANAGER_AGENT, event);
}
/// Push a `HelperEvent` into an arbitrary agent's inbox. Encoded
/// the same way as `notify_manager` (sender = `SYSTEM_SENDER`,
/// body = JSON-encoded event) — e.g. `ContainerCrash`, `NeedsUpdate`.
pub fn notify_agent(&self, agent: &str, event: &hive_sh4re::manager::HelperEvent) {
self.notify_agent_from(hive_sh4re::manager::SYSTEM_SENDER, agent, event);
}
/// Same as `notify_agent` but with an explicit sender. Use this
/// when the event originates from a known agent or the operator
/// rather than the system itself, so the recipient's terminal
/// shows the right name instead of `system`.
pub fn notify_agent_from(
&self,
from: &str,
agent: &str,
event: &hive_sh4re::manager::HelperEvent,
) {
let body = match serde_json::to_string(event) {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = ?e, "failed to encode helper event");
return;
}
};
if let Err(e) = self.broker.send(&hive_sh4re::inbox::Message {
from: hive_sh4re::manager::trusted_sender(from),
to: agent.to_owned(),
body,
in_reply_to: None,
}) {
tracing::warn!(error = ?e, target = %agent, "failed to push helper event");
}
}
/// Deliver `body` to every currently-registered agent except the sender, /// Deliver `body` to every currently-registered agent except the sender,
/// appending the standard broadcast hint. Returns a list of per-agent /// appending the standard broadcast hint. Returns a list of per-agent
/// error strings for any that failed (empty = all ok). /// error strings for any that failed (empty = all ok).

View file

@ -120,13 +120,6 @@ struct TransientView {
secs: u64, secs: u64,
} }
/// Window over which container crashes count toward the `agents_crashing`
/// banner warning. Wide enough that a crash-looping container (restarted
/// by `Restart=on-failure` every few seconds) keeps the warning lit
/// between flaps, short enough that a single recovered crash clears within
/// minutes.
const CRASH_WARNING_WINDOW: std::time::Duration = std::time::Duration::from_mins(10);
/// Cold-load snapshot of the whole dashboard. /// Cold-load snapshot of the whole dashboard.
/// ///
/// Includes the roster, tombstones, job queue, meta inputs, and more. Live /// Includes the roster, tombstones, job queue, meta inputs, and more. Live
@ -195,15 +188,12 @@ pub(super) async fn api_state(
let tombstones = build_tombstone_views(&state.coord, &containers); let tombstones = build_tombstone_views(&state.coord, &containers);
let port_conflicts = build_port_conflicts(&containers); let port_conflicts = build_port_conflicts(&containers);
// Banner warnings: host probes (disk) + agent-state (pending logins, // Banner warnings: host probes (disk) + agent-state (pending logins).
// crashing agents). Built before the response struct because the // Built before the response struct because the agent-state producer
// agent-state producer borrows `containers`, which moves in below. // borrows `containers`, which moves in below.
let server_warnings = { let server_warnings = {
let mut w = crate::host_stats::server_warnings(); let mut w = crate::host_stats::server_warnings();
w.extend(crate::host_stats::agent_state_warnings( w.extend(crate::host_stats::agent_state_warnings(&containers));
&containers,
&state.coord.recent_crash_counts(CRASH_WARNING_WINDOW),
));
w w
}; };

View file

@ -416,9 +416,9 @@ async fn cmd_serve(
// build_logs.sqlite vacuum: c0re-side (single db). Failures kept // build_logs.sqlite vacuum: c0re-side (single db). Failures kept
// 30d, successes 24h — see `build_logs::vacuum` for the rule. // 30d, successes 24h — see `build_logs::vacuum` for the rule.
crate::build_logs::spawn_vacuum(&coord); crate::build_logs::spawn_vacuum(&coord);
// Container crash watcher: emits HelperEvent::ContainerCrash // Container crash watcher: warn!()s "container crash detected" when a
// when a previously-running container goes away without an // previously-running container goes away without an operator-initiated
// operator-initiated transient state. // transient state — a swarm-level grafana alert matches that line.
crash_watch::spawn(coord.clone()); crash_watch::spawn(coord.clone());
// Agent-sockets marker poll: re-fires `agent_sockets::write` // Agent-sockets marker poll: re-fires `agent_sockets::write`
// and `gateway_nginx::write` every 10s so the JSON and nginx // and `gateway_nginx::write` every 10s so the JSON and nginx

View file

@ -15,8 +15,6 @@
//! [`refresh_disk_warning`] drives a held guard from a periodic task (see //! [`refresh_disk_warning`] drives a held guard from a periodic task (see
//! `spawn` in `main.rs`) instead of a `statvfs` per `/api/state`. //! `spawn` in `main.rs`) instead of a `statvfs` per `/api/state`.
use std::collections::HashMap;
use serde::Serialize; use serde::Serialize;
use utoipa::ToSchema; use utoipa::ToSchema;
@ -99,19 +97,20 @@ pub fn refresh_disk_warning(guard: &mut Option<crate::warnings::WarningGuard>) {
} }
/// Agent-state warnings derived from the live container snapshot the /// Agent-state warnings derived from the live container snapshot the
/// dashboard already holds: agents that need a claude login, and agents /// dashboard already holds: agents that need a claude login. Kept
/// that are crashing. Kept separate from [`server_warnings`] (host /// separate from [`server_warnings`] (host probes) because the caller
/// probes) because the caller owns the container list + crash counts; /// owns the container list; the dashboard concatenates both into one
/// the dashboard concatenates both into one banner list. /// banner list.
/// ///
/// `crash_counts` is `Coordinator::recent_crash_counts(window)` — agent → /// Container crashes used to have a sibling `agents_crashing` entry here,
/// number of crashes inside that window — so a crash-looping agent shows /// fed by `Coordinator::recent_crash_counts` — removed in favour of a
/// its repeat count rather than a single point-in-time flap. /// swarm-level grafana alert on the crash-watcher's own log line (see
/// `nix/host-modules/swarm-grafana.nix`'s `hh-container-crashed` rule and
/// `docs/scheduler/observability.md`). That ships the same signal to the
/// operator without needing an agent (or this per-hive banner) in the
/// loop at all.
#[must_use] #[must_use]
pub fn agent_state_warnings<S: std::hash::BuildHasher>( pub fn agent_state_warnings(containers: &[ContainerView]) -> Vec<ServerWarning> {
containers: &[ContainerView],
crash_counts: &HashMap<String, usize, S>,
) -> Vec<ServerWarning> {
let mut out = Vec::new(); let mut out = Vec::new();
// `needs_login` is already running-gated in `container_view::build_all`, // `needs_login` is already running-gated in `container_view::build_all`,
@ -137,27 +136,6 @@ pub fn agent_state_warnings<S: std::hash::BuildHasher>(
}); });
} }
if !crash_counts.is_empty() {
let mut crashing: Vec<(&String, usize)> =
crash_counts.iter().map(|(a, n)| (a, *n)).collect();
crashing.sort_unstable_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
let list = crashing
.iter()
.map(|(a, n)| format!("{a} (×{n})"))
.collect::<Vec<_>>()
.join(", ");
out.push(ServerWarning {
kind: "agents_crashing",
level: "crit",
message: format!(
"{n} agent{plural} crashing: {list} \
— check the container journal (`hivectl logs <agent>`)",
n = crashing.len(),
plural = if crashing.len() == 1 { "" } else { "s" },
),
});
}
out out
} }
@ -264,13 +242,13 @@ mod tests {
#[test] #[test]
fn no_agent_warnings_when_all_healthy() { fn no_agent_warnings_when_all_healthy() {
let containers = [cv("alice", false), cv("bob", false)]; let containers = [cv("alice", false), cv("bob", false)];
assert!(agent_state_warnings(&containers, &HashMap::new()).is_empty()); assert!(agent_state_warnings(&containers).is_empty());
} }
#[test] #[test]
fn pending_logins_lists_sorted_agents() { fn pending_logins_lists_sorted_agents() {
let containers = [cv("zoe", true), cv("amy", false), cv("bob", true)]; let containers = [cv("zoe", true), cv("amy", false), cv("bob", true)];
let w = agent_state_warnings(&containers, &HashMap::new()); let w = agent_state_warnings(&containers);
assert_eq!(w.len(), 1); assert_eq!(w.len(), 1);
assert_eq!(w[0].kind, "pending_logins"); assert_eq!(w[0].kind, "pending_logins");
assert_eq!(w[0].level, "warn"); assert_eq!(w[0].level, "warn");
@ -284,29 +262,7 @@ mod tests {
#[test] #[test]
fn singular_grammar_for_one_agent() { fn singular_grammar_for_one_agent() {
let containers = [cv("solo", true)]; let containers = [cv("solo", true)];
let w = agent_state_warnings(&containers, &HashMap::new()); let w = agent_state_warnings(&containers);
assert!(w[0].message.starts_with("1 agent needs claude login: solo")); assert!(w[0].message.starts_with("1 agent needs claude login: solo"));
} }
#[test]
fn crashing_warning_is_crit_and_count_ordered() {
let crashes = HashMap::from([("flap".to_owned(), 5), ("blip".to_owned(), 1)]);
let w = agent_state_warnings(&[], &crashes);
assert_eq!(w.len(), 1);
assert_eq!(w[0].kind, "agents_crashing");
assert_eq!(w[0].level, "crit");
// higher crash count first
assert!(w[0].message.contains("flap (×5), blip (×1)"));
}
#[test]
fn both_warnings_coexist() {
let containers = [cv("a", true)];
let crashes = HashMap::from([("b".to_owned(), 2)]);
let kinds: Vec<&str> = agent_state_warnings(&containers, &crashes)
.iter()
.map(|w| w.kind)
.collect();
assert_eq!(kinds, ["pending_logins", "agents_crashing"]);
}
} }

View file

@ -1,7 +1,10 @@
//! Per-container crash and login-state watcher. Polls every managed //! Per-container crash and login-state watcher. Polls every managed
//! container on a 10s interval. Fires `ContainerCrash`, `LoggedIn`, //! container on a 10s interval. A classified crash only `tracing::warn!`s
//! and `NeedsLogin` helper events. Event semantics and the //! (`"container crash detected"`) — a swarm-level grafana alert matches
//! `RECENT_TRANSIENT_GRACE` window: `docs/agent-lifecycle/config-changes.md::Helper events`. //! that line directly; see the module doc on [`emit_crash_transitions`].
//! Login-state transitions (`needs_login`/`logged_in`) publish onto
//! `swarm_notices` instead. The `RECENT_TRANSIENT_GRACE` window:
//! `docs/agent-lifecycle/config-changes.md::Helper events`.
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
@ -143,12 +146,12 @@ fn emit_crash_transitions(coord: &Coordinator, prev: &HashSet<String>, current:
if is_deliberate_stop(active, recently_cleared) { if is_deliberate_stop(active, recently_cleared) {
continue; continue;
} }
// The one place this fires: a swarm-level grafana alert
// (`hh-container-crashed` in `nix/host-modules/swarm-grafana.nix`)
// matches this exact message against the log store and pages the
// operator directly — no agent (and no per-hive dashboard banner)
// in the loop. See `docs/scheduler/observability.md`.
tracing::warn!(agent = %stopped, "container crash detected"); tracing::warn!(agent = %stopped, "container crash detected");
coord.record_crash(stopped);
coord.notify_manager(&hive_sh4re::manager::HelperEvent::ContainerCrash {
agent: stopped.clone(),
note: Some("container stopped without an operator action".into()),
});
} }
} }
@ -230,7 +233,7 @@ mod tests {
/// Control: a real drop from running to absent is a *readable*, empty /// Control: a real drop from running to absent is a *readable*, empty
/// list — it must still be processed (and so still reach /// list — it must still be processed (and so still reach
/// `emit_crash_transitions`, which fires the crash event), unlike a /// `emit_crash_transitions`, which warns the crash), unlike a
/// read failure. /// read failure.
#[test] #[test]
fn readable_empty_list_still_processes() { fn readable_empty_list_still_processes() {
@ -261,7 +264,7 @@ mod tests {
#[test] #[test]
fn not_deliberate_with_no_transient_at_all() { fn not_deliberate_with_no_transient_at_all() {
// The real-crash case — fires the ContainerCrash event. // The real-crash case — warns "container crash detected".
assert!(!is_deliberate_stop(None, None)); assert!(!is_deliberate_stop(None, None));
} }

View file

@ -55,12 +55,6 @@ pub fn trusted_sender(s: &str) -> Ident {
pub enum HelperEvent { pub enum HelperEvent {
/// A sub-agent's recorded flake rev is stale relative to hyperhive. /// A sub-agent's recorded flake rev is stale relative to hyperhive.
NeedsUpdate { agent: String }, NeedsUpdate { agent: String },
/// Container exited without an operator-initiated stop (crash).
ContainerCrash {
agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
} }
#[cfg(test)] #[cfg(test)]

View file

@ -399,6 +399,21 @@ in
# prove this config STARTS, never mind that a sample arrives. # prove this config STARTS, never mind that a sample arrives.
validateConfigFile = true; validateConfigFile = true;
settings = { settings = {
# The journald receiver's cursor store, same shape as the agent
# tier's own (`nix/agent-modules/otel.nix`): a restart resumes
# from the last-read position instead of re-reading from the
# journal's tail (silently dropping whatever landed while the
# collector was down) or its head (re-sending everything).
# `create_directory` matters at BUILD time: `validateConfigFile`
# runs `otelcol validate` in the nix sandbox, where systemd's
# `StateDirectory=` has never run and the path does not exist
# yet — without it the validator refuses with "directory must
# exist".
extensions.file_storage = {
directory = "/var/lib/opentelemetry-collector";
create_directory = true;
};
receivers = { receivers = {
otlp.protocols.http.endpoint = listen; otlp.protocols.http.endpoint = listen;
@ -452,6 +467,31 @@ in
system = { }; system = { };
}; };
}; };
# This tier's own host journal, scoped to the one unit whose
# crash-watch warning (`"container crash detected"`,
# `crash_watch.rs`) a swarm-level grafana alert
# (`hh-container-crashed` in `swarm-grafana.nix`) depends on
# reaching VictoriaLogs. Unlike the swarm tier's journald
# receiver (`swarm-otel.nix`) this needs no bind mount: this
# collector is a HOST systemd unit, not a container, so
# `/var/log/journal` is the same filesystem hive-c0re itself
# logs into — no id-mapping gap to cross.
#
# `units`, unlike the swarm tier's unfiltered receiver: this
# collector has no `filter/exclude-user-sessions` processor,
# and a hive host can be someone's workstation too, so an
# allowlist is load-bearing here rather than a tidiness
# preference.
journald = {
directory = "/var/log/journal";
storage = "file_storage";
units = [ "hive-c0re.service" ];
# Severity table shared with the agent and swarm tiers'
# journald receivers — see ../journald-severity.nix for why
# it is its own file.
operators = import ../journald-severity.nix;
};
} }
# Only when a service has actually declared a target. An enabled # Only when a service has actually declared a target. An enabled
# `prometheus` receiver with an empty `scrape_configs` renders, # `prometheus` receiver with an empty `scrape_configs` renders,
@ -499,8 +539,10 @@ in
// lib.optionalAttrs senderAuth { auth.authenticator = authName; }; // lib.optionalAttrs senderAuth { auth.authenticator = authName; };
# ⚠️ An extension not listed here is INERT: the collector starts # ⚠️ An extension not listed here is INERT: the collector starts
# clean and the exporter naming it sends nothing authenticated. # clean and the exporter naming it sends nothing authenticated —
service.extensions = lib.optional senderAuth authName; # and, for `file_storage`, the journald receiver's `storage:`
# above would name a component the collector never starts.
service.extensions = [ "file_storage" ] ++ lib.optional senderAuth authName;
# Host identity, so a sample can say WHICH machine it came from. # Host identity, so a sample can say WHICH machine it came from.
# Without this the host's metrics carry `hive` and no host # Without this the host's metrics carry `hive` and no host
@ -584,21 +626,29 @@ in
exporters = [ swarmName ]; exporters = [ swarmName ];
}; };
# The second signal this tier carries, and the reason it exists # The second signal this tier carries. Two distinct sources, for
# is that an agent's journal is unreadable from here: the # two distinct reasons:
# host-side per-container journal directory is an id-mapped bind # - `otlp`: an agent's journal is unreadable from here (the
# mount journald never writes into, so each agent container runs # host-side per-container journal directory is an id-mapped
# its own forwarder (nix/agent-modules/otel.nix) and pushes over # bind mount journald never writes into), so each agent
# the `otlp` receiver above. Without this pipeline that receiver # container runs its own forwarder (nix/agent-modules/otel.nix)
# answers 404 on `/v1/logs` — measured, and indistinguishable # and pushes over the `otlp` receiver above. Without this
# from a route that was never meant to exist. # pipeline that receiver answers 404 on `/v1/logs` — measured,
# and indistinguishable from a route that was never meant to
# exist.
# - `journald`: this collector's own host journal, scoped to
# `hive-c0re.service` above — no id-mapping gap to cross here,
# since this collector IS the host, unlike an agent.
# #
# One receiver, two pipelines: `otlp` accepts both signals and # One `otlp` receiver, two pipelines: it accepts both signals and
# each pipeline takes the one it names. The exporter needs no # each pipeline takes the one it names. The exporter needs no
# change either — its `endpoint` is a base, and otlphttp appends # change either — its `endpoint` is a base, and otlphttp appends
# `/v1/logs` to it exactly as it appends `/v1/metrics`. # `/v1/logs` to it exactly as it appends `/v1/metrics`.
service.pipelines.logs = { service.pipelines.logs = {
receivers = [ "otlp" ]; receivers = [
"otlp"
"journald"
];
# `resourcedetection` and NOT `deltatocumulative`: that one is # `resourcedetection` and NOT `deltatocumulative`: that one is
# a metrics-only processor, and naming it here is a startup # a metrics-only processor, and naming it here is a startup
# failure rather than a no-op. Host identity applies to both # failure rather than a no-op. Host identity applies to both
@ -863,6 +913,30 @@ in
docs/swarm/secrets.md. docs/swarm/secrets.md.
''; '';
} }
{
# Same guard as ./swarm-otel.nix's sibling journald receiver, and
# the same reason: the receiver reads /var/log/journal, which only
# exists when journald stores persistently. With "volatile" the
# journal lives in /run/log/journal instead and with "none" there
# is none at all — either way `nixos-container`'s analogue for a
# host unit is simpler but no less fatal: the collector's
# `journald` receiver fails to open the directory and the unit
# never starts, with nothing here saying why.
assertion =
!(lib.elem config.services.journald.storage [
"volatile"
"none"
]);
message = ''
services.hyperhive.otel.enable is true, which runs a journald
receiver reading /var/log/journal for hive-c0re's own crash-watch
log line — but services.journald.storage is
"${config.services.journald.storage}" on this host.
Set services.journald.storage = "persistent" (the NixOS
default).
'';
}
]; ];
} }
); );

View file

@ -1176,6 +1176,24 @@ in
for = "0s"; for = "0s";
summary = "{{ $labels._HOSTNAME }} refused to deploy merged config commit {{ $labels.F_REV }} of agent {{ $labels.F_AGENT }}; the reason is the `F_ERROR` field of the hive-c0re log line"; summary = "{{ $labels._HOSTNAME }} refused to deploy merged config commit {{ $labels.F_REV }} of agent {{ $labels.F_AGENT }}; the reason is the `F_ERROR` field of the hive-c0re log line";
}) })
# Logged once per detected crash, by `emit_crash_transitions`
# in hive-c0re's crash_watch.rs (10s poll interval). Replaces
# the old per-hive dashboard `agents_crashing` banner — see
# that function's module doc for why the banner went away
# in favour of this. Host journal records carry the
# tracing fields as `F_<NAME>` and no `hive` attribute,
# so the machine is `_HOSTNAME`
# (same shape as `hh-config-rev-refused` above). The unit
# filter keeps out an agent's own log line if one ever
# quotes the same message.
(logCountRule {
uid = "hh-container-crashed";
title = "container crashed";
windowSeconds = 900;
expr = ''_time:15m _SYSTEMD_UNIT:="hive-c0re.service" _msg:"container crash detected" | stats by (_HOSTNAME, F_AGENT) count() as crashes'';
for = "0s";
summary = "{{ $labels._HOSTNAME }} detected agent {{ $labels.F_AGENT }}'s container crash (not an operator-initiated stop)";
})
] ]
# One rule per declared hive rather than one grouped by # One rule per declared hive rather than one grouped by
# `hive`, so a hive that sends nothing at all still has # `hive`, so a hive that sends nothing at all still has