//! 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; /// Path to the persisted model file. 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()) } } pub(crate) fn persist_model(name: &str) -> std::io::Result<()> { let path = model_file_path(); if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } std::fs::write(path, format!("{name}\n")) } /// Path to the persisted effort-level file. Sibling of `hyperhive-model`, /// 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()) } } pub(crate) fn persist_effort(level: &str) -> std::io::Result<()> { let path = effort_file_path(); if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } std::fs::write(path, format!("{level}\n")) } // --------------------------------------------------------------------------- // 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/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. // (The forge poller used to be that second writer, for a delivery-dedupe // cursor. It no longer persists one — forge's own read-state is the // durable record — and it is a separate process now, which an // in-process mutex could not have serialised anyway.) 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); } /// Compiled-in fallback model used when neither `HIVE_DEFAULT_MODEL` nor a /// persisted runtime override is present. pub const DEFAULT_MODEL: &str = "haiku"; /// Return the model declared in `HIVE_DEFAULT_MODEL` (set from /// `hyperhive.model` in `agent.nix`), or `None` if the env var is absent / /// empty. When `Some`, this takes precedence over any persisted runtime /// override 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"; /// Valid claude `--effort` levels, ascending. The operator picker is /// constrained to these; [`is_valid_effort`] guards the persist path. pub const EFFORT_LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"]; /// True iff `level` is one of [`EFFORT_LEVELS`]. #[must_use] pub fn is_valid_effort(level: &str) -> bool { EFFORT_LEVELS.contains(&level) } /// Return the effort level declared in `HIVE_DEFAULT_EFFORT` (set from /// `hyperhive.effortLevel` in `agent.nix`), or `None` if absent / empty. /// Mirrors [`configured_model`]'s env shape. Unlike model, the persisted /// runtime override takes precedence over this baseline (see `Bus::new`): /// the operator's effort pick sticks across harness restart, with the nix /// value only the default when no override was ever set. #[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, EFFORT_LEVELS, is_valid_effort}; #[test] fn effort_validation_accepts_only_known_levels() { for level in EFFORT_LEVELS { assert!(is_valid_effort(level), "{level} should be valid"); } // Out-of-set, empty, and wrong-case inputs are rejected so a bad // picker POST never reaches `claude --effort`. for bad in ["lowest", "", "MEDIUM", "ultra", "high "] { assert!(!is_valid_effort(bad), "{bad:?} should be rejected"); } } #[test] fn compiled_default_effort_is_selectable() { // The fallback must itself be a valid level, and match the value // baked into prompts/claude-settings.json. assert!(EFFORT_LEVELS.contains(&DEFAULT_EFFORT)); assert_eq!(DEFAULT_EFFORT, "medium"); } }