//! File-backed harness state, split out of [`crate::events`] (which owns //! the live event bus + sqlite store). This module holds the runtime //! model/effort selection and the consolidated `hyperhive-harness.json` //! state file — the persisted knobs the harness reads/writes across //! turns, none of which are about the live event stream. use std::path::PathBuf; use swarm_queue_client::agent_inference::Source; /// Path to the `hyperhive-model` file. The harness only reads it, as the /// fallback in [`local_model`]. Overridable via `HYPERHIVE_MODEL_FILE` for /// dev / tests; otherwise derived from the agent's harness dir. fn model_file_path() -> PathBuf { std::env::var_os("HYPERHIVE_MODEL_FILE").map_or_else( || crate::paths::harness_dir().join("hyperhive-model"), PathBuf::from, ) } pub(crate) fn load_model() -> Option { let s = std::fs::read_to_string(model_file_path()).ok()?; let name = s.trim(); if name.is_empty() { None } else { Some(name.to_owned()) } } /// Path to the `hyperhive-effort` file. The harness only reads it, as the /// fallback in [`local_effort`]. Overridable via `HYPERHIVE_EFFORT_FILE` for /// dev / tests; otherwise derived from the agent's harness dir. fn effort_file_path() -> PathBuf { std::env::var_os("HYPERHIVE_EFFORT_FILE").map_or_else( || crate::paths::harness_dir().join("hyperhive-effort"), PathBuf::from, ) } pub(crate) fn load_effort() -> Option { let s = std::fs::read_to_string(effort_file_path()).ok()?; let level = s.trim(); if level.is_empty() { None } else { Some(level.to_owned()) } } /// The model this agent runs when the swarm sets none, and where it comes /// from. Priority: `configured` (`HIVE_DEFAULT_MODEL`, from /// `services.hyperhive.agent.model` in agent.nix) > an existing /// `hyperhive-model` file > [`DEFAULT_MODEL`]. pub(crate) fn local_model(configured: Option<&str>) -> (String, Source) { model_from(configured, load_model()) } fn model_from(configured: Option<&str>, file: Option) -> (String, Source) { match (configured, file) { (Some(nix), _) => (nix.to_owned(), Source::AgentNix), (None, Some(file)) => (file, Source::LocalFile), (None, None) => (DEFAULT_MODEL.to_owned(), Source::Default), } } /// The effort this agent runs at when the swarm sets none, and where it /// comes from. The inverse of [`local_model`]: an existing /// `hyperhive-effort` file wins over `configured` (`HIVE_DEFAULT_EFFORT`); /// nix is the default for an agent without one. pub(crate) fn local_effort(configured: Option<&str>) -> (String, Source) { effort_from(configured, load_effort()) } fn effort_from(configured: Option<&str>, file: Option) -> (String, Source) { match (file, configured) { (Some(file), _) => (file, Source::LocalFile), (None, Some(nix)) => (nix.to_owned(), Source::AgentNix), (None, None) => (DEFAULT_EFFORT.to_owned(), Source::Default), } } // --------------------------------------------------------------------------- // Consolidated harness state file // --------------------------------------------------------------------------- // // `hyperhive-harness.json` replaces the two legacy boolean sentinel files // (`hyperhive-rate-limited`, `hyperhive-needs-login`) that grew organically // and had no shared schema. A single JSON file is self-documenting, atomic // to write, and cheaper for hive-c0re to read on each sweep (one fopen vs // two stat calls). See `docs/agent-lifecycle/persistence.md::Harness state files`. // // Legacy sentinel files written by older harness builds are still honoured // by `read_harness_state` so in-place upgrades don't lose state (the new // harness re-normalises on first write). Old files are not deleted — they // expire naturally when the state dir is purged. `hive-c0re::container_view` // also checks the legacy paths as a fallback during the transition window. const HARNESS_JSON: &str = "hyperhive-harness.json"; fn harness_json_path() -> PathBuf { crate::paths::state_dir().join(HARNESS_JSON) } // Serialises the read-modify-write of `hyperhive-harness.json`. Every // writer merges into the existing object rather than reconstructing it, // so a future second writer preserves fields it doesn't own; the lock // closes the lost-update window between a writer's read and its rename. // (No second writer today — the forge poller that once shared this file // now keeps its own delivery-dedupe state and runs as a separate process // anyway, which an in-process mutex couldn't have serialised.) static HARNESS_JSON_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// Read the consolidated state file as a JSON object, or an empty object /// when it is missing / unparseable / not an object. fn read_harness_json() -> serde_json::Value { std::fs::read_to_string(harness_json_path()) .ok() .and_then(|raw| serde_json::from_str::(&raw).ok()) .filter(serde_json::Value::is_object) .unwrap_or_else(|| serde_json::json!({})) } /// Atomically overwrite the state file (`.tmp` + rename) so hive-c0re /// never reads a partial file. fn write_harness_json(v: &serde_json::Value) { let path = harness_json_path(); let tmp = path.with_extension("json.tmp"); if std::fs::write(&tmp, v.to_string()).is_ok() { let _ = std::fs::rename(&tmp, &path); } } pub(crate) fn read_harness_state() -> (bool, bool, Option) { // Try the new consolidated file first. if let Ok(raw) = std::fs::read_to_string(harness_json_path()) && let Ok(v) = serde_json::from_str::(&raw) { let rate_limited = v .get("rate_limited") .and_then(serde_json::Value::as_bool) .unwrap_or(false); let needs_login = v .get("needs_login") .and_then(serde_json::Value::as_bool) .unwrap_or(false); let active_model = v .get("active_model") .and_then(serde_json::Value::as_str) .filter(|s| !s.is_empty()) .map(str::to_owned); return (rate_limited, needs_login, active_model); } // Fall back to legacy sentinel files written by older harness builds. let state_dir = crate::paths::state_dir(); let rate_limited = state_dir.join("hyperhive-rate-limited").exists(); let needs_login = state_dir.join("hyperhive-needs-login").exists(); (rate_limited, needs_login, None) } /// Write the turn-loop's harness state fields via a read-modify-write so /// any other writer's fields in the consolidated state file survive. /// Pass `active_model: Some(s)` to update the resolved model (surfaced in /// the dashboard badge); `None` leaves the stored value untouched. pub(crate) fn write_harness_state( rate_limited: bool, needs_login: bool, active_model: Option<&str>, ) { let _guard = HARNESS_JSON_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let mut v = read_harness_json(); v["rate_limited"] = rate_limited.into(); v["needs_login"] = needs_login.into(); if let Some(model) = active_model { v["active_model"] = model.into(); } write_harness_json(&v); } /// Stamp `api_key_mode` into the consolidated state file — a config fact /// (does this agent authenticate to its backend with an API key rather /// than a Claude OAuth session?), not runtime state, so it's written once /// at harness startup and never toggled again for the life of the /// container. Lives in the same file as the toggling fields rather than a /// dedicated marker file: this codebase already consolidated two ad-hoc /// sentinel files into one JSON for exactly the reason a new one would /// re-create (`hive-c0re` paying a stat call per extra file per sweep) — /// see this module's own doc comment above. /// /// hive-c0re reads it to stop reporting `needs_login` for an agent whose /// `~/.claude/` dir is empty by design (`container_view::api_key_mode`) — /// without this, the host's own naive "does the credentials dir have /// files in it" check has no way to tell "never going to log in" apart /// from "hasn't logged in yet". pub(crate) fn write_api_key_mode(enabled: bool) { let _guard = HARNESS_JSON_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let mut v = read_harness_json(); v["api_key_mode"] = enabled.into(); write_harness_json(&v); } /// Compiled-in fallback model used when neither `HIVE_DEFAULT_MODEL` nor a /// `hyperhive-model` file is present. pub const DEFAULT_MODEL: &str = "haiku"; /// Return the model declared in `HIVE_DEFAULT_MODEL` (set from /// `services.hyperhive.agent.model` in `agent.nix`), or `None` if the env var is absent / /// empty. When `Some`, this takes precedence over a `hyperhive-model` file /// so that nix config changes always take effect on rebuild. #[must_use] pub fn configured_model() -> Option<&'static str> { // Leak once at startup — acceptable for a single config value. std::env::var("HIVE_DEFAULT_MODEL") .ok() .filter(|s| !s.trim().is_empty()) .map(|s| &*Box::leak(s.into_boxed_str())) } /// Compiled-in fallback effort level — matches the `effortLevel` baked /// into `prompts/claude-settings.json`. pub const DEFAULT_EFFORT: &str = "medium"; /// Return the effort level declared in `HIVE_DEFAULT_EFFORT` (set from /// `services.hyperhive.agent.effortLevel` in `agent.nix`), or `None` if absent / empty. /// Mirrors [`configured_model`]'s env shape. Unlike model, a /// `hyperhive-effort` file takes precedence over this baseline (see /// [`local_effort`]). #[must_use] pub fn configured_effort() -> Option<&'static str> { std::env::var("HIVE_DEFAULT_EFFORT") .ok() .filter(|s| !s.trim().is_empty()) .map(|s| &*Box::leak(s.into_boxed_str())) } /// Context-window size in tokens for a given model name. /// /// Canonical per-model sizes are declared in the harness nix modules as /// `hyperhive.contextWindowTokens` and injected as /// `HIVE_CONTEXT_WINDOW_TOKENS_` env vars — so this function /// normally just reads them. The Rust code carries no model knowledge; /// updating model families only requires a Nix change. /// /// Resolution order (first match wins): /// 1. `HIVE_CONTEXT_WINDOW_TOKENS_` — key (lowercased) is a /// substring of the active model name. Populated by the Nix default /// map for all known families; add/override in `agent.nix`. /// 2. `HIVE_CONTEXT_WINDOW_TOKENS` — single global override (any model). /// 3. Hard fallback: `200_000` (conservative; only hit outside NixOS). #[must_use] pub fn context_window_tokens(model: &str) -> u64 { let m = model.to_ascii_lowercase(); // Per-model env vars set by `hyperhive.contextWindowTokens` in Nix. for (key, val) in std::env::vars() { if let Some(suffix) = key.strip_prefix("HIVE_CONTEXT_WINDOW_TOKENS_") && !suffix.is_empty() && m.contains(&suffix.to_ascii_lowercase()) && let Ok(v) = val.trim().parse::() && v > 0 { return v; } } // Global override (single value, any model). if let Ok(s) = std::env::var("HIVE_CONTEXT_WINDOW_TOKENS") && let Ok(v) = s.trim().parse::() && v > 0 { return v; } // Hard fallback for dev/test outside NixOS where env vars aren't set. 200_000 } #[cfg(test)] mod tests { use super::{DEFAULT_EFFORT, DEFAULT_MODEL, Source, effort_from, model_from}; #[test] fn compiled_default_effort_matches_claude_settings() { // The value baked into prompts/claude-settings.json. assert_eq!(DEFAULT_EFFORT, "medium"); } fn pick(value: &str, source: Source) -> (String, Source) { (value.to_owned(), source) } /// agent.nix wins over the file for the model. #[test] fn each_model_source() { let file = || Some("sonnet".to_owned()); assert_eq!( model_from(Some("opus"), file()), pick("opus", Source::AgentNix) ); assert_eq!(model_from(None, file()), pick("sonnet", Source::LocalFile)); assert_eq!(model_from(None, None), pick(DEFAULT_MODEL, Source::Default)); } /// The file wins over agent.nix for the effort. #[test] fn each_effort_source() { let file = || Some("low".to_owned()); assert_eq!( effort_from(Some("high"), file()), pick("low", Source::LocalFile) ); assert_eq!( effort_from(Some("high"), None), pick("high", Source::AgentNix) ); assert_eq!( effort_from(None, None), pick(DEFAULT_EFFORT, Source::Default) ); } }