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:
parent
b199c83899
commit
553ed8376c
24 changed files with 557 additions and 92 deletions
|
|
@ -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
|
||||
|
|
|
|||
143
hive-c0re/src/agent_config/archetypes.rs
Normal file
143
hive-c0re/src/agent_config/archetypes.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue