`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.
177 lines
7.7 KiB
Rust
177 lines
7.7 KiB
Rust
//! Manager socket — `/run/hyperhive/manager/mcp.sock` on the host,
|
|
//! bind-mounted into the manager container at `/run/hive/mcp.sock`.
|
|
//! Reserved recipient names/senders, the out-of-band `HelperEvent`
|
|
//! payload hive-c0re pushes into the manager's inbox, and the
|
|
//! schedule-prompt submission payload.
|
|
|
|
use hive_types::Ident;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::approvals::ApprovalStatus;
|
|
|
|
/// Logical name the broker uses for the manager.
|
|
pub const MANAGER_AGENT: &str = "ruth";
|
|
|
|
/// Logical name the broker uses for the human operator. Messages with
|
|
/// `to = OPERATOR_RECIPIENT` accumulate in sqlite and surface on the
|
|
/// dashboard's inbox view — they are never `recv`'d by an agent harness.
|
|
pub const OPERATOR_RECIPIENT: &str = "operator";
|
|
|
|
/// 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";
|
|
|
|
/// Parse `s` as a [`Ident`] for use as `Message.from`, falling back to
|
|
/// [`SYSTEM_SENDER`] on the (should-be-unreachable) case that `s` isn't
|
|
/// ident-shaped. `Message.from` is always either a fixed sentinel literal
|
|
/// (`SYSTEM_SENDER`, `OPERATOR_RECIPIENT`, `"scheduled"`, …) or an
|
|
/// already-registered agent's own name reaching this point through
|
|
/// hive-c0re's internal dispatch — never arbitrary external input — so
|
|
/// this is a defensive fallback for a programming-bug case, not a
|
|
/// validation gate.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Never, unless [`SYSTEM_SENDER`] itself stops being ident-shaped (which
|
|
/// would also be a programming bug, caught by `hive-types`' own tests).
|
|
#[must_use]
|
|
pub fn trusted_sender(s: &str) -> Ident {
|
|
Ident::parse(s)
|
|
.unwrap_or_else(|_| Ident::parse(SYSTEM_SENDER).expect("SYSTEM_SENDER is a valid Ident"))
|
|
}
|
|
|
|
/// Out-of-band events the host-side daemon pushes to the manager's inbox.
|
|
/// Serialised as JSON in `Message::body` (sender = `SYSTEM_SENDER`).
|
|
/// Per-variant triggers + the optional `sha`/`tag` semantics live in
|
|
/// `docs/agent-lifecycle/approvals.md::Helper events to the submitting agent`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "event", rename_all = "snake_case")]
|
|
pub enum HelperEvent {
|
|
/// An approval transitioned to a terminal state.
|
|
ApprovalResolved {
|
|
id: i64,
|
|
agent: String,
|
|
commit_ref: String,
|
|
status: ApprovalStatus,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
note: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
sha: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
tag: Option<String>,
|
|
},
|
|
/// A sub-agent's recorded flake rev is stale relative to hyperhive.
|
|
NeedsUpdate { agent: String },
|
|
/// Container exited without an operator-initiated stop (crash).
|
|
ContainerCrash {
|
|
agent: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
note: Option<String>,
|
|
},
|
|
}
|
|
|
|
/// Submission payload for `RequestSchedulePrompt`. Lives outside the
|
|
/// enum so it can also serialize into the approval row's `commit_ref`
|
|
/// (the dispatcher re-parses it on approve and inserts the schedule).
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct SchedulePromptPayload {
|
|
/// Names of recipient agents. Operator + `root` allowed.
|
|
pub targets: Vec<String>,
|
|
/// Message body delivered to each target's inbox at fire time.
|
|
/// Same size budget as `Send.body` — soft cap at the broker level.
|
|
pub body: String,
|
|
/// Absolute unix timestamp (seconds) for the FIRST fire. For
|
|
/// recurring schedules the worker then re-arms in
|
|
/// `interval_seconds` steps.
|
|
pub first_fire_at_unix: i64,
|
|
/// `None` = one-shot. `Some(n > 0)` = recurring every `n` seconds.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub interval_seconds: Option<u64>,
|
|
/// Optional description shown on the dashboard approval card AND
|
|
/// stored on the resulting schedule row for the operator's
|
|
/// "what is this?" reference later.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod reserved_name_tests {
|
|
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.
|
|
///
|
|
/// **Panics when the variable is absent, deliberately.** The list lives
|
|
/// in `nix/reserved-names.nix` now, so this test can no longer read it
|
|
/// from the Rust tree; `nix/checks.nix` and `nix/devshell.nix` both
|
|
/// export it. Skipping instead would turn "nobody wired the variable"
|
|
/// into a green run — the same shape as a checker that reports clean
|
|
/// because it crashed, and the whole point of a drift test is that it
|
|
/// is the thing that notices.
|
|
fn reserved() -> Vec<String> {
|
|
let raw = hive_types::reserved_names_raw().unwrap_or_else(|| {
|
|
panic!(
|
|
"{RESERVED_NAMES_ENV} is unset or blank, so the drift test cannot run. \
|
|
nix/checks.nix and nix/devshell.nix are supposed to export it from \
|
|
nix/reserved-names.nix — fix the plumbing rather than this test."
|
|
)
|
|
});
|
|
hive_types::parse_reserved_names(&raw)
|
|
.into_iter()
|
|
.map(str::to_owned)
|
|
.collect()
|
|
}
|
|
|
|
/// The sentinels declared here and the blacklist nix owns are two
|
|
/// spellings of one fact, in places that cannot import each other's
|
|
/// intent. This pins them together: adding a sentinel without reserving
|
|
/// it now fails here rather than years later, when an agent takes the
|
|
/// name.
|
|
#[test]
|
|
fn ident_shaped_sentinels_are_reserved() {
|
|
let owned = reserved();
|
|
let reserved: Vec<&str> = owned.iter().map(String::as_str).collect();
|
|
for sentinel in [OPERATOR_RECIPIENT, SYSTEM_SENDER] {
|
|
assert!(
|
|
is_reserved_name(sentinel, &reserved),
|
|
"{sentinel:?} is a sentinel an agent could be named — it must be in \
|
|
nix/reserved-names.nix"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Every entry nix hands us must be a name an agent could actually have
|
|
/// been given. One that `Ident::parse` rejects is dead weight — nothing
|
|
/// could ever have been created with it, so listing it implies a guard
|
|
/// doing nothing. `graceful-stop` is what makes this worth asserting: it
|
|
/// is hyphenated, and a charset tightening would silently retire it.
|
|
///
|
|
/// This assertion used to live beside the list in `hive-types`; it moved
|
|
/// here because here is where the real list is readable.
|
|
#[test]
|
|
fn every_reserved_name_is_a_valid_ident() {
|
|
let owned = reserved();
|
|
assert!(
|
|
!owned.is_empty(),
|
|
"the blacklist rendered empty — an empty list makes every assertion below vacuous"
|
|
);
|
|
for name in &owned {
|
|
assert!(
|
|
Ident::parse(name).is_ok(),
|
|
"{name:?} is reserved but not a parseable ident — one of the two is wrong"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// `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
|
|
/// enforced rather than remembered.
|
|
#[test]
|
|
fn manager_name_is_taken_not_reserved() {
|
|
let owned = reserved();
|
|
let reserved: Vec<&str> = owned.iter().map(String::as_str).collect();
|
|
assert!(Ident::parse(MANAGER_AGENT).is_ok());
|
|
assert!(!is_reserved_name(MANAGER_AGENT, &reserved));
|
|
}
|
|
}
|