topology: drop the parent field and the hierarchy it fed
`topology.json` was a map of `name -> parent | null`, and that value fed the whole agent hierarchy: `<parent>` / `<children>` recipient sentinels, the reparenting API (CLI verb, wire verb, dashboard endpoints, DAG node), the dashboard tree, the rebuild depth sort, and an unconditional bind-mount grant giving every agent RW on its direct children's state. Per the operator's ruling the field goes, and with it all of the above. The file survives as what remains once the value is gone: the roster of agent names, which is the set `ManageRootAgent` grants mounts over. It is now a JSON array; `read` still accepts the old map shape and keeps its keys, so a hive that upgrades across this does not blank its roster (and so no capability holder loses its mounts for the length of that window). Two sites kept their behaviour under a different recipient rather than losing it. Both addressed `<parent>`, which the broker already resolved to `operator` for a root agent, and every agent is now what that fallback called a root: - the harness's turn-failure / plugin-failure notification (`Surface::send_to_parent` -> `send_to_operator`), and - the send allow-list's always-permitted escape hatch, so an agent with a restrictive allow-list still has a way to say it is stuck. What is NOT preserved, deliberately: an agent with no capability no longer sees any other agent's dirs. `ManageRootAgent`'s own grant is unchanged -- still every agent in the roster, still state RW + config RO, still no `harness`. The dashboard's reparenting control (the M0V3 picker) is deleted with its CSS. The tree rendering that reads `ContainerView.parent` is left for the frontend owner -- it degrades to a flat list with the field gone.
This commit is contained in:
parent
392f16cbc0
commit
d94bc2188d
28 changed files with 236 additions and 1513 deletions
|
|
@ -143,11 +143,8 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
|
|||
let ca_touched = materialise_ca_files(&dir, &ca_files)?;
|
||||
|
||||
// Reconcile topology.json against the live agent set — adds
|
||||
// entries for newly-spawned agents (default: manager as parent,
|
||||
// manager itself as root) and drops removed agents. Operator
|
||||
// overrides via the write API are preserved because reconcile
|
||||
// only fills in missing entries. Idempotent; when nothing changed
|
||||
// the file isn't touched.
|
||||
// newly-spawned agents and drops removed ones. Idempotent; when
|
||||
// nothing changed the file isn't touched.
|
||||
let agent_names: Vec<String> = agents.iter().map(|a| a.name.clone()).collect();
|
||||
let pending: Vec<String> = crate::coordinator::Coordinator::pending_init_names()
|
||||
.into_iter()
|
||||
|
|
@ -573,85 +570,6 @@ pub async fn commit_perms(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies every `(child, new_parent)` move under a single `META_LOCK`
|
||||
/// acquisition and creates **one** git commit for all of them — a
|
||||
/// single-move call is just a one-element slice, so there's no separate
|
||||
/// non-batch entry point. Moves are applied in the order given; the first
|
||||
/// validation error short-circuits the whole batch. True atomic write: all
|
||||
/// moves are pre-validated against a cumulative in-memory state with
|
||||
/// [`crate::topology::apply_set_parent`] before anything touches disk, then
|
||||
/// [`crate::topology::write`] is called exactly once. If any move fails
|
||||
/// validation the topology file is never modified.
|
||||
///
|
||||
/// The multi-move commit message uses `moves[0].1` as the destination label.
|
||||
/// This is intentional: the dashboard bulk-move UI always sends a single
|
||||
/// destination for all selected agents, so the message is always accurate in
|
||||
/// practice.
|
||||
///
|
||||
/// Returns a `Vec` of `(child, old_parent)` pairs for every move that
|
||||
/// actually changed the topology (idempotent same-parent moves are skipped),
|
||||
/// so the caller can send targeted notifications.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a `String` error if any move fails validation (cycle, unknown
|
||||
/// agent, etc.) or if the topology file cannot be written. Git-commit failure
|
||||
/// is logged as a warning and does not propagate — `sync_agents` will recover.
|
||||
pub async fn bulk_commit_topology(
|
||||
moves: &[(&str, Option<&str>)],
|
||||
) -> std::result::Result<Vec<(String, Option<String>)>, String> {
|
||||
if moves.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let _guard = META_LOCK.lock().await;
|
||||
// Snapshot parents before any writes so we can compute the diff.
|
||||
let topo_before = crate::topology::read();
|
||||
// Validate all moves against a cumulative in-memory state -- no disk
|
||||
// writes yet; first error aborts with the topology file untouched.
|
||||
let mut next = topo_before.clone();
|
||||
for (child, new_parent) in moves {
|
||||
next = crate::topology::apply_set_parent(&next, child, *new_parent)?;
|
||||
}
|
||||
// Only flush to disk if something actually changed.
|
||||
if next != topo_before {
|
||||
crate::topology::write(&next).map_err(|e| format!("{e:#}"))?;
|
||||
}
|
||||
// Commit the whole batch as one git operation.
|
||||
let dir = crate::paths::meta_root();
|
||||
let commit_msg = if moves.len() == 1 {
|
||||
let (child, new_parent) = moves[0];
|
||||
format!("topology: {} → {}", child, new_parent.unwrap_or("<root>"))
|
||||
} else {
|
||||
let names: Vec<&str> = moves.iter().map(|(c, _)| *c).collect();
|
||||
let dest = moves[0].1.unwrap_or("<root>");
|
||||
format!(
|
||||
"topology: move {} agents → {} ({})",
|
||||
moves.len(),
|
||||
dest,
|
||||
names.join(", ")
|
||||
)
|
||||
};
|
||||
let stage = async {
|
||||
git(&dir, &["add", "topology.json"]).await?;
|
||||
if paths_dirty(&dir, &["topology.json"]).await? {
|
||||
git_commit_paths(&dir, &commit_msg, &["topology.json"]).await?;
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
};
|
||||
if let Err(e) = stage.await {
|
||||
tracing::warn!(error = ?e, "bulk_commit_topology: topology written but git commit failed (sync_agents will recover)");
|
||||
}
|
||||
// Return (child, old_parent) for each move that changed state.
|
||||
let changed = moves
|
||||
.iter()
|
||||
.filter_map(|(child, new_parent)| {
|
||||
let old = topo_before.get(*child).cloned().flatten();
|
||||
(old.as_deref() != *new_parent).then_some((child.to_string(), old))
|
||||
})
|
||||
.collect();
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::too_many_arguments,
|
||||
reason = "many genuine flake inputs (source flakes, port, pronouns, tokens, \
|
||||
|
|
@ -1163,7 +1081,7 @@ where
|
|||
let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, parent ? null, toolGroups ? null, capabilities ? null, memoryMaxBytes ? null }}:"
|
||||
" dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, toolGroups ? null, capabilities ? null, memoryMaxBytes ? null }}:"
|
||||
);
|
||||
out.push_str(
|
||||
r#" let
|
||||
|
|
@ -1172,7 +1090,6 @@ where
|
|||
else hyperhive.nixosConfigurations.agent-base;
|
||||
input = inputs."agent-${name}";
|
||||
service = "hive-agent";
|
||||
parentEnv = if parent == null then {} else { HIVE_PARENT = parent; };
|
||||
toolGroupsEnv = if toolGroups == null then {} else { HIVE_TOOL_GROUPS = toolGroups; };
|
||||
capabilitiesEnv = if capabilities == null then {} else { HIVE_CAPABILITIES = capabilities; };
|
||||
in
|
||||
|
|
@ -1353,7 +1270,7 @@ where
|
|||
}
|
||||
out.push_str(
|
||||
r" };
|
||||
systemd.services.${service}.environment = parentEnv // toolGroupsEnv // capabilitiesEnv // {
|
||||
systemd.services.${service}.environment = toolGroupsEnv // capabilitiesEnv // {
|
||||
HIVE_PORT = toString port;
|
||||
HIVE_LABEL = name;
|
||||
HIVE_DASHBOARD_PORT = toString dashboardPort;
|
||||
|
|
@ -1399,19 +1316,10 @@ where
|
|||
nixosConfigurations = {
|
||||
"#,
|
||||
);
|
||||
// Pull the topology map once and look up each agent's parent. An
|
||||
// empty / absent topology.json yields `parent = null` for everyone
|
||||
// (every container at root). `meta::sync_agents` seeds the file
|
||||
// on first run with manager as root + everyone else under manager.
|
||||
let topology = crate::topology::read();
|
||||
let tool_groups_map = crate::tool_groups::read();
|
||||
let capabilities_map = crate::capabilities::read();
|
||||
let resource_limits_map = crate::resource_limits::read();
|
||||
for spec in agents {
|
||||
let parent_attr = topology
|
||||
.get(&spec.name)
|
||||
.and_then(|p| p.as_ref())
|
||||
.map_or_else(|| "null".to_owned(), |p| format!("\"{p}\""));
|
||||
// Emit `toolGroups = "group1,group2"` when the operator has
|
||||
// explicitly configured groups for this agent. Absent entry = null
|
||||
// = harness falls back to AGENT_DEFAULT (no env var emitted,
|
||||
|
|
@ -1451,12 +1359,11 @@ where
|
|||
.map_or_else(|| "null".to_owned(), |b| b.to_string());
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; parent = {}; toolGroups = {}; capabilities = {}; memoryMaxBytes = {}; }};",
|
||||
" {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; toolGroups = {}; capabilities = {}; memoryMaxBytes = {}; }};",
|
||||
spec.name,
|
||||
spec.name,
|
||||
if spec.is_manager { "true" } else { "false" },
|
||||
spec.port,
|
||||
parent_attr,
|
||||
tool_groups_attr,
|
||||
capabilities_attr,
|
||||
memory_max_attr,
|
||||
|
|
|
|||
Loading…
Reference in a new issue