From fbb48ed3cef8743e46c920b3db09414a61b4c794 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 19 Jun 2026 00:30:37 +0200 Subject: [PATCH 1/5] hivectl: add hive-wide start/stop verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hivectl stop` brings the whole hive down in one operator action — all sub-agents plus the ci/forge/gateway/matrix infra containers — and `hivectl start` brings it back up. Scope flags (--agents, --agent , --ci, --forge, --gateway, --matrix) narrow the set; a bare invocation targets everything. hive-c0re never stops itself. - hive-sh4re: HostRequest::{Stop,Start} + LifecycleScope wire type; priv_proto InfraAction + ControlInfraContainer + the CONTROLLABLE_INFRA_CONTAINERS allowlist (adds hive-matrix, excludes hive-c0re). - hive-priv: control_infra_container handler (systemctl container@, allowlist-validated root-side). - hive-c0re: handle_stop / handle_start fan out agents via lifecycle and infra via hive-priv; per-target failures are aggregated. Infra systemctl routes through hive-priv (the privsep boundary). - The --graceful flag is threaded through Stop now; the per-agent quiesce itself lands with the graceful-agent-stop work. --- hive-c0re/src/bin/hivectl.rs | 118 ++++++++++++++++++++++++++++++- hive-c0re/src/priv_client.rs | 18 ++++- hive-c0re/src/server.rs | 133 ++++++++++++++++++++++++++++++++++- hive-priv/src/main.rs | 42 ++++++++++- hive-sh4re/src/lib.rs | 62 ++++++++++++++++ hive-sh4re/src/priv_proto.rs | 41 +++++++++++ 6 files changed, 407 insertions(+), 7 deletions(-) diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index 9374c004..40a9c81e 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -19,7 +19,7 @@ use std::os::unix::process::CommandExt as _; use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; -use clap::{Parser, Subcommand}; +use clap::{Args, Parser, Subcommand}; use hive_c0re::coordinator::Coordinator; #[derive(Parser)] @@ -100,6 +100,36 @@ enum Cmd { #[arg(long)] fresh: bool, }, + /// Stop containers hive-wide in one operator action. Bare `hivectl + /// stop` stops **everything** — all sub-agents plus the ci, forge, + /// gateway, and matrix infra containers. Narrow it with scope flags: + /// `--agents` (all sub-agents), `--ci` / `--forge` / `--gateway` / + /// `--matrix` (named infra), and `--agent ` (repeatable) for + /// specific sub-agents. Flags are additive (e.g. `--agents --matrix`). + /// Requires the hive-c0re daemon (connects to the host admin socket). + /// hive-c0re itself is never stopped — it services the request. + Stop { + #[command(flatten)] + scope: ScopeArgs, + /// Gracefully quiesce each agent (finish the current turn, drain + /// the inbox) before stopping, instead of a hard stop. + #[arg(long)] + graceful: bool, + /// Path to the hive-c0re host admin socket. + #[arg(long, default_value = DEFAULT_HOST_SOCKET)] + socket: PathBuf, + }, + /// Start containers hive-wide — the inverse of `hivectl stop`. Bare + /// `hivectl start` starts everything back up; the same scope flags as + /// `stop` narrow it (`--agents`, `--ci`, `--forge`, `--gateway`, + /// `--matrix`, `--agent `). Requires the hive-c0re daemon. + Start { + #[command(flatten)] + scope: ScopeArgs, + /// Path to the hive-c0re host admin socket. + #[arg(long, default_value = DEFAULT_HOST_SOCKET)] + socket: PathBuf, + }, /// Emit the full CLI reference as `CommonMark` to stdout. /// /// Hidden tooling command (not part of day-to-day operator admin): @@ -111,6 +141,49 @@ enum Cmd { MarkdownDocs, } +/// Shared scope flags for `hivectl stop` / `hivectl start`. With no flag +/// set the verb targets **everything** (all sub-agents + every controllable +/// infra container). Setting any flag restricts to the selected classes, +/// additively. +// One bool per selectable container class, each mapping 1:1 to a clap flag; +// orthogonal toggles, not a state machine — hence the bools allow (mirrors +// `hive_sh4re::LifecycleScope`). +#[allow(clippy::struct_excessive_bools)] +#[derive(Args)] +struct ScopeArgs { + /// All sub-agent containers. + #[arg(long)] + agents: bool, + /// A specific sub-agent by name. Repeatable: `--agent a --agent b`. + #[arg(long = "agent", value_name = "NAME")] + agent: Vec, + /// The CI runner container (`hive-ci`). + #[arg(long)] + ci: bool, + /// The forge container (`hive-forge`). + #[arg(long)] + forge: bool, + /// The gateway container (`hive-gateway`). + #[arg(long)] + gateway: bool, + /// The matrix container (`hive-matrix`). + #[arg(long)] + matrix: bool, +} + +impl ScopeArgs { + fn to_scope(&self) -> hive_sh4re::LifecycleScope { + hive_sh4re::LifecycleScope { + agents: self.agents, + agent_names: self.agent.clone(), + ci: self.ci, + forge: self.forge, + gateway: self.gateway, + matrix: self.matrix, + } + } +} + #[derive(Subcommand)] enum ForgeCmd { /// Create or refresh the Forgejo account + token for ``. @@ -348,6 +421,12 @@ async fn main() -> Result<()> { AgentsCmd::Restart { name, socket } => agents_restart(&socket, &name).await, AgentsCmd::RestartAll { socket } => agents_restart_all(&socket).await, }, + Cmd::Stop { + scope, + graceful, + socket, + } => stop(&socket, scope.to_scope(), graceful).await, + Cmd::Start { scope, socket } => start(&socket, scope.to_scope()).await, Cmd::Choom { name, fresh } => choom(&name, fresh), Cmd::MarkdownDocs => { print!("{}", clap_markdown::help_markdown::()); @@ -804,6 +883,43 @@ async fn agents_restart_all(socket: &Path) -> Result<()> { Ok(()) } +async fn stop(socket: &Path, scope: hive_sh4re::LifecycleScope, graceful: bool) -> Result<()> { + let resp = + hive_c0re::client::request(socket, hive_sh4re::HostRequest::Stop { scope, graceful }) + .await + .with_context(|| format!("connect to daemon socket {}", socket.display()))?; + render_lifecycle(&resp, "stopped") +} + +async fn start(socket: &Path, scope: hive_sh4re::LifecycleScope) -> Result<()> { + let resp = hive_c0re::client::request(socket, hive_sh4re::HostRequest::Start { scope }) + .await + .with_context(|| format!("connect to daemon socket {}", socket.display()))?; + render_lifecycle(&resp, "started") +} + +/// Render a hive-wide stop/start response: one `: ` line per +/// touched container, then surface any aggregated per-target failure as a +/// non-zero exit. `verb` is the past-tense word printed per item +/// (`stopped` / `started`). +fn render_lifecycle(resp: &hive_sh4re::HostResponse, verb: &str) -> Result<()> { + let items = resp.agents.as_deref().unwrap_or(&[]); + if items.is_empty() { + println!("{verb}: nothing matched the requested scope"); + } else { + for item in items { + println!("{verb}: {item}"); + } + } + if !resp.ok { + bail!( + "{verb}: {}", + resp.error.as_deref().unwrap_or("unknown error") + ); + } + Ok(()) +} + /// Reject usernames containing `:` (field separator) or control chars /// that would corrupt the htpasswd file format. fn validate_htpasswd_username(username: &str) -> Result<()> { diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index 6f38a131..4993d0eb 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -8,8 +8,8 @@ use anyhow::{Context as _, Result, bail}; use hive_sh4re::priv_proto::{ - BindMount, JournalQuery, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, - PrivStream, + BindMount, InfraAction, JournalQuery, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, + PrivResponse, PrivStream, }; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; @@ -295,6 +295,20 @@ pub async fn restart_infra_container(container: &str) -> Result<()> { .await?) } +/// Start / stop / restart a hive infrastructure container (`hive-ci`, +/// `hive-gateway`, `hive-forge`, `hive-matrix`) on the host via `systemctl +/// container@.service`. hive-priv re-validates +/// `container` against its root-side allowlist +/// (`CONTROLLABLE_INFRA_CONTAINERS`). Used by the hive-wide `hivectl stop` / +/// `hivectl start` flow. +pub async fn control_infra_container(container: &str, action: InfraAction) -> Result<()> { + ok(call(&PrivRequest::ControlInfraContainer { + container: container.to_owned(), + action, + }) + .await?) +} + fn check(resp: PrivResponse) -> Result<(String, String)> { if resp.ok { Ok((resp.stdout, resp.stderr)) diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 69e643d6..ceb46b3e 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -2,7 +2,8 @@ use std::path::Path; use std::sync::Arc; use anyhow::{Context, Result}; -use hive_sh4re::{HostRequest, HostResponse}; +use hive_sh4re::priv_proto::InfraAction; +use hive_sh4re::{HostRequest, HostResponse, LifecycleScope}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; @@ -93,6 +94,8 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { HostResponse::success() } HostRequest::RestartAll => handle_restart_all().await?, + HostRequest::Stop { scope, graceful } => handle_stop(scope, *graceful).await?, + HostRequest::Start { scope } => handle_start(scope).await?, HostRequest::Destroy { name, purge } => { actions::destroy(&coord, name, *purge).await?; HostResponse::success() @@ -199,6 +202,134 @@ async fn handle_restart_all() -> Result { } } +/// Stop the containers a [`LifecycleScope`] selects (`hivectl stop`): the +/// scoped sub-agents, then the scoped infra containers. Agents go down +/// before infra so they're not mid-request against a forge/matrix that's +/// already gone. Per-target failures are aggregated rather than aborting on +/// the first error, mirroring `handle_restart_all`. +async fn handle_stop(scope: &LifecycleScope, graceful: bool) -> Result { + tracing::info!(?scope, graceful, "stop"); + let mut ok_items: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); + + for agent in scoped_agents(scope).await? { + // TODO(graceful agent stop): when `graceful`, run the per-agent + // quiesce (turn-end → drain → reject new messages) before the kill. + // The flag is threaded through the wire now; the quiesce itself + // lands with the graceful-agent-stop work. + let _ = graceful; + match lifecycle::kill(&agent).await { + Ok(()) => ok_items.push(agent), + Err(e) => { + tracing::warn!(%agent, error = ?e, "stop: agent kill failed"); + errors.push(format!("{agent}: {e:#}")); + } + } + } + + for container in scoped_infra(scope) { + match crate::priv_client::control_infra_container(container, InfraAction::Stop).await { + Ok(()) => ok_items.push(container.to_owned()), + Err(e) => { + tracing::warn!(%container, error = ?e, "stop: infra stop failed"); + errors.push(format!("{container}: {e:#}")); + } + } + } + + Ok(finish_lifecycle(ok_items, &errors)) +} + +/// Start the containers a [`LifecycleScope`] selects (`hivectl start`) — +/// the inverse of [`handle_stop`]. Infra comes up before agents so the +/// agents find forge/matrix/gateway ready. Per-target failures aggregated. +async fn handle_start(scope: &LifecycleScope) -> Result { + tracing::info!(?scope, "start"); + let mut ok_items: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); + + for container in scoped_infra(scope) { + match crate::priv_client::control_infra_container(container, InfraAction::Start).await { + Ok(()) => ok_items.push(container.to_owned()), + Err(e) => { + tracing::warn!(%container, error = ?e, "start: infra start failed"); + errors.push(format!("{container}: {e:#}")); + } + } + } + + for agent in scoped_agents(scope).await? { + match lifecycle::start(&agent).await { + Ok(()) => ok_items.push(agent), + Err(e) => { + tracing::warn!(%agent, error = ?e, "start: agent start failed"); + errors.push(format!("{agent}: {e:#}")); + } + } + } + + Ok(finish_lifecycle(ok_items, &errors)) +} + +/// Resolve which sub-agent logical names a scope targets: every live +/// container (from `lifecycle::list`) when `agents` is set or the scope is +/// "everything", plus any explicit `agent_names`. Returns de-duplicated +/// logical names with the `h-` container prefix stripped. +async fn scoped_agents(scope: &LifecycleScope) -> Result> { + use std::collections::BTreeSet; + let mut set: BTreeSet = BTreeSet::new(); + if scope.agents || scope.is_everything() { + for c in lifecycle::list().await? { + let logical = c + .strip_prefix(lifecycle::AGENT_PREFIX) + .unwrap_or(&c) + .to_owned(); + set.insert(logical); + } + } + for n in &scope.agent_names { + set.insert(n.clone()); + } + Ok(set.into_iter().collect()) +} + +/// Resolve which infra container names a scope targets. An "everything" +/// scope (no flags set) selects all controllable infra; otherwise each set +/// flag maps to its container. Fixed order for deterministic output. +fn scoped_infra(scope: &LifecycleScope) -> Vec<&'static str> { + let everything = scope.is_everything(); + let mut out = Vec::new(); + if everything || scope.ci { + out.push("hive-ci"); + } + if everything || scope.forge { + out.push("hive-forge"); + } + if everything || scope.gateway { + out.push("hive-gateway"); + } + if everything || scope.matrix { + out.push("hive-matrix"); + } + out +} + +/// Build the aggregated lifecycle response: `ok` with the touched names when +/// every target succeeded, otherwise `ok: false` with the joined errors and +/// the partial success list (matches `handle_restart_all`). +fn finish_lifecycle(ok_items: Vec, errors: &[String]) -> HostResponse { + if errors.is_empty() { + HostResponse::list(ok_items) + } else { + HostResponse { + ok: false, + error: Some(errors.join("; ")), + agents: Some(ok_items), + approvals: None, + } + } +} + /// Rebuild `name`'s container, notifying the manager of the outcome /// (success or failure) and kicking the agent's next turn on success. async fn handle_rebuild(coord: &Arc, name: &str) -> Result { diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 55c69f94..e3a0515b 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -21,9 +21,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; use hive_sh4re::priv_proto::{ - AGENT_PREFIX, AGENT_STATE_ROOT, BindMount, JournalQuery, MANAGER_NAME, META_DIR, - NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, PrivStreamLine, - RESTARTABLE_INFRA_CONTAINERS, SIBLING_CONTAINERS, + AGENT_PREFIX, AGENT_STATE_ROOT, BindMount, CONTROLLABLE_INFRA_CONTAINERS, InfraAction, + JournalQuery, MANAGER_NAME, META_DIR, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, + PrivResponse, PrivStream, PrivStreamLine, RESTARTABLE_INFRA_CONTAINERS, SIBLING_CONTAINERS, }; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::unix::OwnedWriteHalf; @@ -268,6 +268,11 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String, PrivRequest::RestartInfraContainer { ref container } => { restart_infra_container(container).await } + + PrivRequest::ControlInfraContainer { + ref container, + action, + } => control_infra_container(container, action).await, } } @@ -423,6 +428,37 @@ async fn restart_infra_container(container: &str) -> Result<(String, String)> { )) } +/// `ControlInfraContainer` — start/stop/restart a hive infrastructure +/// container via `systemctl container@.service`. The +/// `container` is validated against `CONTROLLABLE_INFRA_CONTAINERS` here, +/// root-side; this is the authoritative allowlist (hive-c0re itself can +/// never appear in it, so a hive-wide stop can't sever the daemon socket +/// the request arrived on). +async fn control_infra_container(container: &str, action: InfraAction) -> Result<(String, String)> { + if !CONTROLLABLE_INFRA_CONTAINERS.contains(&container) { + bail!("container {container:?} is not a controllable hive infra container"); + } + let verb = action.systemctl_verb(); + let unit = format!("container@{container}.service"); + let out = Command::new("systemctl") + .args([verb, &unit]) + .output() + .await + .with_context(|| format!("systemctl {verb} {unit}"))?; + if !out.status.success() { + bail!( + "systemctl {verb} {unit} exited {}: {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + tracing::info!(target: "infra-control", "{verb} {unit}"); + Ok(( + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + )) +} + /// Shared helper for `WriteAgentForgeToken` and `WriteAgentMatrixToken`. /// Writes `content` to `AGENT_STATE_ROOT//state/`, /// chowns to the agent user (derived from the state dir's existing owner), diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 7308761e..ba3789a7 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -58,6 +58,68 @@ pub enum HostRequest { child: String, new_parent: Option, }, + /// Stop managed containers hive-wide in one operator action + /// (`hivectl stop`): agents plus the selected infra containers. `scope` + /// selects which classes; an all-false scope means **everything** (the + /// bare `hivectl stop`). `graceful` runs the per-agent quiesce (graceful + /// agent stop, issue tracker `graceful agent stop`) instead of a hard + /// stop. Agents stop via the lifecycle path; infra containers via the + /// host `container@` units. + Stop { + #[serde(default)] + scope: LifecycleScope, + #[serde(default)] + graceful: bool, + }, + /// Start managed containers hive-wide — the inverse of `Stop` + /// (`hivectl start`). Same `scope` semantics (all-false = everything); + /// no graceful flag (start is unconditional). + Start { + #[serde(default)] + scope: LifecycleScope, + }, +} + +/// Selects which container classes a hive-wide [`HostRequest::Stop`] / +/// [`HostRequest::Start`] touches. An all-false scope means **everything** +/// (the bare `hivectl stop` / `start`); set individual fields to restrict +/// (e.g. only `agents` → just the sub-agent containers). `agents` covers +/// every managed sub-agent container; the rest are the named infra +/// containers (`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`). +// +// A flat bag of independent flag toggles — one bool per selectable +// container class — is exactly the right shape here: each maps 1:1 to a +// `hivectl` `--ci` / `--forge` / `--gateway` / `--matrix` flag, and they're +// orthogonal (any subset is valid), so a state machine or two-variant enums +// would only obscure the mapping. Hence the `struct_excessive_bools` allow. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LifecycleScope { + /// All sub-agent containers (`--agents`). + #[serde(default)] + pub agents: bool, + /// Specific sub-agents by logical name (`--agent `, repeatable). + /// Additive with the rest of the scope; redundant when `agents` is set + /// (which already covers every sub-agent). + #[serde(default)] + pub agent_names: Vec, + #[serde(default)] + pub ci: bool, + #[serde(default)] + pub forge: bool, + #[serde(default)] + pub gateway: bool, + #[serde(default)] + pub matrix: bool, +} + +impl LifecycleScope { + /// True when nothing is explicitly selected — interpreted as "all + /// classes" (the bare `hivectl stop` / `start` with no scope flags). + pub fn is_everything(&self) -> bool { + !(self.agents || self.ci || self.forge || self.gateway || self.matrix) + && self.agent_names.is_empty() + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index b23fca72..8265e4ff 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -27,6 +27,37 @@ pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-gat /// it is the authoritative allowlist regardless of what the caller sends. pub const RESTARTABLE_INFRA_CONTAINERS: &[&str] = &["hive-ci", "hive-gateway", "hive-forge"]; +/// Infra containers hive-c0re may stop/start/restart hive-wide for the +/// `hivectl stop` / `hivectl start` operator flow. Superset of +/// [`RESTARTABLE_INFRA_CONTAINERS`]: it adds `hive-matrix`, because a full +/// stop is a deliberate operator action (unlike the disruptive mid-sync +/// *restart* the `infra_admin` MCP path forbids). `hive-c0re` is still +/// excluded — it runs the daemon servicing the request and must never stop +/// itself. hive-priv re-validates against this list root-side. +pub const CONTROLLABLE_INFRA_CONTAINERS: &[&str] = + &["hive-ci", "hive-gateway", "hive-forge", "hive-matrix"]; + +/// Lifecycle verb for [`PrivRequest::ControlInfraContainer`]. Maps directly +/// to `systemctl container@.service`. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InfraAction { + Start, + Stop, + Restart, +} + +impl InfraAction { + /// The `systemctl` subcommand this action maps to. + pub fn systemctl_verb(self) -> &'static str { + match self { + InfraAction::Start => "start", + InfraAction::Stop => "stop", + InfraAction::Restart => "restart", + } + } +} + /// Host path of the meta flake. The flake ref for agent `` is /// `{META_DIR}#{name}`, derived by `hive-priv` — never passed over the wire. pub const META_DIR: &str = "/var/lib/hyperhive/meta"; @@ -316,6 +347,16 @@ pub enum PrivRequest { /// [`RESTARTABLE_INFRA_CONTAINERS`]. container: String, }, + + /// Start/stop/restart a hive infrastructure container on the host via + /// `systemctl container@.service`. hive-priv + /// validates `container` against [`CONTROLLABLE_INFRA_CONTAINERS`] + /// root-side. Generalises [`PrivRequest::RestartInfraContainer`] for the + /// hive-wide `hivectl stop` / `hivectl start` operator flow. + ControlInfraContainer { + container: String, + action: InfraAction, + }, } /// Response from the privileged helper. From 465dd2d433b0d39ed79b54df1c2f90b20ff2eaa4 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 19 Jun 2026 00:33:28 +0200 Subject: [PATCH 2/5] hivectl: note that stop --graceful is not yet effective Until the per-agent quiesce lands, --graceful falls through to a hard stop. Help-text the limitation so an operator passing the flag isn't misled into thinking the agent quiesced. --- hive-c0re/src/bin/hivectl.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index 40a9c81e..1fe74ed1 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -113,6 +113,11 @@ enum Cmd { scope: ScopeArgs, /// Gracefully quiesce each agent (finish the current turn, drain /// the inbox) before stopping, instead of a hard stop. + /// + /// NOTE: not yet effective — the per-agent quiesce is still being + /// implemented (see the graceful-agent-stop tracker), so today + /// this falls through to a hard stop. The flag is accepted now so + /// the wire/CLI shape is stable when the quiesce lands. #[arg(long)] graceful: bool, /// Path to the hive-c0re host admin socket. From c673dce73d63a571793f00f637522b7a88022341 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 19 Jun 2026 00:40:22 +0200 Subject: [PATCH 3/5] hivectl: resolve stop/start scope to names at the c0re entry point Per review: c0re expands the LifecycleScope to explicit container-name lists (scoped_agents / scoped_infra) in the dispatch arm, then hands those lists to handle_stop / handle_start. The 'all agents' flag no longer flows past the resolution boundary, so downstream consumers (incl. the future graceful-stop queue) always operate on concrete names. CLI --agents flag unchanged. --- hive-c0re/src/server.rs | 58 +++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index ceb46b3e..2e2a041a 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -94,8 +94,20 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { HostResponse::success() } HostRequest::RestartAll => handle_restart_all().await?, - HostRequest::Stop { scope, graceful } => handle_stop(scope, *graceful).await?, - HostRequest::Start { scope } => handle_start(scope).await?, + HostRequest::Stop { scope, graceful } => { + // Resolve the scope to explicit container names at the entry + // point, then operate on names — never pass the bare "all + // agents" flag deeper (it'd force every consumer, incl. the + // graceful-stop queue, to re-expand it). + let agents = scoped_agents(scope).await?; + let infra = scoped_infra(scope); + handle_stop(&agents, &infra, *graceful).await? + } + HostRequest::Start { scope } => { + let agents = scoped_agents(scope).await?; + let infra = scoped_infra(scope); + handle_start(&agents, &infra).await? + } HostRequest::Destroy { name, purge } => { actions::destroy(&coord, name, *purge).await?; HostResponse::success() @@ -202,24 +214,25 @@ async fn handle_restart_all() -> Result { } } -/// Stop the containers a [`LifecycleScope`] selects (`hivectl stop`): the -/// scoped sub-agents, then the scoped infra containers. Agents go down -/// before infra so they're not mid-request against a forge/matrix that's -/// already gone. Per-target failures are aggregated rather than aborting on -/// the first error, mirroring `handle_restart_all`. -async fn handle_stop(scope: &LifecycleScope, graceful: bool) -> Result { - tracing::info!(?scope, graceful, "stop"); +/// Stop the given `agents` (resolved logical names) then `infra` containers +/// (`hivectl stop`). Agents go down before infra so they're not mid-request +/// against a forge/matrix that's already gone. Per-target failures are +/// aggregated rather than aborting on the first error, mirroring +/// `handle_restart_all`. Callers resolve the [`LifecycleScope`] to these +/// explicit name lists up front — this never sees the "all" flag. +async fn handle_stop(agents: &[String], infra: &[&str], graceful: bool) -> Result { + tracing::info!(?agents, ?infra, graceful, "stop"); let mut ok_items: Vec = Vec::new(); let mut errors: Vec = Vec::new(); - for agent in scoped_agents(scope).await? { + for agent in agents { // TODO(graceful agent stop): when `graceful`, run the per-agent // quiesce (turn-end → drain → reject new messages) before the kill. // The flag is threaded through the wire now; the quiesce itself // lands with the graceful-agent-stop work. let _ = graceful; - match lifecycle::kill(&agent).await { - Ok(()) => ok_items.push(agent), + match lifecycle::kill(agent).await { + Ok(()) => ok_items.push(agent.clone()), Err(e) => { tracing::warn!(%agent, error = ?e, "stop: agent kill failed"); errors.push(format!("{agent}: {e:#}")); @@ -227,7 +240,7 @@ async fn handle_stop(scope: &LifecycleScope, graceful: bool) -> Result ok_items.push(container.to_owned()), Err(e) => { @@ -240,15 +253,16 @@ async fn handle_stop(scope: &LifecycleScope, graceful: bool) -> Result Result { - tracing::info!(?scope, "start"); +/// Start the given `infra` containers then `agents` (`hivectl start`) — the +/// inverse of [`handle_stop`]. Infra comes up before agents so the agents +/// find forge/matrix/gateway ready. Per-target failures aggregated. Callers +/// resolve the [`LifecycleScope`] to these explicit name lists up front. +async fn handle_start(agents: &[String], infra: &[&str]) -> Result { + tracing::info!(?agents, ?infra, "start"); let mut ok_items: Vec = Vec::new(); let mut errors: Vec = Vec::new(); - for container in scoped_infra(scope) { + for &container in infra { match crate::priv_client::control_infra_container(container, InfraAction::Start).await { Ok(()) => ok_items.push(container.to_owned()), Err(e) => { @@ -258,9 +272,9 @@ async fn handle_start(scope: &LifecycleScope) -> Result { } } - for agent in scoped_agents(scope).await? { - match lifecycle::start(&agent).await { - Ok(()) => ok_items.push(agent), + for agent in agents { + match lifecycle::start(agent).await { + Ok(()) => ok_items.push(agent.clone()), Err(e) => { tracing::warn!(%agent, error = ?e, "start: agent start failed"); errors.push(format!("{agent}: {e:#}")); From 0df9e4094096aea59d23f6cc305d3989ff799399 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 19 Jun 2026 00:50:00 +0200 Subject: [PATCH 4/5] hivectl: collapse infra allowlist + restart/control ops onto SIBLING_CONTAINERS Per review: RESTARTABLE_INFRA_CONTAINERS and the new CONTROLLABLE_INFRA_CONTAINERS were near-identical subsets of SIBLING_CONTAINERS. Drop both and validate infra lifecycle ops against SIBLING_CONTAINERS directly (all four infra containers; hive-c0re is never in it, so it can't stop itself). This also makes hive-matrix restartable, including via an infra_admin agent's restart tool. Collapse the two priv ops too: RestartInfraContainer is gone; ControlInfraContainer { action } is the single op (restart = action: Restart). priv_client's restart_infra_container is now a thin wrapper over control_infra_container. --- hive-c0re/src/agent_server.rs | 10 ++--- hive-c0re/src/priv_client.rs | 20 ++++------ hive-priv/src/main.rs | 50 +++++------------------ hive-sh4re/src/priv_proto.rs | 75 +++++++++++------------------------ 4 files changed, 46 insertions(+), 109 deletions(-) diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index 68d00ccd..2808fe70 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -518,10 +518,10 @@ async fn handle_start_child(coord: &Arc, agent: &str, name: &str) - async fn handle_restart_child(coord: &Arc, agent: &str, name: &str) -> AgentResponse { // Infra-container restart: an agent holding the `infra_admin` // capability can restart a hive infrastructure container (hive-ci / - // hive-gateway / hive-forge) by passing its name to the same restart - // tool. These names are never agent children, so this branch is - // disjoint from the child-restart path below. - if hive_sh4re::priv_proto::RESTARTABLE_INFRA_CONTAINERS.contains(&name) { + // hive-gateway / hive-forge / hive-matrix) by passing its name to the + // same restart tool. These names are never agent children, so this + // branch is disjoint from the child-restart path below. + if hive_sh4re::priv_proto::SIBLING_CONTAINERS.contains(&name) { return handle_restart_infra(coord, agent, name).await; } if let Some(err) = require_child(agent, name, "restart") { @@ -541,7 +541,7 @@ async fn handle_restart_child(coord: &Arc, agent: &str, name: &str) /// Restart a hive infrastructure container on behalf of an agent that /// holds the `infra_admin` capability. The container name is already -/// known to be in `RESTARTABLE_INFRA_CONTAINERS`; this gates on the +/// known to be in `SIBLING_CONTAINERS`; this gates on the /// capability and routes the systemctl restart through hive-priv (which /// re-validates the name root-side). Direct, not approval-gated. async fn handle_restart_infra( diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index 4993d0eb..b6a25b5f 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -283,24 +283,20 @@ pub async fn restart_matrix_daemon(agent_name: &str) -> Result<()> { .await?) } -/// Restart a hive infrastructure container (hive-ci / hive-gateway / -/// hive-forge) on the host via `systemctl restart -/// container@.service`. hive-priv re-validates `container` -/// against its root-side allowlist; callers must already have checked -/// the requesting agent holds the `infra_admin` capability. +/// Restart a hive infrastructure container on the host (thin wrapper over +/// [`control_infra_container`] with `action = Restart`). hive-priv +/// re-validates `container` against its root-side allowlist; callers must +/// already have checked the requesting agent holds the `infra_admin` +/// capability. pub async fn restart_infra_container(container: &str) -> Result<()> { - ok(call(&PrivRequest::RestartInfraContainer { - container: container.to_owned(), - }) - .await?) + control_infra_container(container, InfraAction::Restart).await } /// Start / stop / restart a hive infrastructure container (`hive-ci`, /// `hive-gateway`, `hive-forge`, `hive-matrix`) on the host via `systemctl /// container@.service`. hive-priv re-validates -/// `container` against its root-side allowlist -/// (`CONTROLLABLE_INFRA_CONTAINERS`). Used by the hive-wide `hivectl stop` / -/// `hivectl start` flow. +/// `container` against its root-side allowlist (`SIBLING_CONTAINERS`). Used +/// by the hive-wide `hivectl stop` / `hivectl start` flow. pub async fn control_infra_container(container: &str, action: InfraAction) -> Result<()> { ok(call(&PrivRequest::ControlInfraContainer { container: container.to_owned(), diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index e3a0515b..47e9cce1 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -21,9 +21,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; use hive_sh4re::priv_proto::{ - AGENT_PREFIX, AGENT_STATE_ROOT, BindMount, CONTROLLABLE_INFRA_CONTAINERS, InfraAction, - JournalQuery, MANAGER_NAME, META_DIR, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, - PrivResponse, PrivStream, PrivStreamLine, RESTARTABLE_INFRA_CONTAINERS, SIBLING_CONTAINERS, + AGENT_PREFIX, AGENT_STATE_ROOT, BindMount, InfraAction, JournalQuery, MANAGER_NAME, META_DIR, + NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, PrivStreamLine, + SIBLING_CONTAINERS, }; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::unix::OwnedWriteHalf; @@ -265,10 +265,6 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String, restart_matrix_daemon(agent_name).await } - PrivRequest::RestartInfraContainer { ref container } => { - restart_infra_container(container).await - } - PrivRequest::ControlInfraContainer { ref container, action, @@ -399,43 +395,15 @@ async fn restart_matrix_daemon(agent_name: &str) -> Result<(String, String)> { )) } -/// `RestartInfraContainer` — restart a hive infrastructure container on -/// the host via `systemctl restart container@.service`. The -/// `container` is validated against `RESTARTABLE_INFRA_CONTAINERS` here, -/// root-side, so this is the authoritative allowlist even though -/// hive-c0re also gates on the caller's `infra_admin` capability. -async fn restart_infra_container(container: &str) -> Result<(String, String)> { - if !RESTARTABLE_INFRA_CONTAINERS.contains(&container) { - bail!("container {container:?} is not a restartable hive infra container"); - } - let unit = format!("container@{container}.service"); - let out = Command::new("systemctl") - .args(["restart", &unit]) - .output() - .await - .with_context(|| format!("systemctl restart {unit}"))?; - if !out.status.success() { - bail!( - "systemctl restart {unit} exited {}: {}", - out.status, - String::from_utf8_lossy(&out.stderr).trim() - ); - } - tracing::info!(target: "infra-restart", "restarted {unit}"); - Ok(( - String::from_utf8_lossy(&out.stdout).into_owned(), - String::from_utf8_lossy(&out.stderr).into_owned(), - )) -} - /// `ControlInfraContainer` — start/stop/restart a hive infrastructure /// container via `systemctl container@.service`. The -/// `container` is validated against `CONTROLLABLE_INFRA_CONTAINERS` here, -/// root-side; this is the authoritative allowlist (hive-c0re itself can -/// never appear in it, so a hive-wide stop can't sever the daemon socket -/// the request arrived on). +/// `container` is validated against `SIBLING_CONTAINERS` here, root-side; +/// this is the authoritative allowlist (hive-c0re is never in it, so a +/// stop can't sever the daemon socket the request arrived on). Serves both +/// the hive-wide `hivectl stop`/`start` flow and an `infra_admin` agent's +/// `restart` (action = Restart). async fn control_infra_container(container: &str, action: InfraAction) -> Result<(String, String)> { - if !CONTROLLABLE_INFRA_CONTAINERS.contains(&container) { + if !SIBLING_CONTAINERS.contains(&container) { bail!("container {container:?} is not a controllable hive infra container"); } let verb = action.systemctl_verb(); diff --git a/hive-sh4re/src/priv_proto.rs b/hive-sh4re/src/priv_proto.rs index 8265e4ff..1b4a3e38 100644 --- a/hive-sh4re/src/priv_proto.rs +++ b/hive-sh4re/src/priv_proto.rs @@ -15,28 +15,16 @@ pub const MANAGER_NAME: &str = "ruth"; /// Sub-agent container prefix. System container name = `h-`. pub const AGENT_PREFIX: &str = "h-"; -/// Sibling service containers managed by hive-c0re. +/// Sibling service containers managed by hive-c0re. This doubles as the +/// authoritative allowlist for infra lifecycle ops +/// ([`PrivRequest::ControlInfraContainer`]): any of these four may be +/// started / stopped / restarted (by the hive-wide `hivectl stop`/`start` +/// flow or an `infra_admin` agent's `restart`). `hive-c0re` is deliberately +/// absent — stopping it would sever the very socket the request arrived on. +/// hive-priv re-validates against this list root-side, so it's authoritative +/// regardless of what the caller sends. pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix", "hive-gateway", "hive-ci"]; -/// Infra containers an agent holding the `infra_admin` capability may -/// restart via the `restart` MCP tool. A deliberate subset of -/// [`SIBLING_CONTAINERS`]: hive-matrix is excluded (kicking the matrix -/// backend mid-sync is its own concern) and hive-c0re is excluded -/// entirely (a self-restart would sever the very socket the request -/// arrived on). hive-priv re-validates against this list root-side, so -/// it is the authoritative allowlist regardless of what the caller sends. -pub const RESTARTABLE_INFRA_CONTAINERS: &[&str] = &["hive-ci", "hive-gateway", "hive-forge"]; - -/// Infra containers hive-c0re may stop/start/restart hive-wide for the -/// `hivectl stop` / `hivectl start` operator flow. Superset of -/// [`RESTARTABLE_INFRA_CONTAINERS`]: it adds `hive-matrix`, because a full -/// stop is a deliberate operator action (unlike the disruptive mid-sync -/// *restart* the `infra_admin` MCP path forbids). `hive-c0re` is still -/// excluded — it runs the daemon servicing the request and must never stop -/// itself. hive-priv re-validates against this list root-side. -pub const CONTROLLABLE_INFRA_CONTAINERS: &[&str] = - &["hive-ci", "hive-gateway", "hive-forge", "hive-matrix"]; - /// Lifecycle verb for [`PrivRequest::ControlInfraContainer`]. Maps directly /// to `systemctl container@.service`. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] @@ -336,24 +324,15 @@ pub enum PrivRequest { agent_name: String, }, - /// Restart a hive infrastructure container on the host via - /// `systemctl restart container@.service`. hive-priv - /// validates `container` against [`RESTARTABLE_INFRA_CONTAINERS`] - /// before acting — the root-side allowlist is authoritative. Used - /// by hive-c0re to service a `restart` request from an agent that - /// holds the `infra_admin` capability. - RestartInfraContainer { - /// Infra container name (e.g. `hive-ci`); must be in - /// [`RESTARTABLE_INFRA_CONTAINERS`]. - container: String, - }, - - /// Start/stop/restart a hive infrastructure container on the host via - /// `systemctl container@.service`. hive-priv - /// validates `container` against [`CONTROLLABLE_INFRA_CONTAINERS`] - /// root-side. Generalises [`PrivRequest::RestartInfraContainer`] for the - /// hive-wide `hivectl stop` / `hivectl start` operator flow. + /// Start / stop / restart a hive infrastructure container on the host + /// via `systemctl container@.service`. hive-priv + /// validates `container` against [`SIBLING_CONTAINERS`] root-side (the + /// authoritative allowlist; `hive-c0re` is never in it). Serves both the + /// hive-wide `hivectl stop` / `hivectl start` flow and an `infra_admin` + /// agent's `restart` (with `action = Restart`). ControlInfraContainer { + /// Infra container name (e.g. `hive-ci`); must be in + /// [`SIBLING_CONTAINERS`]. container: String, action: InfraAction, }, @@ -414,21 +393,15 @@ pub enum PrivEvent { #[cfg(test)] mod tests { - use super::{RESTARTABLE_INFRA_CONTAINERS, SIBLING_CONTAINERS}; + use super::SIBLING_CONTAINERS; #[test] - fn restartable_infra_is_a_safe_subset_of_siblings() { - // Every restartable infra container must be a known sibling. - for c in RESTARTABLE_INFRA_CONTAINERS { - assert!( - SIBLING_CONTAINERS.contains(c), - "{c} is not a managed sibling container" - ); - } - // hive-matrix and hive-c0re are deliberately excluded: kicking the - // matrix backend mid-sync is its own concern, and a self-restart of - // c0re would sever the request socket. - assert!(!RESTARTABLE_INFRA_CONTAINERS.contains(&"hive-matrix")); - assert!(!RESTARTABLE_INFRA_CONTAINERS.contains(&"hive-c0re")); + fn infra_control_allowlist_excludes_c0re_includes_matrix() { + // SIBLING_CONTAINERS is the authoritative allowlist for infra + // lifecycle ops. hive-c0re must NEVER be in it — stopping the daemon + // would sever the socket the request arrived on. + assert!(!SIBLING_CONTAINERS.contains(&"hive-c0re")); + // hive-matrix IS controllable (operator can stop/start/restart it). + assert!(SIBLING_CONTAINERS.contains(&"hive-matrix")); } } From e1b7f7cbeccf2741c229e8f2a1895f27c297fdac Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 19 Jun 2026 02:15:38 +0200 Subject: [PATCH 5/5] docs: regenerate hivectl-cli.md for the new stop/start verbs The hivectl-docs flake check regenerates docs/tools/hivectl-cli.md from the clap command tree and asserts it's committed up to date. Adding the stop/start verbs changed the CLI, so the doc was stale and the check failed. Regenerate it. --- docs/tools/hivectl-cli.md | 47 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 62e230f4..19dd5673 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -21,6 +21,8 @@ This document contains the help content for the `hivectl` command-line program. * [`hivectl agents restart`↴](#hivectl-agents-restart) * [`hivectl agents restart-all`↴](#hivectl-agents-restart-all) * [`hivectl choom`↴](#hivectl-choom) +* [`hivectl stop`↴](#hivectl-stop) +* [`hivectl start`↴](#hivectl-start) ## `hivectl` @@ -35,6 +37,8 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that * `gateway` — Gateway htpasswd user management. Add, remove, or list users in an htpasswd file used by the gateway's HTTP Basic auth (`services.hyperhive.gateway.auth`). Credentials are stored as `BCrypt` hashes — no extra service or PAM required * `agents` — Agent container management. Requires the hive-c0re daemon to be running (connects to the host admin socket) * `choom` — Open an interactive Claude session inside an agent container +* `stop` — Stop containers hive-wide in one operator action. Bare `hivectl stop` stops **everything** — all sub-agents plus the ci, forge, gateway, and matrix infra containers. Narrow it with scope flags: `--agents` (all sub-agents), `--ci` / `--forge` / `--gateway` / `--matrix` (named infra), and `--agent ` (repeatable) for specific sub-agents. Flags are additive (e.g. `--agents --matrix`). Requires the hive-c0re daemon (connects to the host admin socket). hive-c0re itself is never stopped — it services the request +* `start` — Start containers hive-wide — the inverse of `hivectl stop`. Bare `hivectl start` starts everything back up; the same scope flags as `stop` narrow it (`--agents`, `--ci`, `--forge`, `--gateway`, `--matrix`, `--agent `). Requires the hive-c0re daemon @@ -297,6 +301,49 @@ Pass `--fresh` to start a new Claude session instead of continuing the most rece +## `hivectl stop` + +Stop containers hive-wide in one operator action. Bare `hivectl stop` stops **everything** — all sub-agents plus the ci, forge, gateway, and matrix infra containers. Narrow it with scope flags: `--agents` (all sub-agents), `--ci` / `--forge` / `--gateway` / `--matrix` (named infra), and `--agent ` (repeatable) for specific sub-agents. Flags are additive (e.g. `--agents --matrix`). Requires the hive-c0re daemon (connects to the host admin socket). hive-c0re itself is never stopped — it services the request + +**Usage:** `hivectl stop [OPTIONS]` + +###### **Options:** + +* `--agents` — All sub-agent containers +* `--agent ` — A specific sub-agent by name. Repeatable: `--agent a --agent b` +* `--ci` — The CI runner container (`hive-ci`) +* `--forge` — The forge container (`hive-forge`) +* `--gateway` — The gateway container (`hive-gateway`) +* `--matrix` — The matrix container (`hive-matrix`) +* `--graceful` — Gracefully quiesce each agent (finish the current turn, drain the inbox) before stopping, instead of a hard stop. + + NOTE: not yet effective — the per-agent quiesce is still being implemented (see the graceful-agent-stop tracker), so today this falls through to a hard stop. The flag is accepted now so the wire/CLI shape is stable when the quiesce lands. +* `--socket ` — Path to the hive-c0re host admin socket + + Default value: `/run/hyperhive/host.sock` + + + +## `hivectl start` + +Start containers hive-wide — the inverse of `hivectl stop`. Bare `hivectl start` starts everything back up; the same scope flags as `stop` narrow it (`--agents`, `--ci`, `--forge`, `--gateway`, `--matrix`, `--agent `). Requires the hive-c0re daemon + +**Usage:** `hivectl start [OPTIONS]` + +###### **Options:** + +* `--agents` — All sub-agent containers +* `--agent ` — A specific sub-agent by name. Repeatable: `--agent a --agent b` +* `--ci` — The CI runner container (`hive-ci`) +* `--forge` — The forge container (`hive-forge`) +* `--gateway` — The gateway container (`hive-gateway`) +* `--matrix` — The matrix container (`hive-matrix`) +* `--socket ` — Path to the hive-c0re host admin socket + + Default value: `/run/hyperhive/host.sock` + + +