Compare commits
32 changed files with 398 additions and 108 deletions
|
|
@ -80,14 +80,19 @@ module docs for the exact call chain.
|
|||
|
||||
The **manager (`ruth`)** differs from other agents in naming/bootstrap,
|
||||
its socket flavour and its default permissions; its prompt, tool
|
||||
allow-list and state dirs work as for every other agent.
|
||||
allow-list and state dirs work as for every other agent. A hive with
|
||||
`services.hyperhive.ruthless = true` runs no manager: `hive-c0re` skips
|
||||
its root-agent create/start sweep
|
||||
(`hive-c0re/src/workers/auto_update.rs`), and this section doesn't
|
||||
apply.
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
- **Naming/bootstrap** — the manager's broker recipient name, state-dir
|
||||
key, and nixos-container name are all `ruth` (container `h-ruth`).
|
||||
Like every agent it comes from the swarm dashboard or
|
||||
`swarmctl agent create`; `hive-c0re` creates nothing at boot.
|
||||
`hive-c0re` spawns it directly at boot if missing, with no operator
|
||||
approval step — every other agent is created at swarm level
|
||||
(`swarmctl agent create`).
|
||||
Roster-wise, `ruth` is just another entry.
|
||||
- **Wire-protocol** — `hive-core-agent-sock`'s unified `Request` enum
|
||||
is the same on both socket flavours. Container lifecycle
|
||||
|
|
|
|||
|
|
@ -106,8 +106,9 @@ notes) is
|
|||
reuses prior config + login. With `purge = true` the agent's
|
||||
`/var/lib/hyperhive/{agents,applied}/<name>/` trees are also
|
||||
wiped (config history + creds + notes gone forever). The
|
||||
root/bootstrap container is destroyable like any other, and stays gone
|
||||
until you create ruth again from the swarm dashboard.
|
||||
root/bootstrap container is destroyable like any other — hive-c0re
|
||||
recreates it on the next startup if it's absent, so destroying it's
|
||||
transient.
|
||||
|
||||
## Meta flake
|
||||
|
||||
|
|
@ -142,7 +143,7 @@ autoupdate flake-rev bump (one shot before per-agent rebuilds).
|
|||
|
||||
`meta::sync_agents(hive: &HiveEnv, agents: &[AgentSpec])` — `hive`
|
||||
carries `hyperhive_flake`, `dashboard_port`, and the rest of the
|
||||
per-hive config — is the idempotent reconciler called by `provision_container`,
|
||||
per-hive config — is the idempotent reconciler called by `spawn`,
|
||||
`destroy`, `rebuild`, and the startup migration. Renders `flake.nix`
|
||||
from the agent list; if it differs from disk, runs
|
||||
`nix flake lock` + commits as `regenerate meta flake` (or
|
||||
|
|
@ -313,12 +314,11 @@ No state loss in either migration: claude creds, `/state/` notes, the
|
|||
events DB, and both proposed + applied history all survive. The root
|
||||
agent keeps its session; sub-agents stay logged in.
|
||||
|
||||
## The root/bootstrap container
|
||||
## The root/bootstrap container is hive-c0re-managed
|
||||
|
||||
The root agent container runs through the **same lifecycle as
|
||||
sub-agents**. Nothing creates it at hive boot: you create ruth from the
|
||||
swarm dashboard like any agent, and an existing ruth container starts with
|
||||
the hive like every other. The root agent's flake lives at
|
||||
sub-agents**. On `hive-c0re serve` startup, if `ruth` is missing,
|
||||
hive-c0re creates it. The root agent's flake lives at
|
||||
`/var/lib/hyperhive/applied/ruth/`; its proposed config at
|
||||
`/var/lib/hyperhive/agents/ruth/config/`. The root agent can edit its own
|
||||
`agent.nix` (visible inside the container at `/agents/ruth/config/`)
|
||||
|
|
@ -336,13 +336,14 @@ Differences from sub-agents:
|
|||
and `/var/lib/hyperhive/applied` → `/applied` (RO) so the root agent
|
||||
can `git fetch` deployed/failed/denied tags from any agent's
|
||||
authoritative applied repo (see "Root-agent view of applied" below).
|
||||
- Its socket comes from the generic per-agent registration, in the same
|
||||
- `socket_server::start_manager` binds the root agent's socket,
|
||||
pure transport with no dedicated helpers — it uses the same
|
||||
per-agent runtime dir as any other agent (`/run/hyperhive/agents/ruth/`),
|
||||
not a special manager-only path.
|
||||
|
||||
**Migration note** (for older hosts): drop any `containers.root =
|
||||
{ ... }` block from your host NixOS config, and any `services.hyperhive.ruthless`
|
||||
definition, then create ruth from the swarm dashboard.
|
||||
{ ... }` block from your host NixOS config. hyperhive creates and
|
||||
updates the root agent itself.
|
||||
|
||||
## Root-agent policy
|
||||
|
||||
|
|
|
|||
|
|
@ -31,9 +31,9 @@ Beyond that:
|
|||
restart and rebuild** — only an explicit purge (or a hive
|
||||
`--purge`-style host operation, or the agent's own choices) touches
|
||||
them.
|
||||
- The **root/bootstrap agent has no guard**: destroying ruth is as final as
|
||||
for any agent, and she stays gone until you create her again from the
|
||||
swarm dashboard.
|
||||
- The **root/bootstrap agent is special**: it isn't really destroyable
|
||||
in practice — hive-c0re recreates it automatically on its next
|
||||
startup if it's ever gone.
|
||||
|
||||
Everything below this point is implementation detail: exact table
|
||||
schemas, file layouts, and internal migration mechanics.
|
||||
|
|
@ -432,9 +432,11 @@ an agent's state. The mechanics, for completeness:
|
|||
- `PURG3` wipes `/var/lib/hyperhive/{agents,applied}/<name>/` — the
|
||||
union of everything `DESTR0Y` left behind.
|
||||
|
||||
`actions::destroy` has no guard for the root/bootstrap agent: it destroys ruth
|
||||
like any other agent, and she stays gone until you create her again from the
|
||||
swarm dashboard.
|
||||
`actions::destroy` implements the root/bootstrap agent's specialness as a soft policy
|
||||
guard that refuses to destroy it, backstopped by
|
||||
`auto_update::ensure_root_agent`, which recreates it on the next
|
||||
hive-c0re startup if it's ever absent — so even without the guard,
|
||||
destroying it would only be transient.
|
||||
|
||||
### btrfs subvolumes for `/var/lib/hyperhive/agents/<name>`
|
||||
|
||||
|
|
|
|||
|
|
@ -89,20 +89,24 @@ swarmctl forge make-admin mara
|
|||
If the forge already has a local account with your username, the first
|
||||
SSO login asks for its forge password once, to link the two.
|
||||
|
||||
## 4 · Ruth
|
||||
## 4 · Ruth's store identity
|
||||
|
||||
hive-c0re creates no agent at startup. You create ruth, the manager agent,
|
||||
like any other: open the swarm UI start page and click the paperclip, which
|
||||
opens the create form with `ruth` filled in. The form declares her, creates
|
||||
her config repo and deploys her to the hive you pick.
|
||||
hive-c0re creates the manager agent, ruth, on its own at startup — so
|
||||
unlike agents made through the swarm, she has no store identity yet:
|
||||
|
||||
The swarm declares a new agent `paused`, so without a further step ruth runs
|
||||
but drives no turns. Open `/agents` in the swarm UI and set her `up` from her
|
||||
card.
|
||||
```bash
|
||||
swarmctl agent create ruth --hive <hive> # on the swarm-controller's host
|
||||
hivectl agent ruth rebuild # on ruth's hive: hands her the identity
|
||||
```
|
||||
|
||||
On a hive that already has a ruth container from an earlier release, create
|
||||
her once the same way, so that her config repo and store identity exist. The
|
||||
container keeps starting with the hive meanwhile.
|
||||
`agent create` declares an agent the swarm has no declaration for as
|
||||
`paused`, so without a further step ruth keeps running but drives no turns.
|
||||
`swarmctl` has no verb for wanted state: open `/agents` in the swarm UI and set
|
||||
her `up` from her card.
|
||||
|
||||
Within about ten minutes the controller creates her forge user and matrix
|
||||
account and mints their tokens into the store, where she fetches them.
|
||||
`swarmctl agent mint-forge-token ruth` skips the wait for the forge token.
|
||||
|
||||
## 5 · Matrix
|
||||
|
||||
|
|
|
|||
|
|
@ -174,7 +174,9 @@ Each row carries enough context that the caller renders
|
|||
it directly as a bulleted list, no follow-up fetch needed.
|
||||
|
||||
`GetLooseEnds` takes no target — it always returns the caller's own
|
||||
rows. `Reminder` rows always carry `owner == self`.
|
||||
rows, on both the agent and manager sockets alike (the manager, `ruth`,
|
||||
is a normal agent here, just deployed automatically with different
|
||||
default permissions). `Reminder` rows always carry `owner == self`.
|
||||
|
||||
Per-variant fields:
|
||||
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ Power toggles never commit to the meta repo. Every operator power surface —
|
|||
dashboard buttons, the MCP tools, and `hivectl stop/start/restart/kill` —
|
||||
rides the queue through that power layer, so intent, lease serialization,
|
||||
and crash-watch suppression can't drift per surface; the only direct starts
|
||||
left are infra containers (no lease, no
|
||||
left are the root-agent bootstrap and infra containers (no lease, no
|
||||
harness). Cancelling a still-queued power DAG reverts `wanted` to the
|
||||
observed state — a cancel means "don't do it," not "do it later." hive-c0re
|
||||
seeds agents without a row from observed state on first touch (running ⇒
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ variants regardless of who sends them):
|
|||
|
||||
A small `Surface` trait, with one zero-sized impl, factors
|
||||
`hive-agent`'s wire types (`hive_core_agent_sock::{Request, Response}` —
|
||||
one unified enum shared by every agent socket) and its turn
|
||||
one unified enum shared by the agent and manager sockets) and its turn
|
||||
loop, so the loop itself has no per-role branches. See
|
||||
`hive-agent/src/main.rs`'s module
|
||||
doc for the trait shape.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use rmcp::schemars;
|
|||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct SendArgs {
|
||||
/// Logical agent name to deliver the message to (e.g. `"bob"`,
|
||||
/// Logical agent name to deliver the message to (e.g. `"manager"`,
|
||||
/// `"alice"`). `"operator"` is refused: reach the operator over matrix
|
||||
/// if you have it.
|
||||
pub to: String,
|
||||
|
|
@ -106,7 +106,7 @@ pub struct SetStatusArgs {
|
|||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct GetAgentMetaArgs {
|
||||
/// Logical name of the agent to query (e.g. `"iris"`, `"bob"`).
|
||||
/// Logical name of the agent to query (e.g. `"iris"`, `"manager"`).
|
||||
/// Omit to query your own identity + status — replaces the
|
||||
/// previous `whoami` self-introspection tool.
|
||||
#[serde(default)]
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ impl AgentServer {
|
|||
let log = format!("{args:?}");
|
||||
let to = args.to.clone();
|
||||
// Check per-agent allow-list (services.hyperhive.agent.allowedRecipients). When no
|
||||
// policy file is present the check is a no-op.
|
||||
// policy file is present (e.g. manager containers) the check is a no-op.
|
||||
if let Err(refusal) = crate::send_allow::check_send_allowed(&to) {
|
||||
return run_tool_envelope("send", log, async move { refusal }).await;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
/// Where the NixOS module writes the per-agent send allow-list (see
|
||||
/// `nix/agent-modules/mcp.nix`). Empty list = unrestricted (the
|
||||
/// default). Non-empty list constrains `mcp__hyperhive__send`'s `to`
|
||||
/// field.
|
||||
/// field; the manager is always implicitly permitted regardless of
|
||||
/// the list contents.
|
||||
const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json";
|
||||
|
||||
/// Enforce the per-agent send allow-list. Returns `Ok` when the
|
||||
|
|
|
|||
|
|
@ -394,7 +394,7 @@ struct ExtraLink {
|
|||
}
|
||||
|
||||
/// Best-effort: pull the last 30 messages addressed to us via the
|
||||
/// per-agent socket. Empty list on any transport / decode
|
||||
/// per-agent / manager socket. Empty list on any transport / decode
|
||||
/// failure — the inbox section is decorative, not authoritative.
|
||||
async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::inbox::InboxRow> {
|
||||
const LIMIT: u64 = 30;
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ to track it:
|
|||
- **`agent_config/`** — per-agent registries (permissions,
|
||||
resource limits, topology).
|
||||
- **`stats/`** — dashboard metrics aggregation + OTEL export.
|
||||
- **`socket_server/`** — the unix-socket request server for the
|
||||
per-agent sockets.
|
||||
- **`socket_server/`** — the unix-socket request server shared by
|
||||
per-agent + manager sockets.
|
||||
- **`forge/`** — optional Forgejo wiring (`docs/integrations/forge.md`).
|
||||
- **`coordinator.rs`** — top-level wiring for `serve`.
|
||||
- **`meta.rs`**, **`migrate.rs`** — the meta flake + schema/state
|
||||
|
|
|
|||
|
|
@ -126,9 +126,10 @@ async fn ff_applied_main(
|
|||
/// is cleared because its contents (the mcp socket) don't survive restarts
|
||||
/// anyway. With `purge=true` the persistent trees are also wiped — config
|
||||
/// history, claude creds, notes — there is no undo.
|
||||
/// The bootstrap/root container is destroyable like any other. Nothing
|
||||
/// recreates it afterwards: ruth stays gone until she is created again from
|
||||
/// the swarm dashboard.
|
||||
/// The bootstrap/root container is destroyable like any other: it's
|
||||
/// imperative infra that `auto_update::ensure_root_agent` recreates on the
|
||||
/// next hive-c0re startup if absent, so destroying it is transient rather
|
||||
/// than something to refuse at the API.
|
||||
///
|
||||
/// Submits the teardown DAG and returns — it does not wait for the container to
|
||||
/// go away. Same contract as every other lifecycle op (`rebuild`, `kill`,
|
||||
|
|
|
|||
|
|
@ -165,11 +165,14 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn apply_reconcile_adds_missing_live_agent() {
|
||||
let live = vec!["alice".to_owned(), "newbie".to_owned()];
|
||||
let live = vec![
|
||||
crate::lifecycle::MANAGER_NAME.to_owned(),
|
||||
"newbie".to_owned(),
|
||||
];
|
||||
let (next, changed) = apply_reconcile(&BTreeSet::new(), &live, &[]);
|
||||
assert!(changed);
|
||||
assert!(next.contains("newbie"));
|
||||
assert!(next.contains("alice"));
|
||||
assert!(next.contains(crate::lifecycle::MANAGER_NAME));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -496,7 +496,7 @@ impl Coordinator {
|
|||
}
|
||||
|
||||
/// Snapshot the hive-wide configuration fields as a [`HiveEnv`].
|
||||
/// Pass the result to `lifecycle::provision_container` / `rebuild` / `meta::sync_agents`
|
||||
/// Pass the result to `lifecycle::spawn` / `rebuild` / `meta::sync_agents`
|
||||
/// instead of threading the individual fields separately.
|
||||
#[must_use]
|
||||
pub fn hive_env(&self) -> HiveEnv {
|
||||
|
|
|
|||
|
|
@ -296,6 +296,7 @@ async fn reload_gateway_nginx() {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lifecycle::MANAGER_NAME;
|
||||
|
||||
// ── legacy mode (frontend_dir = None) ──────────────────────────────
|
||||
|
||||
|
|
@ -308,10 +309,13 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn render_includes_every_agent() {
|
||||
let names: Vec<String> = ["alice", "iris"].iter().map(|s| (*s).to_owned()).collect();
|
||||
fn render_includes_manager() {
|
||||
let names: Vec<String> = [MANAGER_NAME, "iris"]
|
||||
.iter()
|
||||
.map(|s| (*s).to_owned())
|
||||
.collect();
|
||||
let body = render(&names, None);
|
||||
assert!(body.contains("/agent/alice/"));
|
||||
assert!(body.contains(&format!("/agent/{MANAGER_NAME}/")));
|
||||
assert!(body.contains("/agent/iris/"));
|
||||
}
|
||||
|
||||
|
|
@ -457,10 +461,13 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn split_mode_includes_every_agent() {
|
||||
let names: Vec<String> = ["alice", "iris"].iter().map(|s| (*s).to_owned()).collect();
|
||||
fn split_mode_includes_manager() {
|
||||
let names: Vec<String> = [MANAGER_NAME, "iris"]
|
||||
.iter()
|
||||
.map(|s| (*s).to_owned())
|
||||
.collect();
|
||||
let body = render(&names, Some(FAKE_FRONTEND));
|
||||
assert!(body.contains("/agent/alice/"));
|
||||
assert!(body.contains(&format!("/agent/{MANAGER_NAME}/")));
|
||||
assert!(body.contains("/agent/iris/"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@ pub const MAX_AGENT_NAME: usize = 9;
|
|||
/// Logical name of the manager agent (broker recipient, state-dir key,
|
||||
/// meta flake attribute). All persistent state lives under `ruth/`.
|
||||
pub const MANAGER_NAME: &str = "ruth";
|
||||
/// Container name of the manager. Uses the same `h-` prefix as sub-agents
|
||||
/// so `nixos-container list` output is uniform and the list filter is
|
||||
/// a single `starts_with(AGENT_PREFIX)` check. Logical name → container
|
||||
/// name: `ruth` → `h-ruth`.
|
||||
pub const MANAGER_CONTAINER: &str = "h-ruth";
|
||||
|
||||
/// Mount point of the per-agent runtime directory inside the container.
|
||||
pub const CONTAINER_RUNTIME_MOUNT: &str = "/run/hive";
|
||||
|
||||
|
|
@ -219,6 +225,15 @@ async fn port_collision(self_name: &str) -> Result<Option<String>> {
|
|||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn spawn(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> Result<()> {
|
||||
create_container(name, hive, paths).await?;
|
||||
// Runtime dir must exist before nixos-container start (nspawn bind-mount
|
||||
// source). Create it here so callers don't need a separate preamble step.
|
||||
ensure_agent_runtime_dir(name)?;
|
||||
write_dropins(name, hive, paths).await?;
|
||||
priv_run("start", name).await
|
||||
}
|
||||
|
||||
/// First-spawn pre-create provisioning — the job queue's `Provision`
|
||||
/// node. Fail fast on a port collision, set up the proposed/applied
|
||||
/// repos + state subvolume + claude/notes dirs, then register the new
|
||||
|
|
@ -251,6 +266,16 @@ pub async fn create_only(name: &str) -> Result<()> {
|
|||
priv_run("create", name).await
|
||||
}
|
||||
|
||||
/// First-spawn provisioning + `nixos-container create`, without the
|
||||
/// drop-in write or the start. `spawn` composes this with
|
||||
/// `write_dropins` + start for direct callers (root-agent bootstrap);
|
||||
/// the job queue instead runs `provision_container` (`Provision` node)
|
||||
/// and `create_only` (`Create` node) as separate DAG steps.
|
||||
pub async fn create_container(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> Result<()> {
|
||||
provision_container(name, hive, paths).await?;
|
||||
create_only(name).await
|
||||
}
|
||||
|
||||
/// Rebuild-path preamble, run by the job queue's `Prebuild` node: fail fast on
|
||||
/// a port collision, then make sure the applied repo + state dirs exist.
|
||||
/// Container untouched.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Loose-ends aggregator. Assembles a `Vec<LooseEnd>` for a single agent
|
||||
//! (`for_agent`) — always the caller's own. `Request::GetLooseEnds` from
|
||||
//! an agent socket lands here. Reminders and todos are
|
||||
//! either the agent or manager socket lands here. Reminders and todos are
|
||||
//! agent-local (in-container `hive-agent` stores) and never sourced from
|
||||
//! here.
|
||||
//!
|
||||
|
|
|
|||
|
|
@ -267,17 +267,26 @@ async fn cmd_serve(
|
|||
// Copy value first for the dashboard + knowledge-webhook tasks.
|
||||
let dashboard_port = env.dashboard_port;
|
||||
let coord = Arc::new(Coordinator::open(&db, env, model_prices, build_slots)?);
|
||||
socket_server::start_manager(coord.clone())?;
|
||||
// Idempotent pre-flight: rewrite pre-meta-layout applied
|
||||
// repos, ensure proposed repos carry the `applied`
|
||||
// remote, bootstrap the meta repo, repoint containers at
|
||||
// `meta#<name>` (one-shot, guarded by a marker file).
|
||||
// Runs before the boot reconcile, so agents are built against meta.
|
||||
// Runs before manager auto-spawn so the new manager is
|
||||
// built against meta from the first attempt.
|
||||
if let Err(e) = migrate::run(&coord).await {
|
||||
tracing::warn!(error = ?e, "startup migration failed");
|
||||
}
|
||||
// Before any agent starts, so the bind mounts it gets from the stored
|
||||
// permissions are the meta repo's grants until the swarm answers.
|
||||
crate::workers::permissions_import::seed_local();
|
||||
// Auto-create the root agent container if it isn't there yet. Block
|
||||
// on this — without root the system has no manager harness.
|
||||
// Failures are logged but allowed: a broken auto-spawn shouldn't
|
||||
// make the dashboard unreachable for debugging.
|
||||
if let Err(e) = auto_update::ensure_root_agent(&coord).await {
|
||||
tracing::warn!(error = ?e, "auto-spawn root agent failed");
|
||||
}
|
||||
// Auto-update in the background — don't block service start.
|
||||
// Sub-agent rebuilds can take tens of seconds; we want the admin
|
||||
// socket up immediately.
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ use anyhow::{Context, Result};
|
|||
use tokio::process::Command;
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::lifecycle::{self, AGENT_PREFIX};
|
||||
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_CONTAINER, MANAGER_NAME};
|
||||
use crate::meta;
|
||||
|
||||
const KILL_SWITCH: &str = "HIVE_SKIP_META_MIGRATION";
|
||||
|
|
@ -157,7 +157,11 @@ async fn enumerate_agents() -> Result<Vec<hive_types::Ident>> {
|
|||
Ok(containers
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
let name = c.strip_prefix(AGENT_PREFIX)?;
|
||||
let name = if c == MANAGER_CONTAINER {
|
||||
MANAGER_NAME
|
||||
} else {
|
||||
c.strip_prefix(AGENT_PREFIX)?
|
||||
};
|
||||
hive_types::Ident::parse(name).ok()
|
||||
})
|
||||
.collect())
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue