diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 37ba30da..3b8e0b43 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -707,13 +707,24 @@ const FORWARDED_VARS: &[&str] = &[ /// Both exist because they're consumed at different times: the option is baked /// into scripts at build time (tea-login bakes `FORGE_URL` from it), the env /// var is read at runtime. Setting only one leaves the other on its default, -/// which is how a hive ends up with two disagreeing answers for one URL. -const SERVICE_URL_OPTIONS: &[(&str, &str)] = &[ +/// which is how a hive ends up with two disagreeing answers for one value. +/// +/// The hive/swarm display names are here for exactly that reason, learned the +/// hard way: they were forwarded as runtime env only, while +/// `claude-settings.nix` read them from the container's `environment.variables` +/// at *eval* time — where they were never set. Every agent baked +/// `hive=unknown,swarm=unknown` into its OTEL resource attributes and shipped +/// that label on every metric, while the same process's env held the right +/// answer. Half-wiring this map is not a missing nicety; it is a value that +/// evaluates fine and is silently wrong. +const FORWARDED_VAR_OPTIONS: &[(&str, &str)] = &[ ("HIVE_FORGE_URL", "hyperhive.forge.url"), ("HIVE_MATRIX_URL", "hyperhive.matrix.url"), + ("HYPERHIVE_HIVE_NAME", "hyperhive.hiveName"), + ("HYPERHIVE_SWARM_NAME", "hyperhive.swarmName"), ]; -/// Render the service-URL option assignments for one agent's module block. +/// Render the forwarded-var option assignments for one agent's module block. /// /// Split out of `render_flake` so it can be tested without touching process /// env: the render-level tests have to `set_var`, which makes them race each @@ -726,10 +737,10 @@ const SERVICE_URL_OPTIONS: &[(&str, &str)] = &[ /// For a service the hive cannot run without, silence would instead produce a /// fleet of agents quietly missing an integration, so those are checked by /// [`require_service_urls`] before this is called. -fn push_service_url_options(out: &mut String, vars: &[(&'static str, String)]) { +fn push_forwarded_var_options(out: &mut String, vars: &[(&'static str, String)]) { use std::fmt::Write as _; for (var, val) in vars { - let Some((_, option)) = SERVICE_URL_OPTIONS.iter().find(|(name, _)| name == var) else { + let Some((_, option)) = FORWARDED_VAR_OPTIONS.iter().find(|(name, _)| name == var) else { continue; }; let escaped = val.replace('\\', "\\\\").replace('"', "\\\""); @@ -751,7 +762,7 @@ const REQUIRED_SERVICE_URL_VARS: &[&str] = &["HIVE_FORGE_URL"]; /// `vars`. /// /// Pure over the already-collected pairs so it can be tested without touching -/// process env (the same reason [`push_service_url_options`] is split out). +/// process env (the same reason [`push_forwarded_var_options`] is split out). /// /// Checked by `sync_agents` — the point at which the hive commits a rendered /// flake to disk — rather than inside the renderer. Rendering is a pure string @@ -1238,7 +1249,7 @@ where // missing `HIVE_FORGE_URL` before it writes anything (`require_service_urls`). // The check lives there rather than here because rendering is a pure string // operation the tests exercise directly. - push_service_url_options(&mut out, &forwarded_env_vars()); + push_forwarded_var_options(&mut out, &forwarded_env_vars()); // GitHub integration is on by default in every agent // (`hyperhive.github.enable`); the host turns it off hive-wide via // `services.hyperhive.github.enable = false`, surfaced here as the @@ -1939,21 +1950,27 @@ mod tests { // parallel runner — the first version of this test did exactly that // and failed for that reason rather than for a real defect. let mut out = String::new(); - push_service_url_options( + push_forwarded_var_options( &mut out, &[ ("HIVE_FORGE_URL", "http://forge.example.test".to_string()), ("HIVE_MATRIX_URL", "http://matrix.example.test".to_string()), - // Forwarded but not a service URL — must be ignored here, it - // belongs in globalEnvironment only. + // Mapped too, and that is the fix this test now guards: the + // display names used to be forwarded as runtime env *only*, + // so the build-time reader fell back to "unknown" and every + // agent shipped that label on every metric. ("HYPERHIVE_HIVE_NAME", "pr1ma".to_string()), + // Forwarded but genuinely unmapped — proves the map is a + // filter, not a pass-through. + ("HYPERHIVE_HIVE_DOMAIN", "pr1ma.darkest.space".to_string()), ], ); assert_eq!( out, " hyperhive.forge.url = \"http://forge.example.test\";\n\ - \x20 hyperhive.matrix.url = \"http://matrix.example.test\";\n", - "expected exactly the two service URL options, indented for the agent module block" + \x20 hyperhive.matrix.url = \"http://matrix.example.test\";\n\ + \x20 hyperhive.hiveName = \"pr1ma\";\n", + "expected exactly the mapped options, in input order, indented for the module block" ); } @@ -1987,11 +2004,17 @@ mod tests { // so absence is an absent integration rather than a misdirected one. // Required services don't reach here: `require_service_urls` rejects // them first. + // `HYPERHIVE_HIVE_DOMAIN` is forwarded as env but carries no option — + // deliberately picked over a mapped var, since the whole point is a + // forwarded var the option map does not know about. let mut out = String::new(); - push_service_url_options(&mut out, &[("HYPERHIVE_HIVE_NAME", "pr1ma".to_string())]); + push_forwarded_var_options( + &mut out, + &[("HYPERHIVE_HIVE_DOMAIN", "pr1ma.darkest.space".to_string())], + ); assert!( out.is_empty(), - "no option should be emitted without a forwarded URL, got:\n{out}" + "no option should be emitted for an unmapped forwarded var, got:\n{out}" ); } @@ -2001,7 +2024,7 @@ mod tests { // would end the string and change the surrounding config rather than // merely corrupting one value. let mut out = String::new(); - push_service_url_options( + push_forwarded_var_options( &mut out, &[("HIVE_FORGE_URL", "http://x/\"; evil = \"".to_string())], ); diff --git a/nix/agent-modules/claude-settings.nix b/nix/agent-modules/claude-settings.nix index 5047c1ac..5070e8e5 100644 --- a/nix/agent-modules/claude-settings.nix +++ b/nix/agent-modules/claude-settings.nix @@ -14,12 +14,16 @@ let # Hive-wide OpenTelemetry config (host-driven; baked in per-agent by # meta.rs `otel_config`). otelCfg = config.hyperhive.otel; - # Hive/swarm display names are forwarded into each agent's build by - # meta.rs as `environment.variables` (per-agent, build-time strings), - # so they can be baked into the resource attributes below without a - # runtime shell. Absent (option unset) → "unknown". - hiveDisplayName = config.environment.variables.HYPERHIVE_HIVE_NAME or "unknown"; - swarmDisplayName = config.environment.variables.HYPERHIVE_SWARM_NAME or "unknown"; + # Hive/swarm display names, read from the per-agent options meta.rs + # renders (NOT from `environment.variables` — those carry the same names + # at *runtime* only, so reading them here silently yielded "unknown" on + # every agent while the process env held the right answer). `null` means + # the hive did not name itself; "unknown" is then an honest label rather + # than a guess. + hiveDisplayName = + if config.hyperhive.hiveName == null then "unknown" else config.hyperhive.hiveName; + swarmDisplayName = + if config.hyperhive.swarmName == null then "unknown" else config.hyperhive.swarmName; # Effective per-agent MemoryMax=, in bytes, injected by meta.rs's # per-agent flake render (`hyperhive.claudeMemoryMaxBytes`). `null` # when the effective cap is unbounded ("infinity") or a RAM @@ -215,6 +219,37 @@ in # the agent's *last rebuild* — `set-limits` still applies the # cgroup cap live via a drop-in reload, but this derived heap ceiling # needs a rebuild to pick up a new value. + options.hyperhive.hiveName = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + internal = true; + description = '' + Human-readable hive name, rendered per-agent by + `meta.rs::render_flake` from the host's + `services.hyperhive.hiveName`. Baked into the OTEL resource + attributes at build time, which is why it is an option and not + just the `HYPERHIVE_HIVE_NAME` env var: the env var is read at + runtime, this is read during evaluation, and wiring only one of + the two is how every agent ended up reporting `hive=unknown`. + + `null` means the hive did not name itself. + ''; + }; + + options.hyperhive.swarmName = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + internal = true; + description = '' + Human-readable swarm name, rendered per-agent by + `meta.rs::render_flake` from the host's + `services.hyperhive.swarmName`. Same build-time/runtime split as + `hyperhive.hiveName`. + + `null` means the hive is not part of a named swarm. + ''; + }; + options.hyperhive.claudeMemoryMaxBytes = lib.mkOption { type = lib.types.nullOr lib.types.ints.positive; default = null;