per-agent /state dir for durable notes; manager sees them via /agents

This commit is contained in:
müde 2026-05-15 18:00:08 +02:00
parent 7be64c5e66
commit ff8f8c7c56
10 changed files with 66 additions and 10 deletions

View file

@ -37,6 +37,7 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent);
let applied_dir = Coordinator::agent_applied_dir(&approval.agent);
let claude_dir = Coordinator::agent_claude_dir(&approval.agent);
let notes_dir = Coordinator::agent_notes_dir(&approval.agent);
match approval.kind {
ApprovalKind::ApplyCommit => {
@ -48,6 +49,7 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
&agent_dir,
&applied_dir,
&claude_dir,
&notes_dir,
coord.dashboard_port,
)
.await
@ -70,6 +72,7 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
&proposed_dir,
&applied_dir,
&claude_dir,
&notes_dir,
coord_bg.dashboard_port,
)
.await;

View file

@ -58,12 +58,14 @@ pub async fn rebuild_agent(coord: &Arc<Coordinator>, name: &str, current_rev: &s
.with_context(|| format!("ensure_runtime {name}"))?;
let applied_dir = Coordinator::agent_applied_dir(name);
let claude_dir = Coordinator::agent_claude_dir(name);
let notes_dir = Coordinator::agent_notes_dir(name);
let result = lifecycle::rebuild(
name,
&coord.hyperhive_flake,
&agent_dir,
&applied_dir,
&claude_dir,
&notes_dir,
coord.dashboard_port,
)
.await;
@ -122,6 +124,7 @@ pub async fn ensure_manager(coord: &Arc<Coordinator>) -> Result<()> {
let proposed = Coordinator::agent_proposed_dir(MANAGER_NAME);
let applied = Coordinator::agent_applied_dir(MANAGER_NAME);
let claude_dir = Coordinator::agent_claude_dir(MANAGER_NAME);
let notes_dir = Coordinator::agent_notes_dir(MANAGER_NAME);
lifecycle::spawn(
MANAGER_NAME,
&coord.hyperhive_flake,
@ -129,6 +132,7 @@ pub async fn ensure_manager(coord: &Arc<Coordinator>) -> Result<()> {
&proposed,
&applied,
&claude_dir,
&notes_dir,
coord.dashboard_port,
)
.await?;

View file

@ -178,6 +178,14 @@ impl Coordinator {
Self::agent_state_root(name).join("claude")
}
/// 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.
pub fn agent_notes_dir(name: &str) -> PathBuf {
Self::agent_state_root(name).join("state")
}
/// Authoritative applied config repo. Hive-c0re-only.
pub fn agent_applied_dir(name: &str) -> PathBuf {
PathBuf::from(format!("{APPLIED_STATE_ROOT}/{name}"))

View file

@ -26,6 +26,11 @@ pub const CONTAINER_RUNTIME_MOUNT: &str = "/run/hive";
/// Persistent across destroy/recreate so OAuth login survives.
pub const CONTAINER_CLAUDE_MOUNT: &str = "/root/.claude";
/// Mount point of the per-agent durable knowledge dir inside the container.
/// Agents are told (system prompt) to keep `notes.md` and any other scratch
/// state here; persists across destroy/recreate.
pub const CONTAINER_NOTES_MOUNT: &str = "/state";
const GIT_NAME: &str = "hive-c0re";
const GIT_EMAIL: &str = "hive-c0re@hyperhive";
@ -98,6 +103,7 @@ fn validate(name: &str) -> Result<()> {
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn spawn(
name: &str,
hyperhive_flake: &str,
@ -105,16 +111,18 @@ pub async fn spawn(
proposed_dir: &Path,
applied_dir: &Path,
claude_dir: &Path,
notes_dir: &Path,
dashboard_port: u16,
) -> Result<()> {
validate(name)?;
setup_proposed(proposed_dir, name).await?;
setup_applied(applied_dir, name, hyperhive_flake, dashboard_port).await?;
ensure_claude_dir(claude_dir)?;
ensure_state_dir(notes_dir)?;
let container = container_name(name);
let flake_ref = format!("{}#default", applied_dir.display());
run(&["create", &container, "--flake", &flake_ref]).await?;
set_nspawn_flags(&container, agent_dir, claude_dir)?;
set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir)?;
set_resource_limits(&container)?;
systemd_daemon_reload().await?;
run(&["start", &container]).await
@ -176,14 +184,16 @@ pub async fn rebuild(
agent_dir: &Path,
applied_dir: &Path,
claude_dir: &Path,
notes_dir: &Path,
dashboard_port: u16,
) -> Result<()> {
validate(name)?;
setup_applied(applied_dir, name, hyperhive_flake, dashboard_port).await?;
ensure_claude_dir(claude_dir)?;
ensure_state_dir(notes_dir)?;
let container = container_name(name);
let flake_ref = format!("{}#default", applied_dir.display());
set_nspawn_flags(&container, agent_dir, claude_dir)?;
set_nspawn_flags(&container, agent_dir, claude_dir, notes_dir)?;
set_resource_limits(&container)?;
systemd_daemon_reload().await?;
run(&["update", &container, "--flake", &flake_ref]).await?;
@ -349,6 +359,14 @@ fn ensure_claude_dir(claude_dir: &Path) -> Result<()> {
Ok(())
}
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()))?;
}
Ok(())
}
fn initial_agent_nix(name: &str) -> String {
format!(
"{{ ... }}:\n{{\n # Per-agent overrides for {name}. The manager edits this\n # file (and commits) to customise the agent's NixOS config.\n}}\n",
@ -458,13 +476,19 @@ pub const CONTAINER_MANAGER_AGENTS_MOUNT: &str = "/agents";
/// here so lifecycle stays usable as a leaf module).
const HOST_AGENTS_ROOT: &str = "/var/lib/hyperhive/agents";
fn set_nspawn_flags(container: &str, runtime_dir: &Path, claude_dir: &Path) -> Result<()> {
fn set_nspawn_flags(
container: &str,
runtime_dir: &Path,
claude_dir: &Path,
notes_dir: &Path,
) -> Result<()> {
let path = format!("/etc/nixos-containers/{container}.conf");
let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?;
let mut binds = format!(
"--bind={runtime}:{CONTAINER_RUNTIME_MOUNT} --bind={claude}:{CONTAINER_CLAUDE_MOUNT}",
"--bind={runtime}:{CONTAINER_RUNTIME_MOUNT} --bind={claude}:{CONTAINER_CLAUDE_MOUNT} --bind={notes}:{CONTAINER_NOTES_MOUNT}",
runtime = runtime_dir.display(),
claude = claude_dir.display(),
notes = notes_dir.display(),
);
if container == MANAGER_NAME {
// Manager edits sub-agent proposed/ repos and its own. RW so it can

View file

@ -65,6 +65,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
let proposed_dir = Coordinator::agent_proposed_dir(name);
let applied_dir = Coordinator::agent_applied_dir(name);
let claude_dir = Coordinator::agent_claude_dir(name);
let notes_dir = Coordinator::agent_notes_dir(name);
match lifecycle::spawn(
name,
&coord.hyperhive_flake,
@ -72,6 +73,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
&proposed_dir,
&applied_dir,
&claude_dir,
&notes_dir,
coord.dashboard_port,
)
.await
@ -122,12 +124,14 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
let agent_dir = coord.ensure_runtime(name)?;
let applied_dir = Coordinator::agent_applied_dir(name);
let claude_dir = Coordinator::agent_claude_dir(name);
let notes_dir = Coordinator::agent_notes_dir(name);
lifecycle::rebuild(
name,
&coord.hyperhive_flake,
&agent_dir,
&applied_dir,
&claude_dir,
&notes_dir,
coord.dashboard_port,
)
.await?;