topology: drop the parent field and the hierarchy it fed

`topology.json` was a map of `name -> parent | null`, and that value fed
the whole agent hierarchy: `<parent>` / `<children>` recipient sentinels,
the reparenting API (CLI verb, wire verb, dashboard endpoints, DAG node),
the dashboard tree, the rebuild depth sort, and an unconditional
bind-mount grant giving every agent RW on its direct children's state.

Per the operator's ruling the field goes, and with it all of the above.
The file survives as what remains once the value is gone: the roster of
agent names, which is the set `ManageRootAgent` grants mounts over. It is
now a JSON array; `read` still accepts the old map shape and keeps its
keys, so a hive that upgrades across this does not blank its roster (and
so no capability holder loses its mounts for the length of that window).

Two sites kept their behaviour under a different recipient rather than
losing it. Both addressed `<parent>`, which the broker already resolved to
`operator` for a root agent, and every agent is now what that fallback
called a root:

- the harness's turn-failure / plugin-failure notification
  (`Surface::send_to_parent` -> `send_to_operator`), and
- the send allow-list's always-permitted escape hatch, so an agent with a
  restrictive allow-list still has a way to say it is stuck.

What is NOT preserved, deliberately: an agent with no capability no longer
sees any other agent's dirs. `ManageRootAgent`'s own grant is unchanged --
still every agent in the roster, still state RW + config RO, still no
`harness`.

The dashboard's reparenting control (the M0V3 picker) is deleted with its
CSS. The tree rendering that reads `ContainerView.parent` is left for the
frontend owner -- it degrades to a flat list with the field gone.
This commit is contained in:
atlas 2026-09-21 21:04:22 +02:00 committed by atlas
commit d94bc2188d
28 changed files with 236 additions and 1513 deletions

View file

@ -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
/// `<parent>` 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 `<unknown>` 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<Output = ()>;
/// Send a message addressed to `<parent>` (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<Output = ()>;
/// 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 `<parent>` 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<Output = ()>;
/// 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<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
}
let files = turn::TurnFiles::prepare(socket, &label).await?;
// Plugin install failures come back as a Vec<String> — route each
// through `<parent>` via the `send_to_parent` failure-notify path.
// The broker resolves `<parent>` 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<S: Surface>(
/// 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<S: Surface>(
@ -1063,6 +1066,6 @@ async fn handle_turn_error_recovery<S: Surface>(
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;
}
}

View file

@ -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 `<parent>` 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<String> {
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
return Vec::new();

View file

@ -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),
}