From 90f5162076e7d6bf004208fa283bf5f849100b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 01:43:03 +0200 Subject: [PATCH 1/5] kick_agent: use per-recipient state path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manager keeps /state (legacy mount); sub-agents see their state at /agents//state. wake message hardcoded /state/ for everyone, which is wrong for sub-agents post-refactor — they get a path they can't ls. switch on MANAGER_NAME and format the right path. --- hive-c0re/src/coordinator.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index ec21b0d5..4b448616 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -178,15 +178,25 @@ impl Coordinator { /// Drop a system message into the given agent's inbox. Wakes the /// turn loop with a "you were just (re)started" hint — operator /// caused the transition, agent picks up where it left off - /// (notes are in /state/, last turn is in --continue's session). - /// Best-effort; broker errors are logged but don't propagate. + /// (notes are in the bind-mounted state dir, last turn is in + /// --continue's session). Best-effort; broker errors are logged + /// but don't propagate. pub fn kick_agent(&self, name: &str, reason: &str) { + // Manager keeps the legacy `/state` mount; sub-agents see + // their state at `/agents//state` (see lifecycle.rs's + // `notes_mount`). Tell each agent the path it can actually + // ls without translating. + let state_path = if name == crate::lifecycle::MANAGER_NAME { + "/state/".to_owned() + } else { + format!("/agents/{name}/state/") + }; let body = format!( "{reason}\n\nYou were just (re)started by the operator. \ - If you were mid-task, check `/state/` for your notes \ - and pick up where you left off. claude's `--continue` \ - session is intact, so prior context is still in your \ - window." + If you were mid-task, check `{state_path}` for your \ + notes and pick up where you left off. claude's \ + `--continue` session is intact, so prior context is \ + still in your window." ); if let Err(e) = self.broker.send(&hive_sh4re::Message { from: hive_sh4re::SYSTEM_SENDER.to_owned(), From bf20d99142b6687ff31581297e0216a05ade4c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 01:43:42 +0200 Subject: [PATCH 2/5] kick_agent: use /agents//state uniformly manager has /agents bind-mounted too, so /agents/hm1nd/state resolves there alongside the legacy /state. one canonical path in the wake message instead of branching on MANAGER_NAME. --- hive-c0re/src/coordinator.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 4b448616..bd8fd07e 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -182,19 +182,15 @@ impl Coordinator { /// --continue's session). Best-effort; broker errors are logged /// but don't propagate. pub fn kick_agent(&self, name: &str, reason: &str) { - // Manager keeps the legacy `/state` mount; sub-agents see - // their state at `/agents//state` (see lifecycle.rs's - // `notes_mount`). Tell each agent the path it can actually - // ls without translating. - let state_path = if name == crate::lifecycle::MANAGER_NAME { - "/state/".to_owned() - } else { - format!("/agents/{name}/state/") - }; + // Sub-agents bind their state at /agents//state. The + // manager has both /state (legacy mount) and /agents + // bind-mounted, so /agents//state resolves there too — + // use that uniformly so the wake message has one canonical + // path that works everywhere. let body = format!( "{reason}\n\nYou were just (re)started by the operator. \ - If you were mid-task, check `{state_path}` for your \ - notes and pick up where you left off. claude's \ + If you were mid-task, check `/agents/{name}/state/` for \ + your notes and pick up where you left off. claude's \ `--continue` session is intact, so prior context is \ still in your window." ); From db87167469e0d3511eb30592a298a4480e028de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 01:47:54 +0200 Subject: [PATCH 3/5] forge: seed core admin user + 'core'/'agents' orgs on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new ensure_core_user_and_token mints a site-admin 'core' user with its token at /var/lib/hyperhive/forge-core-token (root 0600) — hive-c0re's own forge identity for pushing the meta repo + driving the admin API. that token then drives ensure_org for 'core' (meta repo lives here) and 'agents' (per-agent applied config repos). both org-create calls are idempotent: HTTP 422/409 treated as success. failures log but don't abort the rest of the sweep. curl is shelled out from the host — already on the hive-c0re service PATH via /run/current-system/sw, no new dep. --- hive-c0re/src/forge.rs | 114 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 104 insertions(+), 10 deletions(-) diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 228fd6ef..e007ee40 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -18,7 +18,17 @@ use tokio::process::Command; use crate::coordinator::Coordinator; const FORGE_CONTAINER: &str = "hive-forge"; +const FORGE_HTTP: &str = "http://localhost:3000"; const TOKEN_NAME_PREFIX: &str = "hyperhive"; +/// Where the host-side `core` admin token lives. Used by hive-c0re +/// itself to push the meta repo + drive admin API calls (org +/// creation, future webhook setup, etc.). Root-only. +const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token"; +/// Forgejo orgs hive-c0re ensures on startup. `core` holds the meta +/// repo (pushed by the `core` user); `agents` holds per-agent +/// applied config repos (pushed by hive-c0re on every deploy, or by +/// the manager when staging proposals). +const SEEDED_ORGS: &[&str] = &["core", "agents"]; /// Forgejo scopes the agent's token gets. Broad-but-not-admin: every /// repo / PR / issue thing an agent needs day-to-day, no admin /// surface. @@ -111,18 +121,23 @@ fn extract_token(output: &str) -> Option { /// Ensure a forgejo user named `name` exists. Idempotent: forgejo /// returns a "user already exists" error which we treat as success. -async fn ensure_user_exists(name: &str) -> Result<()> { - let result = forge_admin(&[ +/// `admin` adds `--admin` (site admin) — used for the bootstrap +/// `core` user that drives the API. +async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> { + let mut args = vec![ "user", "create", "--username", name, "--email", - &format!("{name}@hive.local"), - "--random-password", - "--must-change-password=false", - ]) - .await; + ]; + let email = format!("{name}@hive.local"); + args.push(&email); + args.extend(["--random-password", "--must-change-password=false"]); + if admin { + args.push("--admin"); + } + let result = forge_admin(&args).await; match result { Ok(_) => { tracing::info!(%name, "forge: created user"); @@ -191,18 +206,97 @@ pub async fn ensure_user_for(name: &str) -> Result<()> { if path.exists() { return Ok(()); } - ensure_user_exists(name).await?; + ensure_user_exists(name, false).await?; mint_and_persist_token(name, &path).await } +/// Ensure the bootstrap `core` admin user + a token at +/// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo +/// API calls (org creation now, meta-repo push later). Returns the +/// token. Idempotent: skips creation when user exists, skips token +/// when the file is present. +async fn ensure_core_user_and_token() -> Result { + let path = std::path::Path::new(CORE_TOKEN_PATH); + if let Ok(existing) = std::fs::read_to_string(path) { + let trimmed = existing.trim().to_owned(); + if !trimmed.is_empty() { + return Ok(trimmed); + } + } + ensure_user_exists("core", true).await?; + mint_and_persist_token("core", path).await?; + let raw = std::fs::read_to_string(path) + .with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?; + Ok(raw.trim().to_owned()) +} + +/// POST `/api/v1/orgs` to create an org named `name`. Idempotent: +/// HTTP 422 ("user already exists") is treated as success. +async fn ensure_org(name: &str, admin_token: &str) -> Result<()> { + let body = format!(r#"{{"username":"{name}"}}"#); + let url = format!("{FORGE_HTTP}/api/v1/orgs"); + let out = Command::new("curl") + .args([ + "-sS", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-X", + "POST", + "-H", + "Content-Type: application/json", + "-H", + &format!("Authorization: token {admin_token}"), + "-d", + &body, + &url, + ]) + .output() + .await + .context("invoke curl POST /api/v1/orgs")?; + let code = String::from_utf8_lossy(&out.stdout).trim().to_owned(); + match code.as_str() { + "201" => { + tracing::info!(%name, "forge: created org"); + Ok(()) + } + "422" | "409" => { + tracing::debug!(%name, "forge: org already exists"); + Ok(()) + } + other => anyhow::bail!( + "POST /api/v1/orgs name={name} returned HTTP {other}: {}", + String::from_utf8_lossy(&out.stderr).trim() + ), + } +} + /// Sweep every existing container (manager + sub-agents) and ensure -/// each has a forgejo user + token. Called once at hive-c0re -/// startup. Per-agent failures are logged but don't abort the sweep. +/// each has a forgejo user + token. Also seeds the `core` admin +/// user (hive-c0re's own identity for pushing the meta repo + driving +/// the API) and the `core` / `agents` orgs the system pushes into. +/// Called once at hive-c0re startup. Per-step failures are logged +/// but don't abort the sweep. pub async fn ensure_all() { if !is_present().await { tracing::debug!("forge: hive-forge container absent, skipping user sweep"); return; } + let core_token = match ensure_core_user_and_token().await { + Ok(t) => Some(t), + Err(e) => { + tracing::warn!(error = ?e, "forge: ensure_core_user_and_token failed"); + None + } + }; + if let Some(token) = core_token.as_deref() { + for org in SEEDED_ORGS { + if let Err(e) = ensure_org(org, token).await { + tracing::warn!(%org, error = ?e, "forge: ensure_org failed"); + } + } + } let Ok(containers) = crate::lifecycle::list().await else { tracing::warn!("forge: nixos-container list failed; skipping user sweep"); return; From 68020a15c9ce13c4b3ba0187d20d8ce5d8d5c88f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 01:50:12 +0200 Subject: [PATCH 4/5] =?UTF-8?q?forge:=20drop=20redundant=20'core'=20org=20?= =?UTF-8?q?=E2=80=94=20meta=20repo=20lives=20under=20core=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hive-c0re/src/forge.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index e007ee40..9097da04 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -24,11 +24,12 @@ const TOKEN_NAME_PREFIX: &str = "hyperhive"; /// itself to push the meta repo + drive admin API calls (org /// creation, future webhook setup, etc.). Root-only. const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token"; -/// Forgejo orgs hive-c0re ensures on startup. `core` holds the meta -/// repo (pushed by the `core` user); `agents` holds per-agent -/// applied config repos (pushed by hive-c0re on every deploy, or by -/// the manager when staging proposals). -const SEEDED_ORGS: &[&str] = &["core", "agents"]; +/// Forgejo orgs hive-c0re ensures on startup. The meta repo lives +/// at `core/meta` (the `core` user's own namespace — no org needed); +/// `agents` holds per-agent applied config repos so they're +/// visible/grouped together and access can be granted org-wide +/// (RO membership for the future shared docs/skills repo). +const SEEDED_ORGS: &[&str] = &["agents"]; /// Forgejo scopes the agent's token gets. Broad-but-not-admin: every /// repo / PR / issue thing an agent needs day-to-day, no admin /// surface. From 600ed509f429866bc2a714f4a89886a7d0e4be98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 17 May 2026 01:52:00 +0200 Subject: [PATCH 5/5] forge: ensure core/meta repo + mirror meta commits to forge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startup sweep adds ensure_repo('meta', core_token) after the orgs so the first push isn't a 404. meta::git_commit now calls forge::push_meta after every successful commit — token-in-URL `git push http://core:$token@localhost:3000/core/meta.git` — gated on the core token file existing (no-op when forge isn't seeded). push failures log warn, don't bubble up. no tea needed on the host; git is already on the hive-c0re service PATH via /run/current-system/sw. --- hive-c0re/src/forge.rs | 86 ++++++++++++++++++++++++++++++++++++++++++ hive-c0re/src/meta.rs | 10 ++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 9097da04..7018f327 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -231,6 +231,86 @@ async fn ensure_core_user_and_token() -> Result { Ok(raw.trim().to_owned()) } +/// POST `/api/v1/user/repos` to create a repo in the authenticated +/// user's own namespace. `token` belongs to the user we want the +/// repo owned by (we use `core`'s token for `core/meta`). Idempotent: +/// HTTP 409 ("repository already exists") is treated as success. +pub async fn ensure_repo(name: &str, token: &str) -> Result<()> { + let body = format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#); + let url = format!("{FORGE_HTTP}/api/v1/user/repos"); + let out = Command::new("curl") + .args([ + "-sS", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-X", + "POST", + "-H", + "Content-Type: application/json", + "-H", + &format!("Authorization: token {token}"), + "-d", + &body, + &url, + ]) + .output() + .await + .context("invoke curl POST /api/v1/user/repos")?; + let code = String::from_utf8_lossy(&out.stdout).trim().to_owned(); + match code.as_str() { + "201" => { + tracing::info!(%name, "forge: created repo"); + Ok(()) + } + "409" | "422" => { + tracing::debug!(%name, "forge: repo already exists"); + Ok(()) + } + other => anyhow::bail!( + "POST /api/v1/user/repos name={name} returned HTTP {other}" + ), + } +} + +/// Read the persisted core token, or None when the forge isn't +/// seeded yet. Cheap — just a file read. +pub fn core_token() -> Option { + std::fs::read_to_string(CORE_TOKEN_PATH) + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) +} + +/// Push `dir` (the meta repo) to `core/meta` on the local forge. +/// Best-effort: returns Err which callers log + ignore. No-op when +/// the core token isn't present (forge not enabled). +pub async fn push_meta(dir: &Path) -> Result<()> { + let Some(token) = core_token() else { + return Ok(()); + }; + // Token-in-URL push. Forgejo accepts `oauth2:` or just + // any-username:; using `core` matches the owner so the + // remote name is self-describing. + let url = format!("http://core:{token}@localhost:3000/core/meta.git"); + let out = Command::new("git") + .current_dir(dir) + .args(["push", "--force", &url, "HEAD:main"]) + .output() + .await + .context("invoke git push core/meta")?; + if !out.status.success() { + anyhow::bail!( + "git push core/meta failed ({}): {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + tracing::info!("forge: pushed meta to core/meta"); + Ok(()) +} + /// POST `/api/v1/orgs` to create an org named `name`. Idempotent: /// HTTP 422 ("user already exists") is treated as success. async fn ensure_org(name: &str, admin_token: &str) -> Result<()> { @@ -297,6 +377,12 @@ pub async fn ensure_all() { tracing::warn!(%org, error = ?e, "forge: ensure_org failed"); } } + // Meta repo lives at core/meta — pushed from git_commit in + // meta.rs on every deploy/lock-update. Make sure it exists + // before the first push hits a 404. + if let Err(e) = ensure_repo("meta", token).await { + tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed"); + } } let Ok(containers) = crate::lifecycle::list().await else { tracing::warn!("forge: nixos-container list failed; skipping user sweep"); diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 8f5bb882..6774e669 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -342,7 +342,15 @@ async fn git_commit(dir: &Path, message: &str) -> Result<()> { message, ], ) - .await + .await?; + // Best-effort mirror to the bundled forge. No-op when the forge + // isn't seeded (no core token on disk); push failures log a warn + // but don't bubble up — a missing mirror shouldn't fail an + // otherwise successful deploy. + if let Err(e) = crate::forge::push_meta(dir).await { + tracing::warn!(error = ?e, "forge: meta push after commit failed (non-fatal)"); + } + Ok(()) } async fn nix(dir: &Path, args: &[&str]) -> Result<()> {