Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
32 changed files with 398 additions and 108 deletions

View file

@ -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

View file

@ -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

View file

@ -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>`

View file

@ -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

View file

@ -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:

View file

@ -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 ⇒

View file

@ -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.

View file

@ -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)]

View file

@ -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;
}

View file

@ -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

View file

@ -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;

View file

@ -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

View file

@ -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`,

View file

@ -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]

View file

@ -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 {

View file

@ -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/"));
}

View file

@ -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.

View file

@ -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.
//!

View file

@ -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.

View file

@ -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