From ae6d23594de9e6a3423d258ebd332ce57d3313b4 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 12:52:22 +0200 Subject: [PATCH] feat(#2): split harness-internal state from agent-visible state --- hive-ag3nt/src/events.rs | 8 ++++---- hive-ag3nt/src/paths.rs | 25 +++++++++++++++++++---- hive-ag3nt/src/turn_stats.rs | 2 +- hive-c0re/src/coordinator.rs | 15 +++++++++++--- hive-c0re/src/events_vacuum.rs | 9 +++++---- hive-c0re/src/lifecycle.rs | 32 ++++++++++++++++++++++++++---- hive-c0re/src/meta.rs | 3 +++ hive-c0re/src/migrate.rs | 36 ++++++++++++++++++++++++++++++++++ hive-c0re/src/stats_vacuum.rs | 6 +++--- 9 files changed, 113 insertions(+), 23 deletions(-) diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index 2a45e7f6..65b78043 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -21,19 +21,19 @@ const CHANNEL_CAPACITY: usize = 256; /// sqlite. Older rows are vacuumed on a periodic sweep. const HISTORY_CAPACITY: usize = 2000; /// Path to the persisted event db. Overridable via `HYPERHIVE_EVENTS_DB` -/// for dev / tests; otherwise derived from the agent's state dir. +/// for dev / tests; otherwise derived from the agent's harness dir. fn events_db_path() -> PathBuf { std::env::var_os("HYPERHIVE_EVENTS_DB").map_or_else( - || crate::paths::state_dir().join("hyperhive-events.sqlite"), + || crate::paths::harness_dir().join("hyperhive-events.sqlite"), PathBuf::from, ) } /// Path to the persisted model file. Overridable via `HYPERHIVE_MODEL_FILE` -/// for dev / tests; otherwise derived from the agent's state dir. +/// 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::state_dir().join("hyperhive-model"), + || crate::paths::harness_dir().join("hyperhive-model"), PathBuf::from, ) } diff --git a/hive-ag3nt/src/paths.rs b/hive-ag3nt/src/paths.rs index b1fef762..2b4559fe 100644 --- a/hive-ag3nt/src/paths.rs +++ b/hive-ag3nt/src/paths.rs @@ -1,13 +1,16 @@ -//! Per-agent path resolution for state and credential directories. +//! Per-agent path resolution for state, harness, and credential directories. //! -//! All agents (including the manager `hm1nd`) use `/agents/{label}/state`. +//! All agents (including the manager `hm1nd`) use `/agents/{label}/state` +//! for agent-owned durable notes, and `/agents/{label}/harness` for +//! harness-internal files (`hyperhive-events.sqlite`, `hyperhive-model`, etc.) +//! that should not clutter what claude sees as "my notes dir". //! Claude credentials live at `$HOME/.claude` (resolves to //! `/home//.claude` because the harness service runs as a //! non-root unix user matching the agent label — see //! `docs/persistence.md::First-boot agent-user migration`). //! -//! Both paths can be overridden via env vars (`HYPERHIVE_STATE_DIR`, -//! `HYPERHIVE_CLAUDE_DIR`) for dev / test scenarios. +//! All three paths can be overridden via env vars (`HYPERHIVE_STATE_DIR`, +//! `HYPERHIVE_HARNESS_DIR`, `HYPERHIVE_CLAUDE_DIR`) for dev / test scenarios. use std::path::PathBuf; @@ -24,6 +27,20 @@ pub fn state_dir() -> PathBuf { PathBuf::from(format!("/agents/{label}/state")) } +/// Harness-internal state directory. Holds files the harness owns +/// (`hyperhive-events.sqlite`, `hyperhive-turn-stats.sqlite`, +/// `hyperhive-model`) so they do not appear inside the agent-visible +/// `/agents/{label}/state` tree. Reads `HYPERHIVE_HARNESS_DIR` first; +/// falls back to `/agents/{label}/harness` derived from `HIVE_LABEL`. +#[must_use] +pub fn harness_dir() -> PathBuf { + if let Some(p) = std::env::var_os("HYPERHIVE_HARNESS_DIR") { + return PathBuf::from(p); + } + let label = std::env::var("HIVE_LABEL").unwrap_or_default(); + PathBuf::from(format!("/agents/{label}/harness")) +} + /// Per-turn config dir for the regenerated claude-{mcp-config,settings, /// system-prompt} files the harness drops before each turn. Set by /// systemd via `RuntimeDirectory = "hive-config"`: a per-service runtime diff --git a/hive-ag3nt/src/turn_stats.rs b/hive-ag3nt/src/turn_stats.rs index a17f81ad..6a9238bc 100644 --- a/hive-ag3nt/src/turn_stats.rs +++ b/hive-ag3nt/src/turn_stats.rs @@ -262,5 +262,5 @@ impl TurnStats { } fn default_path() -> PathBuf { - crate::paths::state_dir().join("hyperhive-turn-stats.sqlite") + crate::paths::harness_dir().join("hyperhive-turn-stats.sqlite") } diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 285edc90..5bafe818 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -875,13 +875,22 @@ impl Coordinator { } /// Per-agent durable knowledge dir. Bind-mounted RW into the agent - /// container at `/state`. Survives destroy/recreate alongside the - /// claude dir. Agents are told (via the system prompt) to write - /// long-lived notes / scratch state here. + /// container at `/agents/{name}/state`. Survives destroy/recreate. + /// Agent-visible — claude is told to write long-lived notes here. pub fn agent_notes_dir(name: &str) -> PathBuf { Self::agent_state_root(name).join("state") } + /// Per-agent harness-internal state dir. Bind-mounted RW into the + /// agent container at `/agents/{name}/harness`. Holds sqlite dbs + /// and config files owned by the harness (`hyperhive-events.sqlite`, + /// `hyperhive-turn-stats.sqlite`, `hyperhive-model`) — kept separate + /// from the agent-visible `state/` so claude's "my notes" view is + /// uncluttered and the host vacuum has a clean sweep root. + pub fn agent_harness_dir(name: &str) -> PathBuf { + Self::agent_state_root(name).join("harness") + } + /// Authoritative applied config repo. Hive-c0re-only. pub fn agent_applied_dir(name: &str) -> PathBuf { PathBuf::from(format!("{APPLIED_STATE_ROOT}/{name}")) diff --git a/hive-c0re/src/events_vacuum.rs b/hive-c0re/src/events_vacuum.rs index 8a487cce..f765410b 100644 --- a/hive-c0re/src/events_vacuum.rs +++ b/hive-c0re/src/events_vacuum.rs @@ -1,7 +1,8 @@ //! Host-side vacuum of every per-agent events.sqlite. The harness -//! writes to `/state/hyperhive-events.sqlite` (bind-mounted from -//! `/var/lib/hyperhive/agents//state/`); we open the same file -//! from the host every hour and delete rows older than `KEEP_SECS`. +//! writes to `/agents//harness/hyperhive-events.sqlite` +//! (bind-mounted from `/var/lib/hyperhive/agents//harness/`); +//! we open the same file from the host every hour and delete rows +//! older than `KEEP_SECS`. //! Age-only — no row cap — so a chatty turn doesn't lose history //! sooner than a quiet one; disk pressure on a sustained burst is //! a cheaper problem than a missing event when the operator is @@ -41,7 +42,7 @@ pub fn spawn(coord: &Arc) { fn sweep_once() { for name in Coordinator::kept_state_names() { - let path = Coordinator::agent_notes_dir(&name).join("hyperhive-events.sqlite"); + let path = Coordinator::agent_harness_dir(&name).join("hyperhive-events.sqlite"); if !path.exists() { continue; } diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index ea8f3eb7..afb66009 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -755,12 +755,21 @@ pub fn ensure_claude_dir(claude_dir: &Path) -> Result<()> { } /// Public for the `InitConfig` approval path in `actions.rs` which seeds -/// dirs without calling the full `spawn`. +/// dirs without calling the full `spawn`. Also creates the sibling `harness/` +/// dir so the first harness startup can write its sqlite files immediately. pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> { if !notes_dir.exists() { std::fs::create_dir_all(notes_dir) .with_context(|| format!("create {}", notes_dir.display()))?; } + // Harness dir is a sibling of the agent-visible state dir. + if let Some(parent) = notes_dir.parent() { + let harness_dir = parent.join("harness"); + if !harness_dir.exists() { + std::fs::create_dir_all(&harness_dir) + .with_context(|| format!("create {}", harness_dir.display()))?; + } + } Ok(()) } @@ -1086,15 +1095,30 @@ fn set_nspawn_flags( shared = HOST_SHARED_ROOT, ); - // Per-agent state at `/agents//state`. Skipped for - // the manager — the `/agents` bind below already exposes its - // own state (along with every sub-agent's). + // Per-agent state + harness dirs. Skipped for the manager — + // the `/agents` bind below already exposes both (along with + // every sub-agent's). For regular agents the harness dir is + // the sibling of notes_dir (same parent, "harness" subdir). if container != MANAGER_NAME { let _ = write!( binds, " --bind={notes}:/agents/{agent_name}/state", notes = notes_dir.display(), ); + // Harness dir: sibling of notes_dir under the agent state root. + // systemd-nspawn refuses to start when the bind source is missing; + // ensure_state_dir already creates it, but be defensive here. + if let Some(parent) = notes_dir.parent() { + let harness_dir = parent.join("harness"); + if !harness_dir.exists() { + let _ = std::fs::create_dir_all(&harness_dir); + } + let _ = write!( + binds, + " --bind={harness}:/agents/{agent_name}/harness", + harness = harness_dir.display(), + ); + } } if container == MANAGER_NAME { // systemd-nspawn refuses to start a container whose bind diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 17fb79dc..4d08acd9 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -480,10 +480,12 @@ where environment.variables = { HIVE_LABEL = name; HYPERHIVE_STATE_DIR = "/agents/${name}/state"; + HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness"; }; systemd.globalEnvironment = { HIVE_LABEL = name; HYPERHIVE_STATE_DIR = "/agents/${name}/state"; + HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness"; }; systemd.services.${service}.environment = parentEnv // toolGroupsEnv // { HIVE_PORT = toString port; @@ -521,6 +523,7 @@ where } out.push_str( r#" HYPERHIVE_STATE_DIR = "/agents/${name}/state"; + HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness"; }; } ]; diff --git a/hive-c0re/src/migrate.rs b/hive-c0re/src/migrate.rs index 7bebc8cb..f33ee52c 100644 --- a/hive-c0re/src/migrate.rs +++ b/hive-c0re/src/migrate.rs @@ -47,6 +47,13 @@ pub async fn run(coord: &Arc) -> Result<()> { let names = enumerate_agents().await; tracing::info!(count = names.len(), "migration: scanning"); + // Phase 0: move harness-owned files out of state/ into harness/. + // Idempotent — rename is a no-op if the source doesn't exist and + // the destination already does. + for name in &names { + migrate_harness_files(name); + } + // Phase 1 + 2: per-agent applied + proposed. for name in &names { if let Err(e) = migrate_applied_repo(name).await { @@ -104,6 +111,35 @@ pub async fn run(coord: &Arc) -> Result<()> { Ok(()) } +/// Move harness-owned sqlite/config files out of the agent-visible state dir +/// and into the sibling harness dir. Best-effort: logs warnings but never +/// fails. Idempotent — each file is only moved if present at the old path +/// and absent at the new path. +fn migrate_harness_files(name: &str) { + const HARNESS_FILES: &[&str] = &[ + "hyperhive-events.sqlite", + "hyperhive-turn-stats.sqlite", + "hyperhive-model", + ]; + let state_dir = Coordinator::agent_notes_dir(name); + let harness_dir = Coordinator::agent_harness_dir(name); + if let Err(e) = std::fs::create_dir_all(&harness_dir) { + tracing::warn!(%name, error = ?e, "migration: create harness dir failed"); + return; + } + for file in HARNESS_FILES { + let src = state_dir.join(file); + let dst = harness_dir.join(file); + if !src.exists() || dst.exists() { + continue; + } + match std::fs::rename(&src, &dst) { + Ok(()) => tracing::info!(%name, %file, "migration: moved to harness dir"), + Err(e) => tracing::warn!(%name, %file, error = ?e, "migration: move to harness dir failed"), + } + } +} + async fn enumerate_agents() -> Vec { let containers = lifecycle::list().await.unwrap_or_default(); containers diff --git a/hive-c0re/src/stats_vacuum.rs b/hive-c0re/src/stats_vacuum.rs index c08be606..7e018a6b 100644 --- a/hive-c0re/src/stats_vacuum.rs +++ b/hive-c0re/src/stats_vacuum.rs @@ -1,6 +1,6 @@ //! Host-side vacuum of every per-agent turn-stats.sqlite. The harness -//! writes to `/state/hyperhive-turn-stats.sqlite` (bind-mounted from -//! `/var/lib/hyperhive/agents//state/`); we open the same file +//! writes to `/agents//harness/hyperhive-turn-stats.sqlite` +//! (bind-mounted from `/var/lib/hyperhive/agents//harness/`); we open the same file //! from the host every hour and delete rows older than `KEEP_SECS`. //! Mirrors `events_vacuum` in structure — host-side so the harness //! can't disable it, age-only so a chatty burst doesn't evict old @@ -40,7 +40,7 @@ pub fn spawn(coord: &Arc) { fn sweep_once() { for name in Coordinator::kept_state_names() { let path = - Coordinator::agent_notes_dir(&name).join("hyperhive-turn-stats.sqlite"); + Coordinator::agent_harness_dir(&name).join("hyperhive-turn-stats.sqlite"); if !path.exists() { continue; }