feat(#2): split harness-internal state from agent-visible state
This commit is contained in:
parent
ce875646a5
commit
ae6d23594d
9 changed files with 113 additions and 23 deletions
|
|
@ -21,19 +21,19 @@ const CHANNEL_CAPACITY: usize = 256;
|
||||||
/// sqlite. Older rows are vacuumed on a periodic sweep.
|
/// sqlite. Older rows are vacuumed on a periodic sweep.
|
||||||
const HISTORY_CAPACITY: usize = 2000;
|
const HISTORY_CAPACITY: usize = 2000;
|
||||||
/// Path to the persisted event db. Overridable via `HYPERHIVE_EVENTS_DB`
|
/// 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 {
|
fn events_db_path() -> PathBuf {
|
||||||
std::env::var_os("HYPERHIVE_EVENTS_DB").map_or_else(
|
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,
|
PathBuf::from,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path to the persisted model file. Overridable via `HYPERHIVE_MODEL_FILE`
|
/// 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 {
|
fn model_file_path() -> PathBuf {
|
||||||
std::env::var_os("HYPERHIVE_MODEL_FILE").map_or_else(
|
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,
|
PathBuf::from,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
//! Claude credentials live at `$HOME/.claude` (resolves to
|
||||||
//! `/home/<agent-name>/.claude` because the harness service runs as a
|
//! `/home/<agent-name>/.claude` because the harness service runs as a
|
||||||
//! non-root unix user matching the agent label — see
|
//! non-root unix user matching the agent label — see
|
||||||
//! `docs/persistence.md::First-boot agent-user migration`).
|
//! `docs/persistence.md::First-boot agent-user migration`).
|
||||||
//!
|
//!
|
||||||
//! Both paths can be overridden via env vars (`HYPERHIVE_STATE_DIR`,
|
//! All three paths can be overridden via env vars (`HYPERHIVE_STATE_DIR`,
|
||||||
//! `HYPERHIVE_CLAUDE_DIR`) for dev / test scenarios.
|
//! `HYPERHIVE_HARNESS_DIR`, `HYPERHIVE_CLAUDE_DIR`) for dev / test scenarios.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
|
@ -24,6 +27,20 @@ pub fn state_dir() -> PathBuf {
|
||||||
PathBuf::from(format!("/agents/{label}/state"))
|
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,
|
/// Per-turn config dir for the regenerated claude-{mcp-config,settings,
|
||||||
/// system-prompt} files the harness drops before each turn. Set by
|
/// system-prompt} files the harness drops before each turn. Set by
|
||||||
/// systemd via `RuntimeDirectory = "hive-config"`: a per-service runtime
|
/// systemd via `RuntimeDirectory = "hive-config"`: a per-service runtime
|
||||||
|
|
|
||||||
|
|
@ -262,5 +262,5 @@ impl TurnStats {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_path() -> PathBuf {
|
fn default_path() -> PathBuf {
|
||||||
crate::paths::state_dir().join("hyperhive-turn-stats.sqlite")
|
crate::paths::harness_dir().join("hyperhive-turn-stats.sqlite")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -875,13 +875,22 @@ impl Coordinator {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-agent durable knowledge dir. Bind-mounted RW into the agent
|
/// Per-agent durable knowledge dir. Bind-mounted RW into the agent
|
||||||
/// container at `/state`. Survives destroy/recreate alongside the
|
/// container at `/agents/{name}/state`. Survives destroy/recreate.
|
||||||
/// claude dir. Agents are told (via the system prompt) to write
|
/// Agent-visible — claude is told to write long-lived notes here.
|
||||||
/// long-lived notes / scratch state here.
|
|
||||||
pub fn agent_notes_dir(name: &str) -> PathBuf {
|
pub fn agent_notes_dir(name: &str) -> PathBuf {
|
||||||
Self::agent_state_root(name).join("state")
|
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.
|
/// Authoritative applied config repo. Hive-c0re-only.
|
||||||
pub fn agent_applied_dir(name: &str) -> PathBuf {
|
pub fn agent_applied_dir(name: &str) -> PathBuf {
|
||||||
PathBuf::from(format!("{APPLIED_STATE_ROOT}/{name}"))
|
PathBuf::from(format!("{APPLIED_STATE_ROOT}/{name}"))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
//! Host-side vacuum of every per-agent events.sqlite. The harness
|
//! Host-side vacuum of every per-agent events.sqlite. The harness
|
||||||
//! writes to `/state/hyperhive-events.sqlite` (bind-mounted from
|
//! writes to `/agents/<name>/harness/hyperhive-events.sqlite`
|
||||||
//! `/var/lib/hyperhive/agents/<name>/state/`); we open the same file
|
//! (bind-mounted from `/var/lib/hyperhive/agents/<name>/harness/`);
|
||||||
//! from the host every hour and delete rows older than `KEEP_SECS`.
|
//! 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
|
//! 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
|
//! sooner than a quiet one; disk pressure on a sustained burst is
|
||||||
//! a cheaper problem than a missing event when the operator is
|
//! a cheaper problem than a missing event when the operator is
|
||||||
|
|
@ -41,7 +42,7 @@ pub fn spawn(coord: &Arc<Coordinator>) {
|
||||||
|
|
||||||
fn sweep_once() {
|
fn sweep_once() {
|
||||||
for name in Coordinator::kept_state_names() {
|
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() {
|
if !path.exists() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -755,12 +755,21 @@ pub fn ensure_claude_dir(claude_dir: &Path) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public for the `InitConfig` approval path in `actions.rs` which seeds
|
/// 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<()> {
|
pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> {
|
||||||
if !notes_dir.exists() {
|
if !notes_dir.exists() {
|
||||||
std::fs::create_dir_all(notes_dir)
|
std::fs::create_dir_all(notes_dir)
|
||||||
.with_context(|| format!("create {}", notes_dir.display()))?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1086,15 +1095,30 @@ fn set_nspawn_flags(
|
||||||
shared = HOST_SHARED_ROOT,
|
shared = HOST_SHARED_ROOT,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Per-agent state at `/agents/<container>/state`. Skipped for
|
// Per-agent state + harness dirs. Skipped for the manager —
|
||||||
// the manager — the `/agents` bind below already exposes its
|
// the `/agents` bind below already exposes both (along with
|
||||||
// own state (along with every sub-agent's).
|
// every sub-agent's). For regular agents the harness dir is
|
||||||
|
// the sibling of notes_dir (same parent, "harness" subdir).
|
||||||
if container != MANAGER_NAME {
|
if container != MANAGER_NAME {
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
binds,
|
binds,
|
||||||
" --bind={notes}:/agents/{agent_name}/state",
|
" --bind={notes}:/agents/{agent_name}/state",
|
||||||
notes = notes_dir.display(),
|
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 {
|
if container == MANAGER_NAME {
|
||||||
// systemd-nspawn refuses to start a container whose bind
|
// systemd-nspawn refuses to start a container whose bind
|
||||||
|
|
|
||||||
|
|
@ -480,10 +480,12 @@ where
|
||||||
environment.variables = {
|
environment.variables = {
|
||||||
HIVE_LABEL = name;
|
HIVE_LABEL = name;
|
||||||
HYPERHIVE_STATE_DIR = "/agents/${name}/state";
|
HYPERHIVE_STATE_DIR = "/agents/${name}/state";
|
||||||
|
HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness";
|
||||||
};
|
};
|
||||||
systemd.globalEnvironment = {
|
systemd.globalEnvironment = {
|
||||||
HIVE_LABEL = name;
|
HIVE_LABEL = name;
|
||||||
HYPERHIVE_STATE_DIR = "/agents/${name}/state";
|
HYPERHIVE_STATE_DIR = "/agents/${name}/state";
|
||||||
|
HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness";
|
||||||
};
|
};
|
||||||
systemd.services.${service}.environment = parentEnv // toolGroupsEnv // {
|
systemd.services.${service}.environment = parentEnv // toolGroupsEnv // {
|
||||||
HIVE_PORT = toString port;
|
HIVE_PORT = toString port;
|
||||||
|
|
@ -521,6 +523,7 @@ where
|
||||||
}
|
}
|
||||||
out.push_str(
|
out.push_str(
|
||||||
r#" HYPERHIVE_STATE_DIR = "/agents/${name}/state";
|
r#" HYPERHIVE_STATE_DIR = "/agents/${name}/state";
|
||||||
|
HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,13 @@ pub async fn run(coord: &Arc<Coordinator>) -> Result<()> {
|
||||||
let names = enumerate_agents().await;
|
let names = enumerate_agents().await;
|
||||||
tracing::info!(count = names.len(), "migration: scanning");
|
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.
|
// Phase 1 + 2: per-agent applied + proposed.
|
||||||
for name in &names {
|
for name in &names {
|
||||||
if let Err(e) = migrate_applied_repo(name).await {
|
if let Err(e) = migrate_applied_repo(name).await {
|
||||||
|
|
@ -104,6 +111,35 @@ pub async fn run(coord: &Arc<Coordinator>) -> Result<()> {
|
||||||
Ok(())
|
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<String> {
|
async fn enumerate_agents() -> Vec<String> {
|
||||||
let containers = lifecycle::list().await.unwrap_or_default();
|
let containers = lifecycle::list().await.unwrap_or_default();
|
||||||
containers
|
containers
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Host-side vacuum of every per-agent turn-stats.sqlite. The harness
|
//! Host-side vacuum of every per-agent turn-stats.sqlite. The harness
|
||||||
//! writes to `/state/hyperhive-turn-stats.sqlite` (bind-mounted from
|
//! writes to `/agents/<name>/harness/hyperhive-turn-stats.sqlite`
|
||||||
//! `/var/lib/hyperhive/agents/<name>/state/`); we open the same file
|
//! (bind-mounted from `/var/lib/hyperhive/agents/<name>/harness/`); we open the same file
|
||||||
//! from the host every hour and delete rows older than `KEEP_SECS`.
|
//! from the host every hour and delete rows older than `KEEP_SECS`.
|
||||||
//! Mirrors `events_vacuum` in structure — host-side so the harness
|
//! Mirrors `events_vacuum` in structure — host-side so the harness
|
||||||
//! can't disable it, age-only so a chatty burst doesn't evict old
|
//! can't disable it, age-only so a chatty burst doesn't evict old
|
||||||
|
|
@ -40,7 +40,7 @@ pub fn spawn(coord: &Arc<Coordinator>) {
|
||||||
fn sweep_once() {
|
fn sweep_once() {
|
||||||
for name in Coordinator::kept_state_names() {
|
for name in Coordinator::kept_state_names() {
|
||||||
let path =
|
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() {
|
if !path.exists() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue