Watch
0
0
Fork
You've already forked hyperhive
0

archetypes: build an archetype agent from its archetype's module on the hive

The hive reads the agent-archetype key when it provisions and rebuilds an
agent and keeps it in swarm-agent-archetypes.json. For an archetype agent
the meta flake has no agent-<name> input and mkAgent imports
hyperhive.hyperhiveArchetypes.<name>.module; provisioning, rebuilds, the
meta-input cascade, a deploy's rev and the startup migration skip the
config repos. The harness gets HIVE_ARCHETYPE and the system prompt says
the agent has no config repo. The create form sends the picked archetype.
Adds the android-dev built-in.

Refs #5386
This commit is contained in:
atlas 2026-10-17 08:49:03 +02:00 • committed by mara
commit 553ed8376c
24 changed files with 557 additions and 92 deletions

View file

@ -59,6 +59,7 @@ swarm-queue-client = { workspace = true, features = [
"notices",
"agent-limits",
"agent-permissions",
"agent-archetype",
] }
swarm-secret-client.workspace = true
tokio.workspace = true

View file

@ -0,0 +1,143 @@
//! The archetype each of this hive's archetype agents is built from, as last
//! read from the swarm's `agent-archetype` bucket
//! ([`swarm_queue_client::agent_archetype`]). Stored at
//! `/var/lib/hyperhive/swarm-agent-archetypes.json`, outside the meta repo:
//! the meta render runs at boot, possibly before the queue answers, so it
//! reads this file rather than the bucket.
//!
//! Format: agent name → [`ArchetypeRef`]. An agent with an entry has no config
//! repo: the meta flake builds it from the archetype's module, and the paths
//! that seed, relock or advance `applied/<name>` skip it. An agent with no
//! entry is a custom agent.
//!
//! [`refresh`] reads one agent's key; provisioning and every rebuild's meta
//! sync call it before they render the meta flake.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use swarm_queue_client::agent_archetype::{self, ArchetypeRef, Read};
const FILE: &str = "swarm-agent-archetypes.json";
pub type Stored = BTreeMap<String, ArchetypeRef>;
#[must_use]
pub fn path() -> PathBuf {
crate::paths::state_root().join(FILE)
}
/// Read the stored archetypes. An absent file is the empty map: every agent
/// custom. A file that exists but can't be read or parsed is an error.
pub fn read_at(path: &Path) -> std::io::Result<Stored> {
super::read_map(path)
}
/// `agent`'s archetype per the file at [`path`], `None` for a custom agent.
///
/// # Errors
///
/// As [`read_at`].
pub fn of(agent: &str) -> std::io::Result<Option<ArchetypeRef>> {
Ok(read_at(&path())?.remove(agent))
}
/// Read `agent`'s key and store what it holds. No queue, or a bucket that
/// does not answer, leaves the stored entry as it is.
///
/// # Errors
///
/// The queue is configured but not connected, the read failed, the value
/// names a source or archetype that is not an identifier, or the file could
/// not be read or written.
pub async fn refresh(agent: &str) -> Result<()> {
let Some(client) = crate::swarm_queue::client().await else {
return Ok(());
};
// An unconnected client does not fail a JetStream request, it hangs on it.
swarm_queue_client::ensure_connected(&client)?;
let read = agent_archetype::read(&client, agent)
.await
.map_err(|e| anyhow::anyhow!(e))
.with_context(|| format!("reading the swarm's archetype for {agent}"))?;
store_read(&path(), agent, read)
}
/// Store `read`, the value of `agent`'s key, in the file at `path`. Written
/// only when it changes the entry.
fn store_read(path: &Path, agent: &str, read: Read) -> Result<()> {
let value = match read {
Read::NoBucket => {
tracing::debug!(%agent, "archetype: the agent-archetype bucket does not answer");
return Ok(());
}
Read::Current(value) => value,
};
// Both are rendered into the meta flake as nix strings.
if let Some(ArchetypeRef { source, name }) = &value {
for part in [source, name] {
hive_types::Ident::parse(part)
.map_err(|why| anyhow::anyhow!("archetype {part:?} of {agent}: {why}"))?;
}
}
let mut stored = read_at(path).context("reading the stored archetypes")?;
let changed = match value {
Some(value) => stored.insert(agent.to_owned(), value.clone()) != Some(value),
None => stored.remove(agent).is_some(),
};
if changed {
super::write_map(path, &stored).context("storing the archetypes")?;
tracing::info!(%agent, archetype = ?stored.get(agent), "archetype: stored the swarm's archetype");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn reviewer() -> ArchetypeRef {
ArchetypeRef {
source: "hyperhive".to_owned(),
name: "reviewer".to_owned(),
}
}
#[test]
fn a_read_sets_or_clears_the_entry_and_no_bucket_keeps_it() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(FILE);
assert!(read_at(&path).expect("missing is empty").is_empty());
store_read(&path, "atlas", Read::Current(Some(reviewer()))).expect("store");
store_read(&path, "iris", Read::Current(None)).expect("store");
assert_eq!(
read_at(&path).expect("read"),
Stored::from([("atlas".to_owned(), reviewer())])
);
store_read(&path, "atlas", Read::NoBucket).expect("store");
assert_eq!(
read_at(&path).expect("read").get("atlas"),
Some(&reviewer())
);
store_read(&path, "atlas", Read::Current(None)).expect("store");
assert!(read_at(&path).expect("read").is_empty());
}
/// The value is rendered into nix, so a name that is not an identifier is
/// refused and nothing is stored.
#[test]
fn a_name_that_is_not_an_identifier_is_refused() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(FILE);
let bad = ArchetypeRef {
source: "hyperhive".to_owned(),
name: "x\"; y".to_owned(),
};
store_read(&path, "atlas", Read::Current(Some(bad))).expect_err("refused");
assert!(read_at(&path).expect("read").is_empty());
}
}

View file

@ -1,13 +1,14 @@
//! Per-agent configuration registries: resource limits, topology (JSON files
//! under `/var/lib/hyperhive/meta/`), the permissions the swarm grants
//! (outside the meta repo) and the shared wire-protocol size limits. Each
//! submodule is re-exported at the crate root, so `crate::topology::…` etc.
//! keep working unchanged.
//! under `/var/lib/hyperhive/meta/`), the permissions and archetypes the
//! swarm sets (outside the meta repo) and the shared wire-protocol size
//! limits. Each submodule is re-exported at the crate root, so
//! `crate::topology::…` etc. keep working unchanged.
//!
//! Note the two similarly-named modules: [`limits`] caps inline
//! *message body* sizes on the sockets, while [`resource_limits`] holds
//! per-agent CPU/memory caps for the container drop-in.
pub mod archetypes;
pub mod limits;
pub mod permissions;
pub mod resource_limits;

View file

@ -396,6 +396,11 @@ async fn run_meta_sync(coord: &Arc<Coordinator>, name: &str, relock: bool) -> Re
let agent_dir = crate::paths::agent_runtime_dir(name);
let hive = coord.hive_env();
let paths = Coordinator::agent_paths(name, agent_dir)?;
// A failed read keeps the archetype last stored, so the agent is built as
// it was.
if let Err(e) = crate::archetypes::refresh(name).await {
tracing::warn!(%name, error = ?e, "rebuild: reading the swarm's archetype failed; keeping the stored one");
}
crate::lifecycle::prepare_rebuild_dirs(name, &paths).await?;
// Idempotent meta sync so a manual rebuild can also recover from a
// divergent meta repo; then bump just this agent's input. `relock =
@ -403,7 +408,8 @@ async fn run_meta_sync(coord: &Arc<Coordinator>, name: &str, relock: bool) -> Re
// would revert the bump the cascade just committed.
let agents = crate::lifecycle::agents_for_meta_listing().await?;
crate::meta::sync_agents(&hive, &agents).await?;
if relock {
// An archetype agent has no `agent-<name>` input to relock.
if relock && crate::archetypes::of(name)?.is_none() {
crate::meta::lock_update_for_rebuild(name).await?;
}
Ok(())
@ -553,8 +559,16 @@ async fn run_meta_lock(
// against whatever `applied/<name>` already happened to be locked to —
// normally current, but silently stale forever if a past deploy failed
// and nothing since retried it.
// An archetype agent has no config repo and no `agent-<name>` input, so
// it is left out here; it is still rebuilt with the cascade.
let archetypes = crate::archetypes::read_at(&crate::archetypes::path())?;
let custom: Vec<String> = cascade
.iter()
.filter(|name| !archetypes.contains_key(*name))
.cloned()
.collect();
catch_up_cascade_agents(
&cascade,
&custom,
crate::forge::fast_forward_applied_main,
|agent_inputs| async move { crate::meta::lock_update(&agent_inputs).await },
)

View file

@ -230,8 +230,13 @@ pub async fn provision_container(name: &str, hive: &HiveEnv, paths: &AgentPaths)
agent_web_port(name)
);
}
setup_proposed(&paths.proposed, name).await?;
setup_applied(&paths.applied, Some(&paths.proposed), name).await?;
// Read before anything is seeded: an archetype agent gets no config
// repo, and the meta render below builds it from the archetype.
crate::archetypes::refresh(name).await?;
if crate::archetypes::of(name)?.is_none() {
setup_proposed(&paths.proposed, name).await?;
setup_applied(&paths.applied, Some(&paths.proposed), name).await?;
}
ensure_agent_state_subvolume(name).await?;
ensure_claude_dir(&paths.claude)?;
ensure_state_dir(&paths.notes)?;
@ -273,17 +278,20 @@ pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()>
agent_web_port(name)
);
}
if !paths.applied.join(".git").exists()
&& !crate::forge::fetch_config_main_into_applied(name).await
{
bail!(
"applied repo at {} is missing its .git directory and could not be reseeded from \
// An archetype agent has no applied repo to check or recover.
if crate::archetypes::of(name)?.is_none() {
if !paths.applied.join(".git").exists()
&& !crate::forge::fetch_config_main_into_applied(name).await
{
bail!(
"applied repo at {} is missing its .git directory and could not be reseeded from \
the forge (agent-configs/{name} unreachable, has no `main` — never merged or \
never seeded — or absent); destroy --purge and re-spawn this agent.",
paths.applied.display()
);
paths.applied.display()
);
}
setup_applied(&paths.applied, None, name).await?;
}
setup_applied(&paths.applied, None, name).await?;
ensure_agent_state_subvolume(name).await?;
ensure_claude_dir(&paths.claude)?;
ensure_state_dir(&paths.notes)?;
@ -319,12 +327,14 @@ pub async fn swap_update(
/// handle the `Err` themselves with `.unwrap_or_default()`.
async fn agents_for_meta(name_to_add: Option<&str>) -> Result<Vec<crate::meta::AgentSpec>> {
let containers = list().await?;
let archetypes = crate::archetypes::read_at(&crate::archetypes::path())?;
let mut out: Vec<crate::meta::AgentSpec> = containers
.into_iter()
.filter_map(|c| {
let name = c.strip_prefix(AGENT_PREFIX)?.to_owned();
Some(crate::meta::AgentSpec {
port: agent_web_port(&name),
archetype: archetypes.get(&name).cloned(),
name,
})
})
@ -334,6 +344,7 @@ async fn agents_for_meta(name_to_add: Option<&str>) -> Result<Vec<crate::meta::A
{
out.push(crate::meta::AgentSpec {
port: agent_web_port(extra),
archetype: archetypes.get(extra).cloned(),
name: extra.to_owned(),
});
}

View file

@ -43,7 +43,7 @@ mod swarm_status;
mod test_env;
mod workers;
pub(crate) use agent_config::{limits, permissions, resource_limits, topology};
pub(crate) use agent_config::{archetypes, limits, permissions, resource_limits, topology};
pub(crate) use stats::{container_stats, hive_stats, host_stats, otel_metrics, warnings};
pub(crate) use stores::{broker, build_logs, db, power};
pub(crate) use workers::{agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets};

View file

@ -42,6 +42,9 @@ pub const CONTAINER_MANAGER_META_MOUNT: &str = "/meta";
pub struct AgentSpec {
pub name: String,
pub port: u16,
/// The archetype the agent is built from (`crate::archetypes`), `None`
/// for a custom agent built from its own config repo.
pub archetype: Option<swarm_queue_client::agent_archetype::ArchetypeRef>,
}
/// Stage topology.json if it exists: it is regenerated by `reconcile`
@ -786,6 +789,11 @@ where
// (config PRs land there) but are not the flake's build input. deploy +
// rebuild re-lock this input to `applied/<name>`'s current `main` head.
for spec in agents {
// An archetype agent has no config repo, so no input: `mkAgent`
// takes its module from the archetype.
if spec.archetype.is_some() {
continue;
}
let _ = writeln!(
out,
" agent-{name}.url = \"git+file://{applied}\";",
@ -811,17 +819,26 @@ where
out.push_str(" };\n outputs =\n { self, hyperhive, ... }@inputs:\n let\n");
let _ = writeln!(
out,
" dashboardPort = {dashboard_port};\n mkAgent = {{ name, port }}:"
" dashboardPort = {dashboard_port};\n mkAgent = {{ name, port, archetype ? null }}:"
);
out.push_str(
r#" let
base = hyperhive.nixosConfigurations.agent-base;
input = inputs."agent-${name}";
# Every flake an archetype can come from, by its source name.
archetypeSources = { inherit hyperhive; };
agentModule =
if archetype == null then
inputs."agent-${name}".nixosModules.default
else
(archetypeSources.${archetype.source}
or (throw "agent ${name}: archetype source ${archetype.source} is not an input of this hive's meta flake")
).hyperhiveArchetypes.${archetype.name}.module
or (throw "agent ${name}: this hive's ${archetype.source} input has no archetype ${archetype.name}; update that input");
service = "hive-agent";
in
base.extendModules {
modules = [
input.nixosModules.default
agentModule
{
"#,
);
@ -1008,6 +1025,10 @@ where
HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness";
};
}
# Tells the harness the agent has no config repo of its own.
(if archetype == null then { } else {
systemd.services.${service}.environment.HIVE_ARCHETYPE = archetype.name;
})
];
};
in
@ -1016,9 +1037,17 @@ where
"#,
);
for spec in agents {
// `source` and `name` are identifiers (`archetypes::store_read`), so
// they need no escaping.
let archetype = spec.archetype.as_ref().map_or(String::new(), |a| {
format!(
" archetype = {{ source = \"{}\"; name = \"{}\"; }};",
a.source, a.name
)
});
let _ = writeln!(
out,
" {} = mkAgent {{ name = \"{}\"; port = {}; }};",
" {} = mkAgent {{ name = \"{}\"; port = {};{archetype} }};",
spec.name, spec.name, spec.port,
);
}
@ -1235,6 +1264,7 @@ mod tests {
AgentSpec {
name: name.to_owned(),
port,
archetype: None,
}
}
@ -1501,6 +1531,53 @@ mod tests {
);
}
/// An archetype agent has no `agent-<name>` input, and `mkAgent` is
/// handed its archetype; a custom agent beside it keeps its input.
#[test]
fn render_flake_builds_an_archetype_agent_from_its_archetype() {
let android = AgentSpec {
archetype: Some(swarm_queue_client::agent_archetype::ArchetypeRef {
source: "hyperhive".to_owned(),
name: "android-dev".to_owned(),
}),
..sample_spec("droid", 9002)
};
let out = render_flake_with_lookup(
"github:example/hyperhive",
"path:/nix/store/bbbb-hyperhive-docs-source",
"path:/nix/store/aaaa-nixpkgs-source",
None,
8000,
&[sample_spec("alice", 9001), android],
|_| vec!["nixpkgs"],
);
assert!(
!out.contains("agent-droid"),
"an archetype agent has no config input:\n{out}"
);
assert!(
out.contains(
"droid = mkAgent { name = \"droid\"; port = 9002; \
archetype = { source = \"hyperhive\"; name = \"android-dev\"; }; };"
),
"{out}"
);
assert!(out.contains("agent-alice.url = \"git+file://"), "{out}");
assert!(out.contains("agent-alice.inputs.nixpkgs.follows"), "{out}");
assert!(
out.contains("alice = mkAgent { name = \"alice\"; port = 9001; };"),
"{out}"
);
assert!(
out.contains("hyperhiveArchetypes.${archetype.name}.module"),
"{out}"
);
assert!(
out.contains("environment.HIVE_ARCHETYPE = archetype.name"),
"{out}"
);
}
#[test]
fn render_flake_forwards_env_into_global_environment() {
// Regression for the isolation breakage where forwarded vars

View file

@ -82,7 +82,16 @@ pub async fn run(coord: &Arc<Coordinator>) -> Result<()> {
// Phase 1 + 2: per-agent applied + proposed.
tracing::debug!("migration: phase 1+2 (applied + proposed repos)");
// An archetype agent has neither repo, and seeding one here would hand it
// a config it is not built from.
let archetypes = crate::archetypes::read_at(&crate::archetypes::path()).unwrap_or_else(|e| {
tracing::warn!(error = ?e, "migration: stored archetypes unreadable; treating every agent as custom");
crate::archetypes::Stored::new()
});
for name in &names {
if archetypes.contains_key(name.as_str()) {
continue;
}
tracing::debug!(%name, "migration: applied+proposed");
if let Err(e) = migrate_applied_repo(name.as_str()).await {
tracing::warn!(%name, error = ?e, "migration: applied repo rewrite failed");

View file

@ -298,7 +298,16 @@ async fn handle_deploy_request(
// Only a rebuild applies `rev`. A first deploy ignores it and builds the
// `applied` repo it seeds from the forge's `main`, or the one it finds.
// An archetype agent has no `applied` repo, so its rebuild ignores it too.
let archetype = match crate::archetypes::of(&agent) {
Ok(archetype) => archetype,
Err(e) => {
tracing::warn!(%agent, error = ?e, "swarm events: cannot read the stored archetypes; deploy dropped");
return;
}
};
if known
&& archetype.is_none()
&& let Some(rev) = rev.as_deref()
&& !apply_rev(&agent, rev).await
{