diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 93552780..71106782 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -1384,27 +1384,6 @@ body.dashboard-shell.has-selection { padding-bottom: 4.5em; } -.move-picker { - display: inline-flex; - align-items: center; - gap: 0.3em; - margin-left: 0.6em; -} -.move-picker-select { - font-family: inherit; - font-size: 0.75em; - background: var(--bg); - color: var(--fg); - border: 1px solid var(--purple); - border-radius: 2px; - padding: 0.1em 0.3em; - max-width: 16em; -} -.move-picker-select:disabled { - opacity: 0.5; - cursor: default; -} - /* ST4TS moved to its own page (`/stats.html`): the window selector / summary chips / bars live in stats.css, and the shared `.hive-stats-table` moved to common.css (the SYST3M › diff --git a/frontend/packages/dashboard/src/swarm.js b/frontend/packages/dashboard/src/swarm.js index 6906b348..107b233d 100644 --- a/frontend/packages/dashboard/src/swarm.js +++ b/frontend/packages/dashboard/src/swarm.js @@ -953,153 +953,6 @@ export function renderSelectionBar(containers) { `PURGE ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? containers, config history, claude creds, and notes are all WIPED. no undo.`, }); - // Move agent(s) in the topology tree — selecting an option in the - // M0V3 dropdown immediately confirms + executes the move. "(no parent)" - // promotes to root (empty new_parent on the backend). Cycle-safe: - // dropdown filters out self and descendants on the client side; the - // backend rechecks via `topology::set_parent`. - // - // Backend: POST /api/topology/set-parent (dashboard.rs), - // form-encoded `child=&new_parent=`. Re-emits - // container snapshots on success so the tree repaints without a - // separate refresh. - addMoveActions(actions, selected, containers); -} - -// Render the M0V3 picker in the selection bar. Selecting any real option -// (including "(no parent)") immediately fires a confirm + POST — no -// separate button. Backend `topology::set_parent` refuses invalid moves -// and the refusal surfaces in the alert roll-up. -function addMoveActions(parent, selected, containers) { - const candidates = validReparentCandidates(selected, containers); - const wrap = el("span", { class: "move-picker" }); - const selectTitle = - selected.length === 1 - ? `change ${selected[0].name}'s parent` - : `change ${selected.length} agents' parent`; - const sel = el("select", { class: "move-picker-select", title: selectTitle }); - sel.append(el("option", { value: "" }, "⇢ M0V3 →")); - // "(no parent)" -> empty new_parent on the backend (promotes to root). - sel.append(el("option", { value: "__root__" }, "(no parent)")); - for (const name of candidates) { - sel.append(el("option", { value: name }, name)); - } - sel.addEventListener("change", async () => { - if (sel.selectedIndex === 0) return; - const newParent = sel.value === "__root__" ? "" : sel.value; - const newParentLabel = sel.value === "__root__" ? "(no parent)" : sel.value; - const names = selected.map((c) => c.name); - const promptMsg = - names.length === 1 - ? `move ${names[0]} → ${newParentLabel}?` - : `move ${names.length} agents (${names.join(", ")}) → ${newParentLabel}?`; - if (!(await themedConfirm({ message: promptMsg, danger: true }))) { - sel.selectedIndex = 0; - return; - } - sel.disabled = true; - const failures = []; - if (names.length === 1) { - // Single agent — use the form-encoded endpoint (backwards compat). - try { - const body = new URLSearchParams({ - child: names[0], - new_parent: newParent, - }); - const resp = await fetch("/api/topology/set-parent", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body, - redirect: "manual", - }); - const ok = - resp.ok || - resp.type === "opaqueredirect" || - (resp.status >= 200 && resp.status < 400); - if (!ok) { - const text = await resp.text().catch(() => ""); - failures.push( - `${names[0]}: http ${resp.status}${text ? " — " + text.slice(0, 200) : ""}`, - ); - } - } catch (err) { - failures.push(`${names[0]}: ${err}`); - } - } else { - // Multiple agents — use the bulk endpoint so all moves land in - // a single git commit instead of one per agent. - try { - const payload = names.map((n) => ({ - child: n, - new_parent: newParent || null, - })); - const resp = await fetch("/api/topology/set-parent-bulk", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - redirect: "manual", - }); - const ok = - resp.ok || - resp.type === "opaqueredirect" || - (resp.status >= 200 && resp.status < 400); - if (!ok) { - const text = await resp.text().catch(() => ""); - failures.push( - `bulk: http ${resp.status}${text ? " — " + text.slice(0, 200) : ""}`, - ); - } - } catch (err) { - failures.push(`bulk: ${err}`); - } - } - sel.disabled = false; - sel.selectedIndex = 0; - if (failures.length) { - themedToast( - `M0V3 completed with ${failures.length} failure${failures.length === 1 ? "" : "s"}:\n\n` + - failures.join("\n"), - { type: "error", duration: 0 }, - ); - } - }); - wrap.append(sel); - parent.append(wrap); -} - -// Filter the dashboard's container list to those that are valid -// re-parent targets for the `selected` agents: anyone who isn't IN -// the selection itself, isn't a descendant of any selected agent -// (cycle prevention across the whole batch). The backend re-checks -// per-agent via `topology::set_parent`; this client-side filter is -// purely UX so the operator can't pick an obviously-invalid option. -function validReparentCandidates(selected, containers) { - // Build child map once. - const childrenOf = new Map(); - for (const c of containers) { - const p = c.parent || null; - if (!childrenOf.has(p)) childrenOf.set(p, []); - childrenOf.get(p).push(c.name); - } - // Union descendant set across every selected agent (each agent's - // descendants AND itself). - const blocked = new Set(); - for (const t of selected) { - const queue = [t.name]; - blocked.add(t.name); - while (queue.length) { - const n = queue.shift(); - for (const child of childrenOf.get(n) || []) { - if (blocked.has(child)) continue; - blocked.add(child); - queue.push(child); - } - } - } - return containers - .filter((c) => !blocked.has(c.name)) - .map((c) => c.name) - .sort(); } function addBulkButton(parent, btnClass, label, enabled, selected, opts) { @@ -1147,23 +1000,15 @@ function addBulkButton(parent, btnClass, label, enabled, selected, opts) { // (rebuild_queue dedups but other endpoints don't); the loop is // short — bulk selections are typically a handful of agents. // - // Two URL shapes: - // - `opts.action` is a path prefix and the agent name gets - // appended (lifecycle endpoints: /start/, /rebuild/). - // `opts.body` is a static object applied to every POST. - // - `opts.perAgentBodyFor(name)` is set: `opts.action` is the - // full URL (no name appended) and the per-agent body comes - // from the callback. Used by /api/topology/set-parent, where - // the agent name is a body field rather than a URL component. + // `opts.action` is a path prefix and the agent name gets appended + // (lifecycle endpoints: /start/, /rebuild/). `opts.body` + // is a static object applied to every POST. for (const name of names) { - const body = opts.perAgentBodyFor - ? new URLSearchParams(opts.perAgentBodyFor(name)) - : new URLSearchParams(opts.body || {}); - const url = opts.perAgentBodyFor - ? opts.action - : opts.action + - encodeURIComponent(name) + - (graceful ? "?graceful=true" : ""); + const body = new URLSearchParams(opts.body || {}); + const url = + opts.action + + encodeURIComponent(name) + + (graceful ? "?graceful=true" : ""); try { const resp = await fetch(url, { method: "POST", diff --git a/hive-agent-mcp/src/send_allow.rs b/hive-agent-mcp/src/send_allow.rs index 64681a47..3033eca8 100644 --- a/hive-agent-mcp/src/send_allow.rs +++ b/hive-agent-mcp/src/send_allow.rs @@ -12,20 +12,23 @@ const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json"; /// Enforce the per-agent send allow-list. Returns `Ok` when the -/// recipient is permitted (no list configured, `` sentinel -/// always allowed, or `to` is in the list); returns `Err(refusal)` -/// with a claude-readable string when blocked — the harness surfaces -/// the refusal as the tool result so claude knows the message didn't -/// land and can react (e.g. route via `` instead). +/// recipient is permitted (no list configured, the operator, or `to` is +/// in the list); returns `Err(refusal)` with a claude-readable string +/// when blocked — the harness surfaces the refusal as the tool result so +/// claude knows the message didn't land and can react (e.g. route to the +/// operator instead). pub fn check_send_allowed(to: &str) -> Result<(), String> { - if to == hive_sh4re::manager::PARENT_RECIPIENT { - // Always allow `` — the allow-list constrains peer - // chatter, not the structural reporting line; the operator - // can rewire who the parent IS via `set_parent` without - // having to remember to update the per-agent allow-list. - // The broker resolves the sentinel to the real parent label - // on the host side per topology.json (falls back to `operator` - // for root agents). + if to == hive_sh4re::manager::OPERATOR_RECIPIENT { + // Always allow the operator — the allow-list constrains peer + // chatter, not the reporting line out, and an agent with no way + // to say "I am stuck" is an agent that fails silently. + // + // This bypass used to be spelled ``, which the broker + // resolved per `topology.json` and which fell back to `operator` + // for a root agent. #4472 removed the parent field, so every + // agent is what that fallback called a root — the exemption is + // now written as the name it always resolved to. Same reachable + // set, one fewer indirection. return Ok(()); } let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else { @@ -50,9 +53,9 @@ pub fn check_send_allowed(to: &str) -> Result<(), String> { } Err(format!( "send refused: recipient '{to}' not in services.hyperhive.agent.allowedRecipients \ - (configured in agent.nix). Allowed: {allow:?}. Your structural \ - parent is always reachable — route through `send(to: \"{}\", …)` \ - if you need to reach someone outside the allow-list.", - hive_sh4re::manager::PARENT_RECIPIENT + (configured in agent.nix). Allowed: {allow:?}. The operator is always \ + reachable — route through `send(to: \"{}\", …)` if you need to reach \ + someone outside the allow-list.", + hive_sh4re::manager::OPERATOR_RECIPIENT )) } diff --git a/hive-agent/src/main.rs b/hive-agent/src/main.rs index 3b1e2e8c..a5a3a054 100644 --- a/hive-agent/src/main.rs +++ b/hive-agent/src/main.rs @@ -104,7 +104,7 @@ async fn main() -> Result<()> { /// Surface a `SYSTEM_SENDER` message in the live event bus + tracing /// log. Both agents and the manager receive `ContainerCrash`, -/// reparent notifications, and friends; the parse and log path is +/// notifications, and friends; the parse and log path is /// identical. Quiet no-op when `from` isn't /// `SYSTEM_SENDER`. fn log_system_event(bus: &Bus, from: &str, body: &str) { @@ -122,8 +122,8 @@ fn log_system_event(bus: &Bus, from: &str, body: &str) { }); } -/// Body string for the turn-failure notification we route to -/// `` on `TurnError::Failed`. Reads the hive-qualified +/// Body string for the turn-failure notification we route to the +/// operator on `TurnError::Failed`. Reads the hive-qualified /// identity so the receiver sees `agent@hive` rather than relying on /// the caller threading a `label` through every turn-handling layer. /// Falls back to `` when `HIVE_LABEL` is missing so a @@ -278,10 +278,15 @@ trait Surface { /// fallback. Same shape as `graceful_stop_complete`. fn pause_acknowledged(socket: &Path) -> impl Future; - /// Send a message addressed to `` (broker resolves the - /// sentinel via `topology::parent_of` at delivery time; root - /// agents/manager fall through to operator). - fn send_to_parent(socket: &Path, body: String) -> impl Future; + /// Send a message addressed to the operator. The reporting line out + /// of a container: this is where a turn failure or a plugin-install + /// failure surfaces when nothing inside the harness can act on it. + /// + /// Was `` before #4472, a sentinel the broker resolved per + /// `topology.json` and which already fell through to `operator` for a + /// root agent. With the parent field gone every agent takes that + /// branch, so the recipient is written out rather than resolved. + fn send_to_operator(socket: &Path, body: String) -> impl Future; /// Long-poll the broker for the next message. Wraps the /// `Messages`/empty/error trichotomy in `RecvOutcome` so the @@ -367,11 +372,11 @@ impl Surface for AgentSurface { (threads, reminders) } - async fn send_to_parent(socket: &Path, body: String) { + async fn send_to_operator(socket: &Path, body: String) { let res = hive_sock_client::request::<_, Response>( socket, &Request::Send { - to: hive_sh4re::manager::PARENT_RECIPIENT.into(), + to: hive_sh4re::manager::OPERATOR_RECIPIENT.into(), body, in_reply_to: None, }, @@ -379,7 +384,7 @@ impl Surface for AgentSurface { ) .await; if let Err(e) = res { - tracing::warn!(error = ?e, "failed to notify parent of turn failure"); + tracing::warn!(error = ?e, "failed to notify the operator of turn failure"); } } @@ -519,11 +524,9 @@ async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> { } let files = turn::TurnFiles::prepare(socket, &label).await?; // Plugin install failures come back as a Vec — route each - // through `` via the `send_to_parent` failure-notify path. - // The broker resolves `` per `topology::parent_of`; - // root agents fall through to operator. + // through the `send_to_operator` failure-notify path. for failure in plugins::install_configured().await { - S::send_to_parent(socket, failure).await; + S::send_to_operator(socket, failure).await; } // The forge notification poller used to be spawned here. It is its own // process now (`hive-forge-notify`, its own systemd unit) so a harness @@ -1004,7 +1007,7 @@ async fn handle_turn( /// The non-happy-path half of `handle_turn`: react to each `TurnError` /// variant the turn could have failed with (park-and-retry on rate-limit/ /// stall/auth, requeue-for-a-fresh-turn on prompt-too-long/session-not- -/// found, notify the parent on a hard failure). Split out purely to keep +/// found, notify the operator on a hard failure). Split out purely to keep /// `handle_turn` itself under clippy's line-count lint — no behavior /// change from when this lived inline. async fn handle_turn_error_recovery( @@ -1063,6 +1066,6 @@ async fn handle_turn_error_recovery( S::requeue_inflight(socket).await; } if let Err(turn::TurnError::Failed(e)) = outcome { - S::send_to_parent(socket, format_turn_failure(e)).await; + S::send_to_operator(socket, format_turn_failure(e)).await; } } diff --git a/hive-agent/src/plugins.rs b/hive-agent/src/plugins.rs index 3928ee89..958a6977 100644 --- a/hive-agent/src/plugins.rs +++ b/hive-agent/src/plugins.rs @@ -98,9 +98,8 @@ async fn update_marketplaces() { /// Install every plugin in `/etc/hyperhive/claude-plugins.json`. /// Returns a list of human-readable failure messages so the caller can /// route them through their own per-role surface (turn-failure-style -/// notification, see `Surface::send_to_parent`). Wire-agnostic: the -/// caller picks the recipient via the same `` sentinel that -/// failure-notify uses everywhere else. +/// notification, see `Surface::send_to_operator`). Wire-agnostic: the +/// caller picks the recipient, the same way failure-notify does. pub async fn install_configured() -> Vec { let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else { return Vec::new(); diff --git a/hive-agent/src/turn.rs b/hive-agent/src/turn.rs index 69929dc5..9254fc06 100644 --- a/hive-agent/src/turn.rs +++ b/hive-agent/src/turn.rs @@ -193,7 +193,7 @@ pub enum TurnError { /// rate-limit path — NOT a crash. ApiStall, /// A hard failure with no recovery — the serve loop escalates it to the - /// parent (`send_to_parent`). + /// operator (`send_to_operator`). Failed(anyhow::Error), } diff --git a/hive-c0re/src/agent_config/topology.rs b/hive-c0re/src/agent_config/topology.rs index 894f7e53..3acd1ccd 100644 --- a/hive-c0re/src/agent_config/topology.rs +++ b/hive-c0re/src/agent_config/topology.rs @@ -1,35 +1,26 @@ -//! Agent topology storage — single source of truth for parent/child -//! relations in the hive. Persisted as a flat JSON map of `name → -//! parent name | null` at `/var/lib/hyperhive/meta/topology.json`, -//! alongside the meta `flake.nix`, so topology changes thread through -//! the same git commit log as deploys. +//! Agent roster storage — the set of agent names the hive knows about. +//! Persisted as a JSON array at `/var/lib/hyperhive/meta/topology.json`, +//! alongside the meta `flake.nix`, so roster changes thread through the +//! same git commit log as deploys. //! -//! Broader-than-your-own-children bind-mount grants are **not** stored -//! here: they hang off the `ManageRootAgent` capability in -//! `capabilities.json`. See `lifecycle::set_nspawn_flags`. +//! **There is no hierarchy here any more.** The file used to be a map of +//! `name → parent | null` and this module owned the parent/child tree that +//! fed `` / `` routing, the reparenting API and the +//! dashboard's tree view. All of that is gone (#4472); what the file is +//! *for* now is the one thing that survived the removal — naming every +//! agent, which is the set a +//! [`hive_sh4re::permissions::Capability::ManageRootAgent`] holder gets +//! bind-mounted ([`all_agents`]). //! -//! Format, rationale, read/reconcile/inject/surface flow, and target -//! enforcement semantics: `docs/agent-lifecycle/agent-hierarchy.md::Where the tree lives`. -//! `` sentinel resolution (delivered by [`resolve_recipient`]): -//! `docs/process/conventions.md::Recipient sentinels`. -//! -//! ## Graph representation -//! -//! The on-disk format stays as a flat JSON map `name → parent | null` -//! (small, git-diffable). In-memory, cycle detection uses a [`petgraph`] -//! directed graph where each edge runs -//! **parent → child**. This replaces the ad-hoc bounded walks that existed -//! before: petgraph's `is_cyclic_directed` -//! is correct for graphs of any depth (no 32-hop ceiling) and well-tested. -//! The graph is built on demand from the flat map; it is not cached across -//! calls (the map is small and disk I/O dominates anyway). +//! Broader-than-your-own bind-mount grants are **not** stored here: they +//! hang off the `ManageRootAgent` capability in `capabilities.json`. See +//! `lifecycle::set_nspawn_flags`. -use std::collections::BTreeMap; - -use petgraph::algo::is_cyclic_directed; -use petgraph::graph::{DiGraph, NodeIndex}; +use std::collections::BTreeSet; use std::path::PathBuf; +use serde::Deserialize; + const TOPOLOGY_FILE: &str = "topology.json"; #[must_use] @@ -37,55 +28,44 @@ pub fn topology_path() -> PathBuf { crate::paths::meta_root().join(TOPOLOGY_FILE) } -/// Snapshot of the topology map. Read on every `container_view::build_all` +/// On-disk shapes [`read`] accepts. The array is what [`write`] emits; the +/// map is the pre-#4472 `name → parent | null` format, kept readable so a +/// hive that upgrades across this change keeps its roster instead of +/// blanking it until the next `reconcile` pass — and a blank roster is not +/// a cosmetic gap, it is every `ManageRootAgent` holder losing its mounts +/// for the length of that window. +#[derive(Deserialize)] +#[serde(untagged)] +enum OnDisk { + Roster(BTreeSet), + /// The value was the parent name; only the keys carry over. + WithParents(std::collections::BTreeMap>), +} + +/// Snapshot of the agent roster. Read on every `container_view::build_all` /// and every `render_flake` call. The file is small (one line per agent), /// so we re-read rather than caching — keeps the source of truth on disk. /// -/// Returns an empty map when the file is absent or unparsable; callers -/// treat that as "no recorded parents", which falls back to every agent -/// being root-level. Safe degradation for fresh installs that haven't -/// run through `meta::sync_agents` yet. +/// Returns an empty set when the file is absent or unparsable. Safe +/// degradation for fresh installs that haven't run through +/// `meta::sync_agents` yet. #[must_use] -pub fn read() -> BTreeMap> { +pub fn read() -> BTreeSet { let path = topology_path(); let Ok(raw) = std::fs::read_to_string(&path) else { - return BTreeMap::new(); + return BTreeSet::new(); }; - serde_json::from_str(&raw).unwrap_or_default() + match serde_json::from_str::(&raw) { + Ok(OnDisk::Roster(names)) => names, + Ok(OnDisk::WithParents(map)) => map.into_keys().collect(), + Err(_) => BTreeSet::new(), + } } -/// Return the direct children of `name` — agents whose `topology.json` -/// entry has `name` as their parent. Reads the map once and scans all -/// entries; cheap enough for the fan-out path (one disk read per send -/// to ``). -#[must_use] -pub fn children_of(name: &str) -> Vec { - children_of_in(&read(), name) -} - -/// Pure form of [`children_of`] for unit tests. -#[must_use] -pub fn children_of_in(topo: &BTreeMap>, name: &str) -> Vec { - topo.iter() - .filter_map(|(agent, parent)| { - if parent.as_deref() == Some(name) { - Some(agent.clone()) - } else { - None - } - }) - .collect() -} - -/// Every agent the topology knows about, in name order. This is the set +/// Every agent the roster knows about, in name order. This is the set /// a [`hive_sh4re::permissions::Capability::ManageRootAgent`] holder gets /// bind-mounted, and it is deliberately unfiltered: that capability means /// "may manage any agent", so the set is all of them. -/// -/// It replaces a `top_level_agents()` that selected `parent.is_none()`. -/// With the hierarchy removed every agent is parentless, so the old -/// predicate already matched everything — keeping it would have hidden an -/// all-agents grant behind a filter that no longer filters. #[must_use] pub fn all_agents() -> Vec { all_agents_in(&read()) @@ -93,86 +73,15 @@ pub fn all_agents() -> Vec { /// Pure form of [`all_agents`] for unit tests. #[must_use] -pub fn all_agents_in(topo: &BTreeMap>) -> Vec { - topo.keys().cloned().collect() +pub fn all_agents_in(topo: &BTreeSet) -> Vec { + topo.iter().cloned().collect() } -/// Resolve a magic recipient sentinel (currently just -/// [`hive_sh4re::manager::PARENT_RECIPIENT`]) to a real broker recipient at -/// send time. Returns an owned `String` so callers can plug it -/// straight into [`crate::broker::Broker::send`] without -/// borrow-juggling around the temporary lookup. -/// -/// Rules + rationale: `docs/process/conventions.md::Recipient sentinels`. -/// Fast path: ordinary recipient names short-circuit before any -/// disk read — only `` triggers `read()` on `topology.json`. -#[must_use] -pub fn resolve_recipient(sender: &str, to: &str) -> String { - // Early exit: only sentinel recipients need topology lookup. This - // keeps the cost of a normal `send` at one string comparison. - if to != hive_sh4re::manager::PARENT_RECIPIENT { - return to.to_owned(); - } - resolve_recipient_in(&read(), sender, to) -} - -/// Pure form of [`resolve_recipient`] taking the topology map -/// explicitly. Split out so unit tests can exercise the sentinel -/// rules without writing a `topology.json` to disk. -#[must_use] -pub fn resolve_recipient_in( - topo: &BTreeMap>, - sender: &str, - to: &str, -) -> String { - if to == hive_sh4re::manager::PARENT_RECIPIENT { - topo.get(sender) - .cloned() - .flatten() - .unwrap_or_else(|| hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned()) - } else { - to.to_owned() - } -} - -/// Build an in-memory petgraph directed graph from the topology map. -/// -/// Edges run **parent → child** so that: -/// - `children_of(name)` = outgoing neighbours of `name`'s node -/// - cycle detection = `is_cyclic_directed` after a speculative edge insert -/// -/// Returns the graph and a `BTreeMap` for O(log n) -/// name-to-node lookups. Both are local to each call site — the graph is -/// not cached. Hive topologies are small (< ~100 nodes); building on demand -/// is dominated by the surrounding disk read. -#[must_use] -fn build_graph( - topo: &BTreeMap>, -) -> (DiGraph, BTreeMap) { - let mut graph: DiGraph = DiGraph::new(); - let mut idx: BTreeMap = BTreeMap::new(); - - // Add one node per agent. - for name in topo.keys() { - let ni = graph.add_node(name.clone()); - idx.insert(name.clone(), ni); - } - // Add parent→child edges. - for (name, parent_opt) in topo { - if let Some(parent) = parent_opt - && let (Some(&p_idx), Some(&c_idx)) = (idx.get(parent), idx.get(name.as_str())) - { - graph.add_edge(p_idx, c_idx, ()); - } - } - (graph, idx) -} - -/// Persist the topology map. Sorted JSON output (`BTreeMap` is sorted by -/// key) keeps git diffs minimal across re-writes. Best-effort — +/// Persist the roster. Sorted JSON output (`BTreeSet` iterates in key +/// order) keeps git diffs minimal across re-writes. Best-effort — /// returns an `io::Error` so callers can decide whether a failure -/// should abort their op (`sync_agents`, `RequestSetParent`) or just log. -pub fn write(topology: &BTreeMap>) -> std::io::Result<()> { +/// should abort their op (`sync_agents`) or just log. +pub fn write(topology: &BTreeSet) -> std::io::Result<()> { let path = topology_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; @@ -182,90 +91,15 @@ pub fn write(topology: &BTreeMap>) -> std::io::Result<()> std::fs::write(&path, format!("{text}\n")) } -/// Compute the default topology for a fresh install: every agent is a -/// root (parent = null). There is no structural "manager" — agents -/// arrange themselves via explicit parent edges, written by the operator -/// through the dashboard / `RequestSetParent` API. -/// Used by `meta::sync_agents` on first call to seed `topology.json`. -/// -/// As soon as an explicit write lands (dashboard / `RequestSetParent` -/// API), this seeding stops touching pre-existing entries — -/// `sync_agents` only adds rows for newly-spawned agents against -/// whatever the operator has configured. -#[must_use] -#[allow( - dead_code, - reason = "kept for the dashboard / RequestSetParent write API; \ - `sync_agents` does its own seeding today" -)] -pub fn default_seed(agent_names: &[String]) -> BTreeMap> { - let mut out = BTreeMap::new(); - for name in agent_names { - out.insert(name.clone(), None); - } - out -} - -/// Pure validation + apply for [`crate::meta::bulk_commit_topology`]. Splits off so tests -/// can exercise the rules (cycle / unknown) on an in-memory -/// `BTreeMap` without touching the on-disk `topology.json`. Returns -/// either the post-move map (caller writes it back) or a -/// user-readable error string. -/// -/// The manager is reparentable like any other agent — its special -/// powers come from the privileged MCP socket, not its tree -/// position. The cycle walk below covers "moving X under its own -/// descendant" for the manager as much as any other agent. -/// `docs/agent-lifecycle/agent-hierarchy.md::Reparenting` has the rationale. -pub fn apply_set_parent( - topo: &BTreeMap>, - child: &str, - new_parent: Option<&str>, -) -> Result>, String> { - if !topo.contains_key(child) { - return Err(format!("unknown agent: {child}")); - } - if let Some(p) = new_parent { - if !topo.contains_key(p) { - return Err(format!("unknown parent: {p}")); - } - if p == child { - return Err("an agent cannot be its own parent".to_owned()); - } - // Cycle check via petgraph: build the current graph, speculatively - // insert the proposed parent→child edge, then test for cycles with - // `is_cyclic_directed`. This replaces the earlier ad-hoc 32-hop - // ancestor walk — petgraph is correct for any tree depth and the - // algorithm is well-tested. - let (mut graph, idx) = build_graph(topo); - if let (Some(&p_ni), Some(&c_ni)) = (idx.get(p), idx.get(child)) { - graph.add_edge(p_ni, c_ni, ()); - if is_cyclic_directed(&graph) { - return Err(format!( - "cycle: {p} is in {child}'s subtree (would create a loop)" - )); - } - } - } - let mut next = topo.clone(); - next.insert(child.to_owned(), new_parent.map(str::to_owned)); - Ok(next) -} - -/// Reconcile `topology.json` against the current agent set. Adds an -/// entry (default: parent = null — a new agent with no declared parent -/// is its own root) for any agent missing from the file; removes -/// entries for agents no longer -/// present. Existing entries are preserved as-is — operator/manager -/// choices stick across regenerations. Returns true when the file -/// changed and should be re-committed by the caller. +/// Reconcile `topology.json` against the current agent set. Adds any agent +/// missing from the file; removes entries for agents no longer present. +/// Returns true when the file changed and should be re-committed by the +/// caller. /// /// `pending` lists agents that have a provisioned proposed config repo /// but no container yet (provisioned, not yet spawned). They are KEPT -/// (not dropped) so an explicit parent edge written before the first -/// spawn survives until the first apply-commit, but they are NOT seeded with a -/// default parent here — that happens when the container actually spawns -/// and the name moves into `agent_names`. +/// (not dropped) so an agent that exists on disk but has never booted is +/// still a name the hive knows about. pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result { let (next, changed) = apply_reconcile(&read(), agent_names, pending); if changed { @@ -274,32 +108,25 @@ pub fn reconcile(agent_names: &[String], pending: &[String]) -> std::io::Result< Ok(changed) } -/// Pure form of [`reconcile`] for unit tests. Adds missing live agents -/// at their default position, drops entries for agents that are neither -/// live nor pending-init, and reports whether anything changed. +/// Pure form of [`reconcile`] for unit tests. Adds missing live agents, +/// drops entries for agents that are neither live nor pending-init, and +/// reports whether anything changed. #[must_use] pub fn apply_reconcile( - current: &BTreeMap>, + current: &BTreeSet, agent_names: &[String], pending: &[String], -) -> (BTreeMap>, bool) { +) -> (BTreeSet, bool) { let mut next = current.clone(); let mut changed = false; for name in agent_names { - if !next.contains_key(name) { - // A new agent with no declared parent defaults to root - // (parent = null). An agent placed under a parent carries an - // explicit edge written before its first spawn, so it never - // hits this default — only spawns with no declared parent do, - // and those are roots. No agent is structurally privileged - // here: "root-ness" is just a null parent. - next.insert(name.clone(), None); + if next.insert(name.clone()) { changed = true; } } let known: std::collections::HashSet<&String> = agent_names.iter().chain(pending.iter()).collect(); - next.retain(|name, _| { + next.retain(|name| { let keep = known.contains(name); if !keep { changed = true; @@ -311,301 +138,91 @@ pub fn apply_reconcile( #[cfg(test)] mod tests { - use super::*; + use super::{BTreeSet, OnDisk, all_agents_in, apply_reconcile}; - #[test] - fn default_seed_makes_every_agent_root() { - // No structural manager: every agent defaults to root (null - // parent). Explicit edges are layered on later. - let agents = vec![ - "alice".to_owned(), - crate::lifecycle::MANAGER_NAME.to_owned(), - "bob".to_owned(), - ]; - let seed = default_seed(&agents); - assert_eq!(seed.get(crate::lifecycle::MANAGER_NAME), Some(&None)); - assert_eq!(seed.get("alice"), Some(&None)); - assert_eq!(seed.get("bob"), Some(&None)); + fn roster_three() -> BTreeSet { + ["alice", "bob", "carol"] + .into_iter() + .map(str::to_owned) + .collect() } #[test] - fn default_seed_handles_empty_input() { - let seed = default_seed(&[]); - assert!(seed.is_empty()); - } - - fn topo_three_level() -> BTreeMap> { - let mut m = BTreeMap::new(); - m.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None); - m.insert( - "alice".to_owned(), - Some(crate::lifecycle::MANAGER_NAME.to_owned()), - ); - m.insert("bob".to_owned(), Some("alice".to_owned())); - m.insert("carol".to_owned(), Some("alice".to_owned())); - m - } - - #[test] - fn apply_set_parent_promotes_to_root() { - let next = apply_set_parent(&topo_three_level(), "alice", None).unwrap(); - assert_eq!(next.get("alice"), Some(&None)); - } - - #[test] - fn apply_set_parent_reparents_under_sibling_subtree() { - // bob and carol both under alice; move carol under bob. - let next = apply_set_parent(&topo_three_level(), "carol", Some("bob")).unwrap(); - assert_eq!(next.get("carol"), Some(&Some("bob".to_owned()))); - } - - #[test] - fn apply_set_parent_allows_manager_move() { - // The manager is reparentable like any other agent (its - // privileges live on the MCP socket, not its tree position). - // Build a topo with an unrelated root-level agent `peer` so - // moving the manager under it doesn't trip the cycle walk - // (every non-manager agent in topo_three_level descends from - // the manager, so that fixture can't exercise a legal - // manager move). - let mut topo = BTreeMap::new(); - topo.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None); - topo.insert("peer".to_owned(), None); - let next = apply_set_parent(&topo, crate::lifecycle::MANAGER_NAME, Some("peer")) - .expect("manager move should succeed"); - assert_eq!( - next.get(crate::lifecycle::MANAGER_NAME), - Some(&Some("peer".to_owned())) - ); - } - - #[test] - fn apply_set_parent_refuses_manager_under_own_descendant() { - // Moving the manager under `bob` (who already lives under - // `alice` who lives under the manager) would close the loop. - // The general cycle walk catches this; no separate manager - // guard needed. - let err = apply_set_parent( - &topo_three_level(), - crate::lifecycle::MANAGER_NAME, - Some("bob"), - ) - .unwrap_err(); - assert!(err.contains("cycle"), "err = {err}"); - } - - #[test] - fn apply_set_parent_refuses_unknown_child() { - let err = apply_set_parent(&topo_three_level(), "nobody", Some("alice")).unwrap_err(); - assert!(err.contains("unknown agent"), "err = {err}"); - } - - #[test] - fn apply_set_parent_refuses_unknown_parent() { - let err = apply_set_parent(&topo_three_level(), "bob", Some("nobody")).unwrap_err(); - assert!(err.contains("unknown parent"), "err = {err}"); - } - - #[test] - fn apply_set_parent_refuses_self() { - let err = apply_set_parent(&topo_three_level(), "alice", Some("alice")).unwrap_err(); - assert!(err.contains("own parent"), "err = {err}"); - } - - #[test] - fn apply_set_parent_refuses_cycle() { - // bob's parent is alice; trying to make alice's parent = - // bob would close the loop alice → bob → alice. - let err = apply_set_parent(&topo_three_level(), "alice", Some("bob")).unwrap_err(); - assert!(err.contains("cycle"), "err = {err}"); - } - - #[test] - fn apply_set_parent_refuses_deep_cycle() { - // Three-deep chain: manager → alice → bob → carol. Moving - // alice under carol would create the loop alice → carol → bob → alice. - let mut topo = BTreeMap::new(); - topo.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None); - topo.insert( - "alice".to_owned(), - Some(crate::lifecycle::MANAGER_NAME.to_owned()), - ); - topo.insert("bob".to_owned(), Some("alice".to_owned())); - topo.insert("carol".to_owned(), Some("bob".to_owned())); - let err = apply_set_parent(&topo, "alice", Some("carol")).unwrap_err(); - assert!(err.contains("cycle"), "err = {err}"); - } - - #[test] - fn apply_set_parent_is_idempotent_noop() { - // bob is already under alice — same value returned. - let next = apply_set_parent(&topo_three_level(), "bob", Some("alice")).unwrap(); - assert_eq!(next, topo_three_level()); - } - - #[test] - fn apply_reconcile_adds_missing_live_agent_as_root() { - // A live agent with no prior topology entry defaults to root - // (null parent) — no structural manager to hang it under. + fn apply_reconcile_adds_missing_live_agent() { let live = vec![ crate::lifecycle::MANAGER_NAME.to_owned(), "newbie".to_owned(), ]; - let (next, changed) = apply_reconcile(&BTreeMap::new(), &live, &[]); + let (next, changed) = apply_reconcile(&BTreeSet::new(), &live, &[]); assert!(changed); - assert_eq!(next.get("newbie"), Some(&None)); - assert_eq!(next.get(crate::lifecycle::MANAGER_NAME), Some(&None)); + assert!(next.contains("newbie")); + assert!(next.contains(crate::lifecycle::MANAGER_NAME)); } #[test] fn apply_reconcile_drops_vanished_agent() { - let live = vec![ - crate::lifecycle::MANAGER_NAME.to_owned(), - "alice".to_owned(), - ]; - // carol + bob are gone from the live set and not pending. - let (next, changed) = apply_reconcile(&topo_three_level(), &live, &[]); + let live = vec!["alice".to_owned()]; + let (next, changed) = apply_reconcile(&roster_three(), &live, &[]); assert!(changed); - assert!(!next.contains_key("bob")); - assert!(!next.contains_key("carol")); - assert!(next.contains_key("alice")); + assert!(!next.contains("bob")); + assert!(!next.contains("carol")); + assert!(next.contains("alice")); } #[test] - fn apply_reconcile_keeps_pending_init_agent_edge() { - // `dora` was placed under alice (edge present) but has no - // container yet, so it's absent from the live set. It must NOT - // be dropped, and its alice-parent edge must be preserved (not - // re-seeded under the manager). - let mut topo = topo_three_level(); - topo.insert("dora".to_owned(), Some("alice".to_owned())); - let live = vec![ - crate::lifecycle::MANAGER_NAME.to_owned(), - "alice".to_owned(), - "bob".to_owned(), - "carol".to_owned(), - ]; + fn apply_reconcile_keeps_pending_init_agent() { + // `dora` was provisioned but has no container yet, so it's absent + // from the live set. It must NOT be dropped. + let mut roster = roster_three(); + roster.insert("dora".to_owned()); + let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()]; let pending = vec!["dora".to_owned()]; - let (next, changed) = apply_reconcile(&topo, &live, &pending); + let (next, changed) = apply_reconcile(&roster, &live, &pending); assert!(!changed, "no change expected: {next:?}"); - assert_eq!(next.get("dora"), Some(&Some("alice".to_owned()))); + assert!(next.contains("dora")); } #[test] - fn resolve_recipient_passes_through_ordinary_names() { - let topo = topo_three_level(); - // Real labels, broadcast, and the operator literal all - // shortcut through unchanged — no resolution magic. - assert_eq!(resolve_recipient_in(&topo, "bob", "alice"), "alice"); - assert_eq!(resolve_recipient_in(&topo, "bob", "*"), "*"); - assert_eq!( - resolve_recipient_in(&topo, "bob", hive_sh4re::manager::OPERATOR_RECIPIENT), - hive_sh4re::manager::OPERATOR_RECIPIENT - ); + fn apply_reconcile_is_a_noop_when_already_in_sync() { + let live = vec!["alice".to_owned(), "bob".to_owned(), "carol".to_owned()]; + let (next, changed) = apply_reconcile(&roster_three(), &live, &[]); + assert!(!changed); + assert_eq!(next, roster_three()); } #[test] - fn resolve_recipient_rewrites_parent_sentinel_to_parent_label() { - let topo = topo_three_level(); - // bob's parent is alice → `` from bob goes to alice. - assert_eq!( - resolve_recipient_in(&topo, "bob", hive_sh4re::manager::PARENT_RECIPIENT), - "alice" - ); - // alice's parent is the manager — same one-hop rewrite. - assert_eq!( - resolve_recipient_in(&topo, "alice", hive_sh4re::manager::PARENT_RECIPIENT), - crate::lifecycle::MANAGER_NAME - ); + fn all_agents_in_returns_every_name_sorted() { + assert_eq!(all_agents_in(&roster_three()), vec!["alice", "bob", "carol"]); } #[test] - fn resolve_recipient_falls_back_to_operator_for_root_agent() { - let topo = topo_three_level(); - // Manager is structurally root (parent = None) → `` - // resolves to the operator (the "no parent → tell mara" - // fallback documented in conventions.md). - assert_eq!( - resolve_recipient_in( - &topo, - crate::lifecycle::MANAGER_NAME, - hive_sh4re::manager::PARENT_RECIPIENT - ), - hive_sh4re::manager::OPERATOR_RECIPIENT - ); + fn all_agents_in_empty_roster_returns_empty() { + assert!(all_agents_in(&BTreeSet::new()).is_empty()); + } + + /// The upgrade path. A hive whose `topology.json` still carries the + /// pre-#4472 `name → parent` map must read as the same roster, parent + /// values discarded — otherwise the first read after the upgrade hands + /// `ManageRootAgent` holders an empty mount set. + #[test] + fn a_pre_4472_parent_map_reads_as_its_key_set() { + let raw = r#"{"alice": "bob", "bob": null, "carol": "bob"}"#; + let parsed: OnDisk = serde_json::from_str(raw).expect("legacy map parses"); + let names = match parsed { + OnDisk::Roster(n) => n, + OnDisk::WithParents(m) => m.into_keys().collect(), + }; + assert_eq!(names, roster_three()); } #[test] - fn resolve_recipient_falls_back_to_operator_for_unknown_sender() { - // Sender absent from topology entirely — defensive fallback - // covers the race window where an agent's spawn has registered - // its socket but the meta-flake `sync_agents` hasn't yet added - // its row. - let topo = topo_three_level(); - assert_eq!( - resolve_recipient_in(&topo, "nobody", hive_sh4re::manager::PARENT_RECIPIENT), - hive_sh4re::manager::OPERATOR_RECIPIENT - ); - } - - #[test] - fn children_of_in_returns_direct_descendants() { - let topo = topo_three_level(); - // alice's children: bob, carol. - let mut children = children_of_in(&topo, "alice"); - children.sort(); - assert_eq!(children, vec!["bob", "carol"]); - } - - #[test] - fn children_of_in_manager_returns_root_level_agents() { - let topo = topo_three_level(); - // Only alice's parent is manager; bob+carol are under alice. - let children = children_of_in(&topo, crate::lifecycle::MANAGER_NAME); - assert_eq!(children, vec!["alice"]); - } - - #[test] - fn children_of_in_leaf_returns_empty() { - let topo = topo_three_level(); - // bob and carol have no children. - assert!(children_of_in(&topo, "bob").is_empty()); - assert!(children_of_in(&topo, "carol").is_empty()); - } - - #[test] - fn children_of_in_unknown_sender_returns_empty() { - let topo = topo_three_level(); - assert!(children_of_in(&topo, "nobody").is_empty()); - } - - #[test] - fn all_agents_in_returns_every_name() { - let topo = topo_three_level(); - let mut all = all_agents_in(&topo); - all.sort(); - let mut expected = vec![crate::lifecycle::MANAGER_NAME, "alice", "bob", "carol"]; - expected.sort_unstable(); - assert_eq!(all, expected); - } - - /// The set behind the `ManageRootAgent` mount grant must not depend on - /// `parent`: a capability holder manages an agent whether or not that - /// agent sits under someone. This is the assertion the old - /// `top_level_agents_in` could not have made. - #[test] - fn all_agents_in_includes_parented_agents() { - let mut topo = BTreeMap::new(); - topo.insert("alice".to_owned(), Some("bob".to_owned())); - topo.insert("bob".to_owned(), None); - let mut all = all_agents_in(&topo); - all.sort(); - assert_eq!(all, vec!["alice", "bob"]); - } - - #[test] - fn all_agents_in_empty_topo_returns_empty() { - let topo = BTreeMap::new(); - assert!(all_agents_in(&topo).is_empty()); + fn a_roster_array_round_trips() { + let raw = serde_json::to_string(&roster_three()).expect("serialises"); + let parsed: OnDisk = serde_json::from_str(&raw).expect("array parses"); + let names = match parsed { + OnDisk::Roster(n) => n, + OnDisk::WithParents(m) => m.into_keys().collect(), + }; + assert_eq!(names, roster_three()); } } diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index a037f131..88bc56cb 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -49,14 +49,6 @@ pub struct ContainerView { /// for this agent's input. #[serde(skip_serializing_if = "Option::is_none")] pub deployed_sha: Option, - /// Name of this agent's parent in the agent hierarchy. `None` - /// marks the agent as root-level; the dashboard renders it without - /// indentation. Sourced from `meta/topology.json` (single source of - /// truth, hive-c0re-owned) — NOT from per-agent agent.nix, because - /// an agent shouldn't be able to unilaterally declare its own place - /// in the tree. See `docs/agent-lifecycle/agent-hierarchy.md::Where the tree lives`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent: Option, /// The Claude model the agent's harness is currently using, read from /// `state/hyperhive-harness.json["active_model"]`. `None` when the /// agent has never started a turn or the field is absent. Only @@ -121,7 +113,6 @@ impl From for hive_sh4re::container::AgentStatusRow { needs_login: v.needs_login, deployed_sha: v.deployed_sha, pending_reminders: 0, - parent: v.parent, paused: v.paused, active_model: v.active_model, status_text: v.status_text, @@ -164,13 +155,8 @@ fn agent_url_for_domain(domain: &str, name: &str) -> String { pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> Vec { let raw = lifecycle::list().await.unwrap_or_default(); let locked = read_meta_locked_revs(); - // Pull the topology map once and look up each agent's parent below. - // Empty / absent topology.json → every agent root-level (safe - // degradation for fresh installs that haven't run sync_agents yet). - let topology = crate::topology::read(); - // Same once-per-scan treatment as the topology map: the override file - // is read here and resolved per agent below, rather than re-read for - // every container on every SSE scan. + // Read once per scan rather than re-read for every container on every + // SSE scan. let limits = crate::resource_limits::read(); let mut out = Vec::new(); for c in &raw { @@ -189,7 +175,6 @@ pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> Vec let needs_update = crate::auto_update::agent_config_pending(logical.as_str(), deployed_full).await; let deployed_sha = deployed_full.map(|s| s[..s.len().min(12)].to_owned()); - let parent = topology.get(logical.as_str()).cloned().flatten(); // One `systemctl` call for both facts: `unit_state` is the same // shell-out `is_running` makes, minus `--quiet`. Asking twice would // double the per-agent subprocess count on every SSE scan. @@ -252,7 +237,6 @@ pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> Vec needs_update, needs_login, deployed_sha, - parent, active_model, status_text, status_set_at, @@ -547,7 +531,6 @@ mod tests { needs_update: true, needs_login: false, deployed_sha: Some("abc123def456".to_owned()), - parent: Some("bob".to_owned()), active_model: Some("claude-opus".to_owned()), status_text: Some("shipping".to_owned()), status_set_at: Some(Utc.timestamp_opt(1_700_000_000, 0).unwrap()), @@ -563,7 +546,6 @@ mod tests { assert!(row.needs_update); assert!(!row.needs_login); assert_eq!(row.deployed_sha.as_deref(), Some("abc123def456")); - assert_eq!(row.parent.as_deref(), Some("bob")); assert!(row.paused); assert_eq!(row.active_model.as_deref(), Some("claude-opus")); assert_eq!(row.status_text.as_deref(), Some("shipping")); diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 2daeafb9..35da2088 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -870,81 +870,6 @@ impl Coordinator { } } - /// Apply topology reparent(s) + fan the resulting notifications out to - /// the affected agents. Applies all moves under a single `META_LOCK` - /// acquisition (one git commit — a single-move call is just a - /// one-element slice) then, for each move that actually changed - /// topology, drops a one-line system message into the inbox of: - /// - /// 1. The **old parent** (if any) — `"{child} moved out of your - /// subtree to {new_parent_or_root}"`. - /// 2. The **new parent** (if any) — `"{child} just moved into - /// your subtree (was previously under - /// {old_parent_or_root})"`. - /// 3. The **moved agent** — `"your parent changed from - /// {old_parent_or_root} to {new_parent_or_root}"`. - /// - /// `_or_root` resolves to the literal string `""` when the - /// slot is `None`, keeping the wording consistent with the - /// `` sentinel's "root → operator" routing (see - /// `docs/process/conventions.md::Recipient sentinels`). The - /// notifications fire as ordinary broker messages with - /// `from = hive_sh4re::manager::SYSTEM_SENDER` so the dashboard renders - /// them under the existing system-source styling. - /// - /// First validation failure aborts the whole batch with no disk writes. - /// - /// # Errors - /// - /// Propagates any error returned by [`crate::meta::bulk_commit_topology`] - /// (validation failure or topology-file write error). - pub async fn reparent_bulk_with_notify( - self: &Arc, - moves: &[(&str, Option<&str>)], - ) -> std::result::Result<(), String> { - if moves.is_empty() { - return Ok(()); - } - // bulk_commit_topology applies all set_parent calls under one lock - // and returns (child, old_parent) for every move that changed. - let changed = crate::meta::bulk_commit_topology(moves).await?; - - // Send per-agent notifications for each changed move. - for (child, old_parent) in &changed { - // Find the new parent from the moves slice. - let new_parent = moves - .iter() - .find(|(c, _)| *c == child) - .and_then(|(_, np)| *np); - let old_label = old_parent.as_deref().unwrap_or(""); - let new_label = new_parent.unwrap_or(""); - if let Some(op) = old_parent.as_deref() { - let _ = self.broker.send(&hive_sh4re::inbox::Message { - from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::SYSTEM_SENDER), - to: op.to_owned(), - body: format!("{child} moved out of your subtree to {new_label}"), - in_reply_to: None, - }); - } - if let Some(np) = new_parent { - let _ = self.broker.send(&hive_sh4re::inbox::Message { - from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::SYSTEM_SENDER), - to: np.to_owned(), - body: format!( - "{child} just moved into your subtree (was previously under {old_label})" - ), - in_reply_to: None, - }); - } - let _ = self - .broker - .send_coalescing_reparent(child, old_label, new_label); - } - - self.rescan_containers_and_emit().await; - Ok(()) - } - /// Read-only snapshot of the last cached container view. Used by /// `/api/state` to cold-load page-open clients without re-running /// `nixos-container list` themselves; the diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index 86da1de5..f44d130f 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -47,7 +47,6 @@ use crate::lifecycle; (name = "state_files", description = "proxied reads of allow-listed per-agent state files"), (name = "state_snapshot", description = "cold-load dashboard snapshot"), (name = "tombstones", description = "purge of retained state for destroyed agents"), - (name = "topology", description = "operator-driven agent reparenting"), (name = "webhook", description = "forgejo webhook receivers"), ) )] @@ -72,7 +71,6 @@ mod schedules; mod state_files; mod state_snapshot; mod tombstones; -mod topology; mod webhook; // Run after lock bumps by the job queue (`job_queue/exec.rs`); the view @@ -161,8 +159,6 @@ pub async fn serve( .routes(routes!(build_logs::get_build_logs_agent)) .routes(routes!(build_logs::get_build_log_full)) .routes(routes!(build_logs::get_build_log_raw)) - .routes(routes!(topology::post_set_parent)) - .routes(routes!(topology::post_set_parent_bulk)) .routes(routes!(permissions::get_tool_groups)) .routes(routes!(permissions::post_tool_groups)) .routes(routes!(permissions::get_capabilities)) @@ -402,8 +398,6 @@ mod router_build_probe { .routes(routes!(build_logs::get_build_logs_agent)) .routes(routes!(build_logs::get_build_log_full)) .routes(routes!(build_logs::get_build_log_raw)) - .routes(routes!(topology::post_set_parent)) - .routes(routes!(topology::post_set_parent_bulk)) .routes(routes!(permissions::get_tool_groups)) .routes(routes!(permissions::post_tool_groups)) .routes(routes!(permissions::get_capabilities)) diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index 6e11ae06..bd14e9d1 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -805,7 +805,6 @@ mod tests { needs_update: false, needs_login: false, deployed_sha: None, - parent: None, active_model: None, status_text: None, status_set_at: None, diff --git a/hive-c0re/src/dashboard/topology.rs b/hive-c0re/src/dashboard/topology.rs deleted file mode 100644 index 37ec5045..00000000 --- a/hive-c0re/src/dashboard/topology.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Topology (set-parent) endpoints for the dashboard. -//! -//! Operator-driven agent reparenting — single (`/api/topology/set-parent`, -//! form-encoded) and bulk (`/api/topology/set-parent-bulk`, JSON array → -//! one git commit). Both submit a `NodeKind::Reparent` DAG to the job -//! queue (fire-and-forget, like every other queue-backed op — the -//! dashboard tree repaints off the queue's own snapshot/rescan once the -//! commit lands, same as a rebuild or restart). The executor delegates to -//! `Coordinator::reparent_bulk_with_notify`, which wraps -//! `crate::meta::bulk_commit_topology` with the move-notification messages -//! and the `ContainerView` rescan. - -use axum::{ - extract::{Form, State}, - http::StatusCode, - response::{IntoResponse, Response}, -}; -use serde::Deserialize; -use utoipa::ToSchema; - -use problem_details::ProblemDetails; - -use super::{AppState, error_problem}; - -/// `POST /api/topology/set-parent` body. `child` is required. -/// `new_parent` may be: -/// - absent or empty / whitespace-only → promote to root, -/// - non-empty → new parent's logical name. -/// -/// (The CLI surface gates "no parent specified" behind an explicit -/// `--root` flag for safety; the HTTP surface is permissive -/// because the dashboard form encodes "no value" as the empty -/// string for the optional radio-group input.) -#[derive(Deserialize, ToSchema)] -pub(super) struct SetParentForm { - child: String, - new_parent: Option, -} - -/// One entry in a `POST /api/topology/set-parent-bulk` JSON array. -/// `new_parent`: absent/null/empty-string all mean "promote to root". -#[derive(Deserialize, ToSchema)] -pub(super) struct SetParentBulkEntry { - child: String, - #[serde(default)] - new_parent: Option, -} - -/// Operator-driven parent move. -/// -/// Form fields: `child` (required, agent name), `new_parent` -/// (optional — empty / absent string ⇒ promote to root). Refuses -/// cycles and unknown agents (surfaced async on the job view — this -/// handler only validates the identifiers, not the move itself). The -/// manager is reparentable like any other agent — its privileges come -/// from the privileged MCP socket, not its tree position. Submitting -/// re-emits the queue snapshot immediately so the dashboard shows the -/// queued move without a refresh; the tree itself repaints once the -/// commit lands. -#[utoipa::path( - post, - path = "/api/topology/set-parent", - responses( - (status = 200, description = "reparent queued", body = String), - (status = 400, description = "missing/invalid child or new_parent identifier"), - ), - tag = "topology" -)] -pub(super) async fn post_set_parent( - State(state): State, - Form(form): Form, -) -> Result { - let child = form.child.trim().to_owned(); - if child.is_empty() { - return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) - .with_detail("set-parent: `child` required")); - } - let child = hive_types::Ident::parse(&child) - .map_err(|e| error_problem(&format!("set-parent: `child` {e}")))?; - // Empty / whitespace-only `new_parent` ⇒ promote to root. Web - // forms submit the empty string for a "no value" radio button, - // so this is the ergonomic encoding. - let new_parent = form - .new_parent - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(hive_types::Ident::parse) - .transpose() - .map_err(|e| error_problem(&format!("set-parent: `new_parent` {e}")))?; - tracing::info!( - child = %child, - new_parent = ?new_parent, - "operator: set-parent via dashboard" - ); - state - .coord - .job_queue - .insert_job(|b| { - crate::job_queue::templates::reparent(b, vec![(child, new_parent)]); - Vec::new() - }) - .expect("template-declared shapes are acyclic"); - state.coord.emit_rebuild_queue_snapshot(); - Ok((StatusCode::OK, "ok").into_response()) -} - -/// Move multiple agents in a -/// single request, producing **one** git commit. -/// -/// JSON body: `[{"child":"name", "new_parent":"target-or-null"}, ...]`. -/// Empty array is a no-op (200 OK). First identifier that fails to -/// parse aborts the whole batch before anything is submitted — a -/// partially-invalid bulk move never reaches the queue. -#[utoipa::path( - post, - path = "/api/topology/set-parent-bulk", - responses( - (status = 200, description = "reparents queued", body = String), - (status = 400, description = "an invalid child identifier in the batch"), - ), - tag = "topology" -)] -pub(super) async fn post_set_parent_bulk( - State(state): State, - axum::Json(body): axum::Json>, -) -> Result { - if body.is_empty() { - return Ok((StatusCode::OK, "ok").into_response()); - } - // Collect into `Result<_, String>` first, not `ProblemDetails` directly — - // clippy::result_large_err flags a ~232-byte `Err` variant threaded - // through this closure's `?`. `String` is small enough to satisfy the - // lint; the single `map_err` below promotes it to a `ProblemDetails` - // once, after the fallible collect. - let moves = body - .iter() - .map(|e| { - let child = hive_types::Ident::parse(e.child.trim()) - .map_err(|err| format!("set-parent-bulk: `{}` {err}", e.child))?; - let new_parent = e - .new_parent - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(hive_types::Ident::parse) - .transpose() - .map_err(|err| format!("set-parent-bulk: `{}` {err}", e.child))?; - Ok((child, new_parent)) - }) - .collect::, String>>() - .map_err(|e| error_problem(&e))?; - let names: Vec<&str> = body.iter().map(|e| e.child.as_str()).collect(); - tracing::info!(agents = ?names, "operator: set-parent-bulk via dashboard"); - state - .coord - .job_queue - .insert_job(|b| { - crate::job_queue::templates::reparent(b, moves); - Vec::new() - }) - .expect("template-declared shapes are acyclic"); - state.coord.emit_rebuild_queue_snapshot(); - Ok((StatusCode::OK, "ok").into_response()) -} diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 27309eba..6ac5cda7 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -60,7 +60,7 @@ pub(super) async fn run_node( kind: &NodeKind, ) -> (super::JobBuilder, Result<()>) { // The agent this node targets rides the payload — empty for the agentless - // kinds (`MetaLock`, `Reparent`), which never read it. + // kinds (`MetaLock`), which never read it. let agent = kind.agent(); // Every arm is `Result<()>`; the three that grow work declare into `builder` // *synchronously*, after their own awaits have finished. Borrowing `&builder` @@ -120,7 +120,6 @@ pub(super) async fn run_node( // takes it directly instead of re-matching the kind behind a `bail!` // that could never fire. NodeKind::WritePermFile { payload, .. } => run_write_perm_file(coord, agent, payload).await, - NodeKind::Reparent { moves } => run_reparent(coord, moves).await, NodeKind::MergeVerify { approval_id, .. } => { run_merge_verify(coord, *approval_id, id).await } @@ -815,33 +814,6 @@ async fn run_write_perm_file( Ok(()) } -/// Apply the node's `(child, new_parent)` moves as one `META_LOCK`-fused -/// commit (`Coordinator::reparent_bulk_with_notify`, which already handles -/// both the single- and bulk-move case, sends the per-agent move -/// notifications, and rescans + diff-emits the container tree). Runs under -/// the deploy window (it declares `Resource::MetaWindow`), same reasoning as -/// `run_write_perm_file`: a topology commit landing inside another node's -/// staged deploy window would sweep the staged lock into its commit. -async fn run_reparent( - coord: &Arc, - moves: &[(hive_types::Ident, Option)], -) -> Result<()> { - let refs: Vec<(&str, Option<&str>)> = moves - .iter() - .map(|(child, parent)| { - ( - child.as_str(), - parent.as_ref().map(hive_types::Ident::as_str), - ) - }) - .collect(); - coord - .reparent_bulk_with_notify(&refs) - .await - .map_err(|e| anyhow::anyhow!(e))?; - Ok(()) -} - /// Deploy phase 1 — drift gate, fetch, eval-verify. Mutates nothing, so a /// failure here cancel-cascades the rest of the subtree with the forge and the /// applied repo exactly as they were. @@ -890,7 +862,9 @@ async fn run_deploy_tail( /// flake affects — the fan-out set for `MetaUpdate` DAGs. Empty /// `inputs` or any input under `hyperhive` → every container; /// otherwise just the agents named by `agent-` inputs. -/// Topology-sorted so parents rebuild before their children. +/// Sorted by name — #4472 removed the parent field the old depth sort +/// keyed on, and with every agent a root that sort already reduced to +/// this. /// /// `inputs` is the caller-supplied flake-input-name list (the dashboard's /// `POST /api/meta-update` form field, operator-supplied but not @@ -918,8 +892,7 @@ pub async fn meta_update_cascade_agents(inputs: &[String]) -> Vec { } else { touched_agents }; - let topo = crate::topology::read(); - crate::auto_update::topology_sort(&mut names, &topo); + names.sort(); names } diff --git a/hive-c0re/src/job_queue/model.rs b/hive-c0re/src/job_queue/model.rs index 0ee175b3..eda0d9f5 100644 --- a/hive-c0re/src/job_queue/model.rs +++ b/hive-c0re/src/job_queue/model.rs @@ -102,12 +102,6 @@ pub enum NodeKind { WriteDropin { agent: String }, /// Commit `tool-groups.json` / `capabilities.json` per its `payload`. WritePermFile { agent: String, payload: PermPayload }, - /// Topology move(s) — `set-parent` (len 1) or `set-parent-bulk` (len N) — - /// as a single queue node. `moves` is typed `(Ident, Option)` - /// pairs, applied in order under one `META_LOCK` acquisition. - Reparent { - moves: Vec<(hive_types::Ident, Option)>, - }, /// Group root of the approval-deploy (`MergeConfigPr`) subtree — the /// **brace** that owns the deploy window. See _Braces_ and _Approvals_. DeployWindow { agent: String, approval_id: i64 }, @@ -203,7 +197,6 @@ impl hive_jobq_wire::WireNode for NodeKind { impl NodeKind { /// The agent this node targets, or `""` for agentless kinds /// ([`NodeKind::MetaLock`] on the `hyperhive` pseudo-agent, - /// [`NodeKind::Reparent`] which can span multiple agents, and /// [`NodeKind::ResolveApproval`] which acts on an approval row). #[must_use] pub fn agent(&self) -> &str { @@ -236,7 +229,6 @@ impl NodeKind { | NodeKind::EmitRebuilt { agent, .. } | NodeKind::SetWanted { agent, .. } => agent, NodeKind::MetaLock { .. } - | NodeKind::Reparent { .. } | NodeKind::ResolveApproval { .. } | NodeKind::ForgeSweep | NodeKind::MatrixSweep diff --git a/hive-c0re/src/job_queue/templates.rs b/hive-c0re/src/job_queue/templates.rs index 7e5575d4..64eb09f5 100644 --- a/hive-c0re/src/job_queue/templates.rs +++ b/hive-c0re/src/job_queue/templates.rs @@ -597,27 +597,6 @@ pub fn meta_update(builder: &JobBuilder, inputs: Vec, approval_id: Optio } } -/// Topology move(s) as a single-node DAG. `moves` is `(child, new_parent)` -/// pairs — len 1 for `set-parent`, len N for `set-parent-bulk`, applied -/// uniformly by the one [`NodeKind::Reparent`] node (which holds the global -/// meta window for its duration, same precedent as [`NodeKind::WritePermFile`]). -/// No rebuild subgraph: `topology.json` is read live by every consumer -/// (dashboard tree, ``/`` sentinel routing, permission -/// checks), so a parent move needs no container rebuild to take effect. -/// No transient pill either — the node is agentless (no lease to hang one -/// off of) and near-instant. No tail node: the write is the whole effect. -/// -/// Returns the single node's guid so a caller can wait on it. -pub fn reparent( - builder: &JobBuilder, - moves: Vec<(hive_types::Ident, Option)>, -) -> hive_jobq::NodeGuid { - builder - .node(NodeKind::Reparent { moves }) - .needs(Resource::MetaWindow) - .guid() -} - // The boot is assembled inline in `workers/auto_update.rs::submit_boot_tree` // as ONE `Boot` DAG (a sweep `MetaLock` root that grows rebuild subgraphs // in-DAG, plus a `Reconcile` root per drifted agent) — no anchor node and no diff --git a/hive-c0re/src/job_queue/tests.rs b/hive-c0re/src/job_queue/tests.rs index bbf06d45..b18fd019 100644 --- a/hive-c0re/src/job_queue/tests.rs +++ b/hive-c0re/src/job_queue/tests.rs @@ -49,10 +49,6 @@ fn insert_named( .collect() } -fn ident(s: &str) -> hive_types::Ident { - hive_types::Ident::parse(s).expect("valid test ident") -} - fn rebuild(builder: &JobBuilder, agent: &str) -> Vec { templates::rebuild(builder, agent, true) } @@ -186,14 +182,6 @@ fn node_of(q: &JobQueue, kind: &str) -> hive_jobq::NodeId { found.pop().expect("checked above") } -/// The payload of the one node of `kind`, for assertions about what a node -/// *carries* rather than how it is wired. -fn payload_of(q: &JobQueue, kind: &str) -> NodeKind { - let id = node_of(q, kind); - let sched = q.sched().lock().expect("job_queue mutex poisoned"); - sched.graph().node(id).expect("node exists").payload.clone() -} - /// Kinds of every node still `Pending` — the nodes that could yet run. /// Stronger than asking the scheduler what is *ready right now*: a node /// blocked on a dep is not ready but is very much still alive. @@ -1785,48 +1773,3 @@ fn perm_change_shape_prefixes_rebuild_chain() { "the perm write prefixes an otherwise ordinary rebuild chain" ); } - -#[test] -fn reparent_shape_is_a_lone_agentless_meta_window_node() { - // Single-move `set-parent` shape: one node, no rebuild subgraph (no - // container rebuild needed for a parent move), agentless like - // `MetaLock`, and it must declare the meta window — a topology commit - // must not land inside another node's staged deploy window. - let q = JobQueue::new(1); - insert(&q, |builder| { - templates::reparent(builder, vec![(ident("alice"), Some(ident("bob")))]); - }); - assert_eq!( - declared_shape(&q), - vec![row("reparent", None, &[])], - "one node, no rebuild subgraph" - ); - let node = node_of(&q, "reparent"); - assert_eq!( - declared_resources(&q, node), - vec![Resource::MetaWindow], - "a topology commit must declare the same MetaWindow as WritePermFile, \ - and nothing else — no lease (agentless), no build slot (no nix work)" - ); -} - -#[test] -fn reparent_bulk_shape_carries_every_move_on_one_node() { - // `set-parent-bulk`: still ONE node (one git commit, `moves.len() > 1`), - // not one node per move — bulk atomicity across every move in the - // request is the reason a single node was chosen in the first place. - let moves = vec![(ident("alice"), Some(ident("bob"))), (ident("carol"), None)]; - let q = JobQueue::new(1); - insert(&q, |builder| { - templates::reparent(builder, moves.clone()); - }); - assert_eq!( - declared_shape(&q), - vec![row("reparent", None, &[])], - "one node for the whole request, not one per move" - ); - let NodeKind::Reparent { moves: got } = payload_of(&q, "reparent") else { - panic!("expected a Reparent node"); - }; - assert_eq!(got, moves, "every move rides the single node"); -} diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 88aa6d3e..904dc23d 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -77,7 +77,8 @@ pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied"; /// clone is where a config change is staged, so it can hold a proposal /// that is still under review or was rejected outright — mounting it shows /// an agent a config which does not govern it. Both mounts (an agent's own -/// and a parent's view of a child's) go through here so they cannot drift. +/// and a `ManageRootAgent` holder's view of another agent's) go through +/// here so they cannot drift. /// /// Never empty under a live container: `provision_container` runs /// `setup_applied` before `create_only` makes the container at all. @@ -86,26 +87,30 @@ fn config_bind_source(name: &str) -> PathBuf { } /// Append bind flags for `child`'s state and config dirs into `binds`. -/// See docs/agent-lifecycle/persistence.md ("Parent access to child state") for what a -/// parent may touch and why. Creates missing host-side directories so -/// nspawn doesn't refuse to start; missing dirs are non-fatal. +/// See docs/agent-lifecycle/persistence.md ("Cross-agent access to state") +/// for what the holder may touch and why. Creates missing host-side +/// directories so nspawn doesn't refuse to start; missing dirs are +/// non-fatal. +/// +/// The only caller left is the `ManageRootAgent` grant — #4472 removed the +/// parent/child tree that used to hand every agent its own children here. /// /// **Three dirs, three different answers** — the uniformity of the /// original loop is what hid that: /// -/// - `state` is **read-write**: a parent reads and writes a child's notes -/// to recover it, which is the one case that needs to work while the -/// child is down. -/// - `config` is **read-only**, and read-only for the *parent* is the -/// point: a config change is a PR against the child's repo on the +/// - `state` is **read-write**: the holder reads and writes another +/// agent's notes to recover it, which is the one case that needs to +/// work while that agent is down. +/// - `config` is **read-only**, and read-only for the *holder* is the +/// point: a config change is a PR against the other agent's repo on the /// forge, reviewed and merged, never an edit in place. A writable mount /// here is a second path to the same file that skips the review — the /// boundary would then be a convention rather than a permission. -/// - `harness` is **absent entirely**. It holds the child's own runtime +/// - `harness` is **absent entirely**. It holds that agent's own runtime /// material — `bash-tasks/`, turn-stats and event sqlite dbs — none of -/// which a parent has a stated reason to read, let alone write. +/// which the holder has a stated reason to read, let alone write. /// hive-c0re still reads it directly on the host (`stats::hive_stats`), -/// which needs no bind mount into the parent. +/// which needs no bind mount into anyone else's container. /// /// ⚠️ The config-repo seeding hive-c0re does at spawn is **not** affected by /// the `config` flag and must not be read as a reason to widen it: that runs @@ -316,21 +321,12 @@ async fn set_nspawn_flags( read_only: true, }); - // Topology-driven child mounts: every direct child of this agent gets - // its state dir RW and its config dir RO. See `bind_child_agent_dirs` - // for why each is what it is. - let direct_children = crate::topology::children_of(agent_name); - for child in &direct_children { - bind_child_agent_dirs(child, &mut binds); - } - - // `ManageRootAgent` capability: additionally mount *every* agent in - // the hive as a virtual child. Enables recovery — the holder can - // update another agent's config even when that agent is down. Also - // grants RO access to /applied and /meta. + // `ManageRootAgent` capability: mount *every* agent in the hive as a + // virtual child. Enables recovery — the holder can update another + // agent's config even when that agent is down. Also grants RO access + // to /applied and /meta. // - // ⚠️ "every agent" reads as a widening next to the `children_of` - // mounts above, so: it is the definition of this capability, not an + // ⚠️ "every agent" is the definition of this capability, not an // accident of how the set is computed. The grant used to hang off a // `can_manage_top_level_agents` role and cover `top_level_agents()` // — i.e. `parent.is_none()` — which was "everything outside the @@ -338,13 +334,18 @@ async fn set_nspawn_flags( // parentless, so that set *was* every agent anyway; the capability // now says so out loud instead of deriving it from a field that no // longer discriminates. + // + // This is the *only* cross-agent mount left. #4472 dropped the + // topology parent field, and with it the unconditional grant every + // agent used to get over its own direct children — so an agent with + // no capability now sees its own dirs and nothing else. if crate::capabilities::has_cap(agent_name, Capability::ManageRootAgent) { // Skipping self is a no-op, not a narrowing: `agent_notes_dir` is // `agent_state_dir/state` and `config_bind_source` is shared, so // binding the holder as its own virtual child reproduced the two // own-dir mounts pushed above, byte for byte. for other in crate::topology::all_agents() { - if other != agent_name && !direct_children.contains(&other) { + if other != agent_name { bind_child_agent_dirs(&other, &mut binds); } } @@ -416,10 +417,11 @@ mod tests { binds } - /// The boundary, asserted as a whole rather than per-dir: a parent - /// sees a child's `state` and `config`, and nothing else. + /// The boundary, asserted as a whole rather than per-dir: a + /// `ManageRootAgent` holder sees another agent's `state` and + /// `config`, and nothing else. #[test] - fn parent_sees_only_child_state_and_config() { + fn holder_sees_only_other_agents_state_and_config() { let paths: Vec = child_binds() .into_iter() .map(|b| b.container_path) @@ -448,21 +450,21 @@ mod tests { ); } - /// The regression this exists for. `harness` holds the child's own - /// runtime material and was only ever mounted because one loop + /// The regression this exists for. `harness` holds the other agent's + /// own runtime material and was only ever mounted because one loop /// treated all three dirs alike — re-adding it to that loop is a /// one-word change that nothing else would catch. #[test] - fn parent_never_sees_a_child_harness_dir() { + fn a_holder_never_sees_another_agents_harness_dir() { for bind in child_binds() { assert!( !bind.container_path.contains("harness"), - "child harness must not be bound into a parent: {}", + "another agent's harness must not be bound in: {}", bind.container_path ); assert!( !bind.host_path.contains("harness"), - "child harness must not be bound into a parent: {}", + "another agent's harness must not be bound in: {}", bind.host_path ); } diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index e6c69200..3f26dd4b 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -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 = agents.iter().map(|a| a.name.clone()).collect(); let pending: Vec = 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)>, 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("")) - } else { - let names: Vec<&str> = moves.iter().map(|(c, _)| *c).collect(); - let dest = moves[0].1.unwrap_or(""); - 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, diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index e9cbbf08..0d0e3440 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -207,26 +207,6 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { actions::deny(&coord, *id, None)?; HostResponse::success() } - HostRequest::SetParent { child, new_parent } => { - tracing::info!(%child, ?new_parent, "set_parent"); - // Fire-and-forget, like every other queue-backed op: - // submit returns a DAG id immediately, the caller polls - // `QueueDag` (`hivectl`'s wait/progress loop) for the - // outcome instead of blocking here on the commit. - let inserted = coord.job_queue.insert_job(|b| { - vec![crate::job_queue::templates::reparent( - b, - vec![(child.clone(), new_parent.clone())], - )] - }); - match inserted { - Ok(ids) => { - coord.emit_rebuild_queue_snapshot(); - HostResponse::queued(ids.into_iter().map(hive_jobq::NodeId::get).collect()) - } - Err(e) => HostResponse::error(format!("queue reparent: {e}")), - } - } HostRequest::SetResourceLimits { name, cpu_quota, diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index 1ce0c746..85ed2523 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -712,36 +712,9 @@ fn check_can_cancel_approval(canceller: &str) -> Result<(), String> { } } -/// Fan out one message to each recipient in `targets`. Skips the sender -/// itself. Returns a list of `": "` strings for any delivery -/// failures (empty = all good). -pub(crate) fn fan_out_send( - coord: &Arc, - from: &str, - body: &str, - in_reply_to: Option, - targets: &[String], -) -> Vec { - let mut errors = Vec::new(); - for target in targets { - if target == from { - continue; - } - if let Err(e) = coord.broker.send(&Message { - from: hive_sh4re::manager::trusted_sender(from), - to: target.clone(), - body: body.to_owned(), - in_reply_to, - }) { - errors.push(format!("{target}: {e}")); - } - } - errors -} - /// Common Send handler shared between dispatch arms. Applies the -/// 4 KiB body cap, then routes broadcast (`to == "*"`) / children fan-out -/// (`to == ""`) / unicast through their respective broker calls. +/// 4 KiB body cap, then routes broadcast (`to == "*"`) / unicast through +/// their respective broker calls. /// `pub(crate)` so `dispatch_shared` can use it across both socket paths. pub(crate) fn handle_send( coord: &Arc, @@ -763,50 +736,30 @@ pub(crate) fn handle_send( } }; } - // ``: fan out to every direct descendant of the sender per - // topology.json. Bypasses the allow-list check — structural fan-out - // targets are never user-listed peers. No-op (returns Ok) for leaf - // agents that have no children. - if to == hive_sh4re::manager::CHILDREN_RECIPIENT { - let children = crate::topology::children_of(agent); - let errors = fan_out_send(coord, agent, body, in_reply_to, &children); - return if errors.is_empty() { - Response::Ok - } else { - Response::Err { - message: format!("children fan-out failed for agents: {}", errors.join(", ")), - } - }; - } - // Resolve magic-recipient sentinels (``) against topology.json; - // no-op for ordinary names. Lets agents address structural roles without - // learning the label — runtime reparenting propagates for free. See - // `docs/process/conventions.md::Recipient sentinels`. - let resolved = crate::topology::resolve_recipient(agent, to); - // Validate that the resolved recipient is a known local agent or the + // Validate that the recipient is a known local agent or the // special "operator" recipient. Without this check a typo in `to` // silently queues a message nobody will ever read. // // Cross-hive messaging (`name@hive` qualified names) is not routed // through the broker — use the Matrix MCP tools for that instead. - if resolved.contains('@') { + if to.contains('@') { return Response::Err { message: format!( - "send failed: cross-hive recipient `{resolved}` is not supported \ + "send failed: cross-hive recipient `{to}` is not supported \ via the broker — use Matrix MCP tools for cross-hive messaging" ), }; } - if resolved != hive_sh4re::manager::OPERATOR_RECIPIENT { + if to != hive_sh4re::manager::OPERATOR_RECIPIENT { // A name that doesn't parse as an Ident can't be a local agent, so // it collapses into the same "unknown recipient" error as a valid // name with no state dir. - let exists = hive_types::Ident::parse(&resolved) + let exists = hive_types::Ident::parse(to) .is_ok_and(|id| crate::paths::agent_state_dir(&id).exists()); if !exists { return Response::Err { message: format!( - "send failed: unknown recipient `{resolved}` \ + "send failed: unknown recipient `{to}` \ (no agent with that name exists on this hive)" ), }; @@ -814,7 +767,7 @@ pub(crate) fn handle_send( } match coord.broker.send(&Message { from: hive_sh4re::manager::trusted_sender(agent), - to: resolved, + to: to.to_owned(), body: body.to_owned(), in_reply_to, }) { diff --git a/hive-c0re/src/stats/host_stats.rs b/hive-c0re/src/stats/host_stats.rs index 56459f0f..76b6e1a1 100644 --- a/hive-c0re/src/stats/host_stats.rs +++ b/hive-c0re/src/stats/host_stats.rs @@ -252,7 +252,6 @@ mod tests { needs_update: false, needs_login, deployed_sha: None, - parent: None, active_model: None, status_text: None, status_set_at: None, diff --git a/hive-c0re/src/stores/broker.rs b/hive-c0re/src/stores/broker.rs index 7a4f54a0..f2df9265 100644 --- a/hive-c0re/src/stores/broker.rs +++ b/hive-c0re/src/stores/broker.rs @@ -355,83 +355,6 @@ impl Broker { Ok(u64::try_from(n.max(0)).unwrap_or(0)) } - /// Send a "your parent changed from X to Y" notification to `child`, - /// coalescing with any existing undelivered one so that multiple moves - /// while the agent is offline collapse into a single message spanning - /// the full arc (e.g. A→B then B→C becomes "your parent changed from A to C"). - /// - /// If an undelivered system reparent notification for `child` already - /// exists, its body is updated in-place preserving the original "from" - /// label. If none exists, a fresh message is inserted with `old_label` - /// as the source. - pub fn send_coalescing_reparent( - &self, - child: &str, - old_label: &str, - new_label: &str, - ) -> Result<()> { - const PREFIX: &str = "your parent changed from "; - const SEPARATOR: &str = " to "; - let conn = self.conn.lock().unwrap(); - let now = Utc::now().timestamp(); - let existing: Option<(i64, String)> = conn - .query_row( - "SELECT id, body FROM messages - WHERE recipient = ?1 - AND sender = ?2 - AND body LIKE ?3 - AND delivered_at IS NULL - AND acked_at IS NULL - LIMIT 1", - params![ - child, - hive_sh4re::manager::SYSTEM_SENDER, - format!("{PREFIX}%") - ], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), - ) - .optional()?; - if let Some((row_id, old_body)) = existing { - // Preserve the original "from" label from the earlier notification. - let original_from = old_body - .strip_prefix(PREFIX) - .and_then(|rest| rest.split(SEPARATOR).next()) - .unwrap_or(old_label); - let new_body = format!("{PREFIX}{original_from}{SEPARATOR}{new_label}"); - conn.execute( - "UPDATE messages SET body = ?1, sent_at = ?2 WHERE id = ?3", - params![new_body, now, row_id], - )?; - drop(conn); - let _ = self.events.send(MessageEvent::Sent { - id: row_id, - from: hive_sh4re::manager::SYSTEM_SENDER.to_owned(), - to: child.to_owned(), - body: new_body, - at: now, - in_reply_to: None, - }); - } else { - let body = format!("{PREFIX}{old_label}{SEPARATOR}{new_label}"); - conn.execute( - "INSERT INTO messages (sender, recipient, body, sent_at, in_reply_to) - VALUES (?1, ?2, ?3, ?4, NULL)", - params![hive_sh4re::manager::SYSTEM_SENDER, child, body, now], - )?; - let row_id = conn.last_insert_rowid(); - drop(conn); - let _ = self.events.send(MessageEvent::Sent { - id: row_id, - from: hive_sh4re::manager::SYSTEM_SENDER.to_owned(), - to: child.to_owned(), - body, - at: now, - in_reply_to: None, - }); - } - Ok(()) - } - /// Long-poll variant of `recv_batch`: returns immediately if any /// row is pending (popping up to `max`); otherwise waits up to /// `timeout` for the broker to emit a `Sent { to: recipient }` diff --git a/hive-c0re/src/workers/auto_update.rs b/hive-c0re/src/workers/auto_update.rs index 2e441ec0..09a799e0 100644 --- a/hive-c0re/src/workers/auto_update.rs +++ b/hive-c0re/src/workers/auto_update.rs @@ -250,44 +250,6 @@ fn seed_manager_capabilities() { } } -/// Sort `names` in-place so parents precede their children in the topology. -/// Uses BFS from root agents (depth 0). Agents absent from `topo` sort last, -/// alphabetically within their tier. Stable within each depth tier. -pub fn topology_sort( - names: &mut [String], - topo: &std::collections::BTreeMap>, -) { - use std::collections::{HashMap, VecDeque}; - // Build depth map using owned clones so the borrow on `names` is released - // before the sort_by mutable borrow. - let name_set: Vec = names.to_vec(); - let mut depth: HashMap = HashMap::new(); - let mut queue: VecDeque = VecDeque::new(); - // Seed roots: entries with no parent, or names not present in topo at all. - for name in &name_set { - if topo.get(name).is_none_or(Option::is_none) { - depth.insert(name.clone(), 0); - queue.push_back(name.clone()); - } - } - // BFS to assign depths to children. - while let Some(parent) = queue.pop_front() { - let d = depth[&parent] + 1; - for name in &name_set { - let is_child = topo.get(name).and_then(|p| p.as_deref()) == Some(parent.as_str()); - if is_child && !depth.contains_key(name) { - depth.insert(name.clone(), d); - queue.push_back(name.clone()); - } - } - } - names.sort_by(|a, b| { - let da = depth.get(a).copied().unwrap_or(usize::MAX); - let db = depth.get(b).copied().unwrap_or(usize::MAX); - da.cmp(&db).then(a.cmp(b)) - }); -} - /// Boot reconcile (see the module doc): classify every agent by rev /// freshness + persisted `wanted` intent, submit one `Boot` DAG /// (hyperhive lock bump growing an in-DAG rebuild subgraph per stale @@ -305,16 +267,16 @@ pub async fn run(coord: Arc) -> Result<()> { let current_rev = current_flake_rev(&coord.hyperhive_flake); - // Resolve container names to logical agent names, then sort by - // topology depth so parents are always rebuilt before their - // children. Root agents (depth 0) go first; agents absent from - // the topology file sort last (stable, alphabetical within tier). + // Resolve container names to logical agent names, then sort. #4472 + // removed the parent field this used to depth-sort by; with no + // hierarchy left to respect, alphabetical is the whole order — and it + // is exactly what the depth sort already produced once every agent + // was a root. let mut logical_names: Vec = containers .iter() .filter_map(|c| c.strip_prefix(AGENT_PREFIX).map(str::to_owned)) .collect(); - let topo = crate::topology::read(); - topology_sort(&mut logical_names, &topo); + logical_names.sort(); // Classify. `get_or_seed` doubles as the one-time migration: an // agent without an `agent_power` row is seeded from its observed @@ -374,8 +336,8 @@ pub async fn run(coord: Arc) -> Result<()> { // Rebuild running agents first. All fanout entries are wanted=Up; // among them, warm the live/serving agents onto the fresh config before the // stopped-but-wanted-up ones so the scarce build slots hit uptime-critical - // agents first. Stable sort keeps topology order (parents before children) - // within each running/stopped group. The `drifted` reconciles aren't sorted + // agents first. Stable sort keeps the alphabetical order within each + // running/stopped group. The `drifted` reconciles aren't sorted // — they hold no build slot and run concurrently, so their order is moot. fanout.sort_by_key(|(_, running)| !running); let fanout: Vec = fanout.into_iter().map(|(name, _)| name).collect(); diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 496afed2..2e9dcae0 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -202,7 +202,7 @@ pub enum HostRequest { /// credentials into the existing state tree. AgentExists { name: Ident }, /// List managed agents with their full status + technical state - /// (running / needs-login / needs-update / deployed sha / parent / + /// (running / needs-login / needs-update / deployed sha / /// pending reminders) — the `hivectl list-agents` roster view. /// Reuses the dashboard's per-agent `ContainerView` aggregation. AgentStatus, @@ -247,14 +247,6 @@ pub enum HostRequest { Approve { id: i64 }, /// Deny a pending request by id. Deny { id: i64 }, - /// Move an agent in the topology tree. `new_parent = None` - /// promotes the agent to root, `Some(name)` sets a new parent. - /// Validation rules + bind-mount caveat documented in - /// `docs/agent-lifecycle/agent-hierarchy.md::Reparenting`. - SetParent { - child: Ident, - new_parent: Option, - }, /// Declare an agent's CPU/memory overrides for the per-container /// systemd drop-in, persisted to `meta/resource-limits.json`. /// diff --git a/hive-sh4re/src/container.rs b/hive-sh4re/src/container.rs index 33ce770d..f928ad8b 100644 --- a/hive-sh4re/src/container.rs +++ b/hive-sh4re/src/container.rs @@ -53,9 +53,6 @@ pub struct AgentStatusRow { /// paused while stopped, and pause survives a restart. #[serde(default)] pub paused: bool, - /// Parent in the topology tree. `None` marks a root-level agent. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent: Option, /// The Claude model the agent's harness is currently using. Mirrors /// `container_view::ContainerView::active_model`: `None` when the /// agent has never started a turn, the field is absent from its diff --git a/hive-sh4re/src/manager.rs b/hive-sh4re/src/manager.rs index 9323f2da..c21ed357 100644 --- a/hive-sh4re/src/manager.rs +++ b/hive-sh4re/src/manager.rs @@ -17,23 +17,6 @@ pub const MANAGER_AGENT: &str = "ruth"; /// dashboard's inbox view — they are never `recv`'d by an agent harness. pub const OPERATOR_RECIPIENT: &str = "operator"; -/// Reserved magic recipient — `send(to: "", ...)` is rewritten -/// by hive-c0re at delivery time to whoever `topology::parent_of(sender)` -/// returns, or to [`OPERATOR_RECIPIENT`] when the sender is a root agent -/// (no parent). Lets agents address their parent without hardcoding the -/// label, so runtime reparenting requires no agent-side restart. The -/// angle brackets are not valid in agent names (validators reject -/// `<`/`>`), so this name can never collide with a real recipient. -pub const PARENT_RECIPIENT: &str = ""; - -/// Reserved magic recipient — `send(to: "", ...)` fans out to -/// every agent whose direct parent (per `topology.json`) is the sender. -/// Lets a sub-manager nudge its subtree without enumerating labels at -/// call-time; topology changes propagate for free. The angle brackets -/// are structurally safe — agent name validation rejects `<`/`>`. -/// Delivers to an empty set (no-op) for leaf agents that have no children. -pub const CHILDREN_RECIPIENT: &str = ""; - /// Sender hive-c0re uses for events it pushes into the manager's inbox. /// Manager harness recognises this and parses the body as a `HelperEvent`. pub const SYSTEM_SENDER: &str = "system"; @@ -113,9 +96,7 @@ pub struct SchedulePromptPayload { #[cfg(test)] mod reserved_name_tests { - use super::{ - CHILDREN_RECIPIENT, MANAGER_AGENT, OPERATOR_RECIPIENT, PARENT_RECIPIENT, SYSTEM_SENDER, - }; + use super::{MANAGER_AGENT, OPERATOR_RECIPIENT, SYSTEM_SENDER}; use hive_types::{Ident, RESERVED_NAMES_ENV, is_reserved_name}; /// The blacklist as nix rendered it for this test run. @@ -182,24 +163,6 @@ mod reserved_name_tests { } } - /// The other half, and the reason the test above is not vacuous: these - /// sentinels are *unreachable* as agent names because the charset - /// rejects them, so they are correctly absent from the list. If a - /// charset change ever made one parseable, it would become a real - /// collision and this test is what notices. - #[test] - fn bracketed_recipients_cannot_be_agent_names() { - let owned = reserved(); - let reserved: Vec<&str> = owned.iter().map(String::as_str).collect(); - for sentinel in [PARENT_RECIPIENT, CHILDREN_RECIPIENT] { - assert!( - Ident::parse(sentinel).is_err(), - "{sentinel:?} parses as an ident now — it is reachable as an agent name and must be reserved" - ); - assert!(!is_reserved_name(sentinel, &reserved)); - } - } - /// `ruth` is a real agent, not a protocol literal, so it is not /// reserved: a second agent wanting the name is a *taken* name, which /// the roster check answers. Recorded as a test so the distinction is diff --git a/hivectl/src/agents.rs b/hivectl/src/agents.rs index a4eda3d0..66c77215 100644 --- a/hivectl/src/agents.rs +++ b/hivectl/src/agents.rs @@ -1,6 +1,6 @@ //! `hivectl agent ` — everything scoped to ONE managed agent: //! container lifecycle over the host admin socket (restart/pause/resume/ -//! spawn/kill/destroy/rebuild/set-parent/set-limits/choom/watch), plus the +//! spawn/kill/destroy/rebuild/set-limits/choom/watch), plus the //! `quota` and `subvol` groups, whose handlers live in their own modules. //! `agents_list` (`hivectl list-agents`) is the one genuinely hive-wide //! read that lives in this module too since it shares the same daemon @@ -172,15 +172,14 @@ pub(crate) async fn agents_list(socket: &Path, json: bool) -> Result<()> { } s }; - let headers = ["NAME", "STATUS", "REV", "PARENT", "REMIND"]; - let table: Vec<[String; 5]> = rows + let headers = ["NAME", "STATUS", "REV", "REMIND"]; + let table: Vec<[String; 4]> = rows .iter() .map(|r| { [ r.name.clone(), status_of(r), r.deployed_sha.clone().unwrap_or_else(|| "-".to_owned()), - r.parent.clone().unwrap_or_else(|| "-".to_owned()), if r.pending_reminders > 0 { r.pending_reminders.to_string() } else { @@ -291,18 +290,6 @@ pub(crate) async fn run_agent(socket: &Path, name: &str, cmd: AgentCmd) -> Resul let name = crate::util::parse_ident(name)?; render(crate::client::request(socket, HostRequest::Rebuild { name }).await?) } - AgentCmd::SetParent { parent, root } => { - let child = crate::util::parse_ident(name)?; - let new_parent = if root { - None - } else { - parent.map(|p| crate::util::parse_ident(&p)).transpose()? - }; - render( - crate::client::request(socket, HostRequest::SetParent { child, new_parent }) - .await?, - ) - } AgentCmd::SetLimits { cpu_quota, memory_max, diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index 542c36e6..a4de9784 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -507,16 +507,6 @@ pub enum AgentCmd { }, /// Apply pending config to this managed container. Rebuild, - /// Move this agent in the topology tree — under a new parent, or to - /// root. - SetParent { - /// New parent agent name. Mutually exclusive with `--root`. - #[arg(long, conflicts_with = "root", required_unless_present = "root")] - parent: Option, - /// Promote this agent to root (no parent). - #[arg(long)] - root: bool, - }, /// Declare this agent's CPU/memory limits, overriding the hive-wide /// defaults. ///