hyperhive/hive-agent/src/harness_state.rs

295 lines
12 KiB
Rust

//! 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<String> {
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<String> {
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::<serde_json::Value>(&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<String>) {
// 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::<serde_json::Value>(&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
/// 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_<KEY_UPPER>` 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>` — 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::<u64>()
&& 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::<u64>()
&& 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");
}
}