agents: persistent /agents/<n>/work dir outside state/
Agents keep git worktrees and cargo target dirs under /home/<agent>, which lives in the container rootfs and is deleted on destroy. They can't move them under state/, because a claude started there walks up into state/CLAUDE.md and takes on the agent's identity. Add a per-agent work/ dir next to state/ on the host (/var/lib/hyperhive/agents/<n>/work), bind-mounted RW at /agents/<n>/work. hive-c0re creates it in ensure_state_dir and in set_nspawn_flags (the rebuild path reaches only the latter), the agent's activation chowns it to the agent user like state/ and harness/, and measure_agent_disk counts it. destroy never touches the agent root, so work/ survives it; purge removes the whole root, so work/ goes with it. Closes #4977
This commit is contained in:
parent
962c1548eb
commit
89a5d65119
8 changed files with 123 additions and 36 deletions
|
|
@ -188,6 +188,30 @@ fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Append RW bind flags for `agent_name`'s own `state`, `harness` and `work`
|
||||
/// dirs into `binds`. `harness` and `work` are siblings of `notes_dir`;
|
||||
/// missing ones are created so nspawn doesn't refuse to start.
|
||||
fn bind_own_agent_dirs(agent_name: &str, notes_dir: &Path, binds: &mut Vec<BindMount>) {
|
||||
binds.push(BindMount {
|
||||
host_path: notes_dir.to_string_lossy().into_owned(),
|
||||
container_path: format!("/agents/{agent_name}/state"),
|
||||
read_only: false,
|
||||
});
|
||||
if let Some(state_parent) = notes_dir.parent() {
|
||||
for sub in ["harness", "work"] {
|
||||
let dir = state_parent.join(sub);
|
||||
if !dir.exists() {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
}
|
||||
binds.push(BindMount {
|
||||
host_path: dir.to_string_lossy().into_owned(),
|
||||
container_path: format!("/agents/{agent_name}/{sub}"),
|
||||
read_only: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `agent_name` holds `ManageRootAgent` per the capabilities file
|
||||
/// at `caps_path`. An unreadable file denies: the error is logged and the
|
||||
/// agent starts without the cross-agent mounts, so one corrupt file never
|
||||
|
|
@ -362,25 +386,10 @@ async fn set_nspawn_flags(
|
|||
},
|
||||
];
|
||||
|
||||
// Own state, harness, and config dirs — same for every agent including
|
||||
// the manager. Config is RO: an agent must not edit its own config; changes
|
||||
// only ever flow through a config PR on the forge.
|
||||
binds.push(BindMount {
|
||||
host_path: notes_dir.to_string_lossy().into_owned(),
|
||||
container_path: format!("/agents/{agent_name}/state"),
|
||||
read_only: false,
|
||||
});
|
||||
if let Some(state_parent) = notes_dir.parent() {
|
||||
let harness_dir = state_parent.join("harness");
|
||||
if !harness_dir.exists() {
|
||||
let _ = std::fs::create_dir_all(&harness_dir);
|
||||
}
|
||||
binds.push(BindMount {
|
||||
host_path: harness_dir.to_string_lossy().into_owned(),
|
||||
container_path: format!("/agents/{agent_name}/harness"),
|
||||
read_only: false,
|
||||
});
|
||||
}
|
||||
// Own state, harness, work, and config dirs — same for every agent
|
||||
// including the manager. Config is RO: an agent must not edit its own
|
||||
// config; changes only ever flow through a config PR on the forge.
|
||||
bind_own_agent_dirs(agent_name, notes_dir, &mut binds);
|
||||
hive_types::Ident::parse(agent_name)
|
||||
.map_err(|e| anyhow::anyhow!("invalid agent name {agent_name:?}: {e}"))?;
|
||||
let own_config = config_bind_source(agent_name);
|
||||
|
|
@ -476,7 +485,7 @@ async fn set_nspawn_flags(
|
|||
mod tests {
|
||||
use super::{
|
||||
BindMount, QUEUE_CLIENT_ID_CREDENTIAL, QUEUE_SECRET_CREDENTIAL, bind_child_agent_dirs,
|
||||
holds_manage_root_agent, limits_to_apply, queue_agent_credentials,
|
||||
bind_own_agent_dirs, holds_manage_root_agent, limits_to_apply, queue_agent_credentials,
|
||||
};
|
||||
use crate::coordinator::HiveEnv;
|
||||
|
||||
|
|
@ -539,6 +548,25 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// An agent's own `work` dir is bound RW at `/agents/<n>/work` from the
|
||||
/// sibling of its state dir, and the host side exists afterwards —
|
||||
/// nspawn refuses a missing bind source.
|
||||
#[test]
|
||||
fn own_binds_include_a_writable_work_dir() {
|
||||
let root = tempfile::tempdir().expect("tempdir");
|
||||
let notes_dir = root.path().join("state");
|
||||
let mut binds = Vec::new();
|
||||
bind_own_agent_dirs("iris", ¬es_dir, &mut binds);
|
||||
let work_dir = root.path().join("work");
|
||||
let work = binds
|
||||
.iter()
|
||||
.find(|b| b.container_path == "/agents/iris/work")
|
||||
.expect("a work bind");
|
||||
assert_eq!(work.host_path, work_dir.to_string_lossy());
|
||||
assert!(!work.read_only);
|
||||
assert!(work_dir.is_dir());
|
||||
}
|
||||
|
||||
/// An unparseable name yields no mounts at all — the guard must fail
|
||||
/// closed, since the alternative is a path built from unvalidated
|
||||
/// input.
|
||||
|
|
|
|||
|
|
@ -210,18 +210,20 @@ pub fn ensure_claude_dir(claude_dir: &Path) -> Result<()> {
|
|||
|
||||
/// Create the per-agent state dir if missing. Also creates the sibling
|
||||
/// `harness/` dir so the first harness startup can write its sqlite files
|
||||
/// immediately.
|
||||
/// immediately, and the sibling `work/` dir.
|
||||
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.
|
||||
// Harness and work dirs are siblings 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()))?;
|
||||
for sibling in ["harness", "work"] {
|
||||
let dir = parent.join(sibling);
|
||||
if !dir.exists() {
|
||||
std::fs::create_dir_all(&dir)
|
||||
.with_context(|| format!("create {}", dir.display()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -230,7 +232,7 @@ pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> {
|
|||
/// Ensure agent `name`'s persistent state root
|
||||
/// (`/var/lib/hyperhive/agents/<name>`) is a btrfs subvolume — when the host
|
||||
/// filesystem supports it — BEFORE the per-agent subdirs (`state/`, `claude/`,
|
||||
/// `harness/`) are created by `ensure_state_dir` / `ensure_claude_dir`.
|
||||
/// `harness/`, `work/`) are created by `ensure_state_dir` / `ensure_claude_dir`.
|
||||
///
|
||||
/// Progressive enhancement: if the root already exists
|
||||
/// (any agent provisioned before this landed, plain dir or subvol) it's left
|
||||
|
|
|
|||
Loading…
Reference in a new issue