Compare commits

...
Author SHA1 Message Date
atlas
e1b7f7cbec 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.
2026-06-19 08:16:18 +02:00
atlas
0df9e40940 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.
2026-06-19 08:16:18 +02:00
atlas
c673dce73d 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.
2026-06-19 08:16:18 +02:00
atlas
465dd2d433 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.
2026-06-19 08:16:18 +02:00
atlas
fbb48ed3ce hivectl: add hive-wide start/stop verbs
`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 <name>,
--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 <verb>
  container@<name>, 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.
2026-06-19 08:16:18 +02:00
8 changed files with 466 additions and 63 deletions

View file

@ -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 <name>` (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 <name>`). 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 <name>` (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 <NAME>` — 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 <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 <name>`). Requires the hive-c0re daemon
**Usage:** `hivectl start [OPTIONS]`
###### **Options:**
* `--agents` — All sub-agent containers
* `--agent <NAME>` — 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 <SOCKET>` — Path to the hive-c0re host admin socket
Default value: `/run/hyperhive/host.sock`
<hr/>
<small><i>

View file

@ -518,10 +518,10 @@ async fn handle_start_child(coord: &Arc<Coordinator>, agent: &str, name: &str) -
async fn handle_restart_child(coord: &Arc<Coordinator>, 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<Coordinator>, 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(

View file

@ -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,41 @@ 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 <name>` (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.
///
/// 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.
#[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 <name>`). 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 +146,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<String>,
/// 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 `<name>`.
@ -348,6 +426,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::<Cli>());
@ -804,6 +888,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 `<verb>: <name>` 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<()> {

View file

@ -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;
@ -283,14 +283,24 @@ 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@<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 {
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
/// <action> container@<container>.service`. hive-priv re-validates
/// `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(),
action,
})
.await?)
}

View file

@ -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,20 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
HostResponse::success()
}
HostRequest::RestartAll => handle_restart_all().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()
@ -199,6 +214,136 @@ async fn handle_restart_all() -> Result<HostResponse> {
}
}
/// 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<HostResponse> {
tracing::info!(?agents, ?infra, graceful, "stop");
let mut ok_items: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
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.clone()),
Err(e) => {
tracing::warn!(%agent, error = ?e, "stop: agent kill failed");
errors.push(format!("{agent}: {e:#}"));
}
}
}
for &container in infra {
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 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<HostResponse> {
tracing::info!(?agents, ?infra, "start");
let mut ok_items: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
for &container in infra {
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 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:#}"));
}
}
}
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<Vec<String>> {
use std::collections::BTreeSet;
let mut set: BTreeSet<String> = 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<String>, 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<Coordinator>, name: &str) -> Result<HostResponse> {

View file

@ -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,
AGENT_PREFIX, AGENT_STATE_ROOT, BindMount, InfraAction, JournalQuery, MANAGER_NAME, META_DIR,
NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, PrivStreamLine,
RESTARTABLE_INFRA_CONTAINERS, SIBLING_CONTAINERS,
SIBLING_CONTAINERS,
};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::unix::OwnedWriteHalf;
@ -265,9 +265,10 @@ 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,
} => control_infra_container(container, action).await,
}
}
@ -394,29 +395,32 @@ async fn restart_matrix_daemon(agent_name: &str) -> Result<(String, String)> {
))
}
/// `RestartInfraContainer` — restart a hive infrastructure container on
/// the host via `systemctl restart container@<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");
/// `ControlInfraContainer` — start/stop/restart a hive infrastructure
/// container via `systemctl <verb> container@<container>.service`. The
/// `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 !SIBLING_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(["restart", &unit])
.args([verb, &unit])
.output()
.await
.with_context(|| format!("systemctl restart {unit}"))?;
.with_context(|| format!("systemctl {verb} {unit}"))?;
if !out.status.success() {
bail!(
"systemctl restart {unit} exited {}: {}",
"systemctl {verb} {unit} exited {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
tracing::info!(target: "infra-restart", "restarted {unit}");
tracing::info!(target: "infra-control", "{verb} {unit}");
Ok((
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),

View file

@ -58,6 +58,68 @@ pub enum HostRequest {
child: String,
new_parent: Option<String>,
},
/// 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@<name>` 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 <name>`, 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<String>,
#[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)]

View file

@ -15,17 +15,36 @@ pub const MANAGER_NAME: &str = "ruth";
/// Sub-agent container prefix. System container name = `h-<agent_name>`.
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"];
/// Lifecycle verb for [`PrivRequest::ControlInfraContainer`]. Maps directly
/// to `systemctl <verb> container@<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 `<name>` is
/// `{META_DIR}#{name}`, derived by `hive-priv` — never passed over the wire.
@ -305,16 +324,17 @@ pub enum PrivRequest {
agent_name: String,
},
/// Restart a hive infrastructure container on the host via
/// `systemctl restart container@<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 {
/// Start / stop / restart a hive infrastructure container on the host
/// via `systemctl <action> container@<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
/// [`RESTARTABLE_INFRA_CONTAINERS`].
/// [`SIBLING_CONTAINERS`].
container: String,
action: InfraAction,
},
}
@ -373,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"));
}
}