mcp: remove the restart/kill/start/update/get_logs agent verbs

Container lifecycle from inside an agent goes away: an agent no longer
starts, stops, restarts or rebuilds a container in its subtree, and no
longer reads another container's journal. Those are operator actions —
the dashboard and hivectl keep their own paths to the same job-queue
and hive-priv plumbing, which is why none of that machinery is removed
here, only the five MCP verbs and what they alone reached.

What went with them: the `Request` variants and `Response::Logs` on the
agent socket, the five tool definitions and their arg structs, the four
lifecycle handlers plus `handle_get_logs`, and `require_descendant` —
the topology guard those five were the only remaining callers of.
`ToolGroup::Diagnostics` goes too: `get_logs` was its only tool, so it
would otherwise be a grantable group that grants nothing. `lifecycle`
stays, now carrying `list_containers` alone.

An agent that gets a `needs_update` or `container_crash` helper event
has no remedy of its own left, so the system prompt and the docs now
send it to the operator instead of to a tool that no longer exists.

Refs #4480
This commit is contained in:
atlas 2026-09-19 03:47:18 +02:00 • committed by mara
commit 87970a8c93
17 changed files with 48 additions and 450 deletions

View file

@ -107,10 +107,7 @@ pub(super) async fn post_kill(
// submitted by other sub-agents still process through the
// host-side approval queue without the manager up, and
// operator-driven meta-input updates work from the dashboard
// either way. The MCP-surface self-kill guard in
// `socket_server.rs::Request::Kill` stays in place: a
// manager calling Kill on its own container is self-suicide
// mid-call, not a legitimate operator action.
// either way.
if let Err(e) =
crate::job_queue::power::stop_many(&state.coord, std::slice::from_ref(&logical), false)
.await

View file

@ -1,97 +1,12 @@
//! Container-lifecycle request handlers (`Start` / `Restart` / `Kill` /
//! `Update` / `ListDescendants`). All are topology-guarded via
//! `super::require_descendant`.
//! `ListDescendants` request handler — the `lifecycle` tool group's
//! remaining verb, a read of the caller's own subtree.
use std::sync::Arc;
use hive_core_agent_sock::Response;
use super::require_descendant;
use crate::coordinator::Coordinator;
/// `Start` — start a container, kicking its next turn. The caller must be an
/// ancestor of `name` in the topology (the root covers every agent).
pub(super) async fn handle_start(coord: &Arc<Coordinator>, agent: &str, name: &str) -> Response {
if let Some(err) = require_descendant(agent, name, "start") {
return err;
}
tracing::info!(%agent, %name, "start container");
// Persist `wanted = Up` and submit the Start DAG; the submit layer
// upgrades a stale-rev start to a full rebuild so the container
// runs current nix derivations before it starts.
if let Err(e) = crate::job_queue::power::start_many(coord, &[name.to_owned()]).await {
tracing::error!(%agent, %name, error = ?e, "start: insert failed");
}
Response::Ok
}
/// `Restart` — enqueue a restart for a container. The caller must be an
/// ancestor of `name` in the topology. Agents have no infra-container
/// restart path: an infra name here just falls through to the topology
/// guard like any other non-descendant name.
pub(super) async fn handle_restart(coord: &Arc<Coordinator>, agent: &str, name: &str) -> Response {
if let Some(err) = require_descendant(agent, name, "restart") {
return err;
}
tracing::info!(%agent, %name, "submit restart");
if let Err(e) = crate::job_queue::power::restart_many(coord, &[name.to_owned()], false).await {
tracing::error!(%agent, %name, error = ?e, "restart: insert failed");
}
Response::Ok
}
/// `Kill` — kill a container, unregister it, notify the swarm. The caller
/// must be an ancestor of `name` in the topology.
pub(super) async fn handle_kill(coord: &Arc<Coordinator>, agent: &str, name: &str) -> Response {
if let Some(err) = require_descendant(agent, name, "kill") {
return err;
}
tracing::info!(%agent, %name, "kill container");
// Persist the intent even if the kill fails — otherwise the next
// reconcile would restart the container.
if let Err(e) = coord.power.set(name, crate::power::Wanted::Offline) {
tracing::warn!(%name, error = ?e, "agent_power: set wanted=offline failed");
}
let result: anyhow::Result<()> = async {
crate::lifecycle::kill(name).await?;
coord.unregister_agent(name);
Ok(())
}
.await;
match result {
Ok(()) => {
crate::swarm_notices::notify(
"core",
Some(format!("killed:{name}")),
format!("agent '{name}' killed"),
None,
)
.await;
Response::Ok
}
Err(e) => Response::Err {
message: format!("{e:#}"),
},
}
}
/// `Update` — enqueue a rebuild for a container. The caller must be an
/// ancestor of `name` in the topology.
pub(super) fn handle_update(coord: &Arc<Coordinator>, agent: &str, name: &str) -> Response {
if let Some(err) = require_descendant(agent, name, "rebuild") {
return err;
}
tracing::info!(%agent, %name, "submit rebuild");
if let Err(e) = coord.job_queue.insert_job(|b| {
crate::job_queue::templates::rebuild(b, name, true);
Vec::new()
}) {
tracing::error!(%agent, %name, error = ?e, "update: insert failed");
}
coord.emit_rebuild_queue_snapshot();
Response::Ok
}
/// `ListDescendants` — every topological descendant of `agent` with
/// its running/stopped state, parents before children.
pub(super) async fn handle_list_descendants(coord: &Arc<Coordinator>, agent: &str) -> Response {

View file

@ -31,9 +31,7 @@ pub(crate) use schedules::filter_ghost_schedule_targets;
pub use schedules::schedule_to_wire_public;
use config_approvals::handle_request_update_meta_inputs;
use lifecycle_handlers::{
handle_kill, handle_list_descendants, handle_restart, handle_start, handle_update,
};
use lifecycle_handlers::handle_list_descendants;
use schedules::{
EditSchedulePatch, handle_cancel_schedule, handle_edit_schedule, handle_fire_schedule_now,
handle_list_schedules, handle_request_schedule_prompt,
@ -542,41 +540,31 @@ fn handle_requeue_inflight(
/// Unified dispatch for every socket connection — per-agent sockets and the
/// (now pure-transport) manager socket alike. There is no privilege bit;
/// authority derives uniformly from the caller's identity: subtree-relational
/// verbs (lifecycle/config/logs) require the caller to be an ancestor of the
/// target (`is_descendant_of`, so the root covers all); hive-wide agent-state
/// queries require the `QueryAgentState` capability; hive-wide orchestration
/// verbs (schedules / meta-inputs) require the matching tool-group (the
/// grantable capability).
/// authority derives uniformly from the caller's identity: hive-wide
/// agent-state queries require the `QueryAgentState` capability; hive-wide
/// orchestration verbs (schedules / meta-inputs) require the matching
/// tool-group (the grantable capability).
async fn dispatch(req: &Request, agent: &str, coord: &Arc<Coordinator>) -> Response {
if let Some(resp) = dispatch_shared(req, agent, coord).await {
return resp;
}
match req {
// Lifecycle + config: caller must be an ancestor of the target
// (a parent owns its whole subtree; the root covers every agent).
Request::Start { name } => handle_start(coord, agent, name).await,
Request::Restart { name } => handle_restart(coord, agent, name).await,
Request::Kill { name } => handle_kill(coord, agent, name).await,
Request::Update { name } => handle_update(coord, agent, name),
Request::ListDescendants => handle_list_descendants(coord, agent).await,
// Agent-state queries: own subtree is free; other agents + the
// hive-wide `"*"` sweep require `QueryAgentState`.
Request::GetLooseEnds { agent: target } => {
handle_get_loose_ends(coord, agent, target.as_deref())
}
// Orchestration / diagnostics verbs — gated per-verb on tool-group
// membership or topology (see `dispatch_orchestration`).
// Orchestration verbs — gated per-verb on tool-group membership
// (see `dispatch_orchestration`).
_ => dispatch_orchestration(req, agent, coord).await,
}
}
/// Handle the hive-wide orchestration verbs (scheduling, meta-input updates)
/// plus container-log reads. No blanket socket gate: each verb gates on the
/// grantable capability that authorises it — the matching tool-group
/// (`scheduling` / `approvals`), or `is_descendant_of` for `get_logs`
/// (a parent reads its subtree's logs). Any other variant is a host-admin /
/// unknown request invalid on either socket.
/// Handle the hive-wide orchestration verbs (scheduling, meta-input updates).
/// No blanket socket gate: each verb gates on the grantable capability that
/// authorises it — the matching tool-group (`scheduling` / `approvals`). Any
/// other variant is a host-admin / unknown request invalid on either socket.
async fn dispatch_orchestration(req: &Request, agent: &str, coord: &Arc<Coordinator>) -> Response {
match req {
Request::RequestUpdateMetaInputs {
@ -638,15 +626,6 @@ async fn dispatch_orchestration(req: &Request, agent: &str, coord: &Arc<Coordina
}
handle_fire_schedule_now(coord, agent, *id).await
}
Request::GetLogs {
agent: target,
lines,
} => {
if let Some(err) = require_descendant(agent, target, "read logs of") {
return err;
}
handle_get_logs(target, *lines).await
}
// Host-admin-only / unknown variants: never valid on either socket.
_ => Response::Err {
message: "request not handled on this socket".to_owned(),
@ -654,25 +633,6 @@ async fn dispatch_orchestration(req: &Request, agent: &str, coord: &Arc<Coordina
}
}
/// Topology guard for the subtree-relational lifecycle/config/log tools: the
/// `target` must be the caller itself or one of its topology descendants — a
/// parent owns its whole subtree, and the root (`ruth`) covers every agent as
/// a consequence, with no positional/hardcoded privilege. Returns `Some(Err)`
/// to short-circuit the dispatch arm when it isn't, `None` when authorised.
/// `action` is the verb phrase for the message (e.g. `"start"`).
fn require_descendant(agent: &str, target: &str, action: &str) -> Option<Response> {
if crate::topology::is_descendant_of(target, agent) {
None
} else {
Some(Response::Err {
message: format!(
"agent `{agent}` cannot {action} `{target}`: \
not in its subtree (topology)"
),
})
}
}
/// Capability guard for the hive-wide orchestration verbs: the caller must
/// hold the given tool-group. The tool-group (c0re-owned `tool_groups.json`,
/// read server-side via [`crate::tool_groups::groups_for`]) is the grantable
@ -1074,35 +1034,6 @@ pub(crate) fn handle_send(
}
}
/// `GetLogs` — read a child container's journal via hive-priv (the
/// `-M` read needs root). `journalctl -M` wants the `h-<name>` machine
/// name, which `container_name` derives.
async fn handle_get_logs(agent: &str, lines: Option<u32>) -> Response {
// Clamped here and not only in the MCP layer that also clamps it: a
// caller-side limit is not a limit, and anything speaking this socket
// sets `lines` itself. Mirrors `handle_get_host_journal`'s own cap.
let n = lines.unwrap_or(50).min(500);
let machine = crate::lifecycle::container_name(agent);
tracing::info!(%agent, %machine, %n, "manager: get_logs");
match crate::priv_client::read_container_journal(
&machine,
hive_priv_sock::JournalQuery {
lines: n,
..Default::default()
},
)
.await
{
Ok((stdout, stderr)) => {
let content = if stdout.is_empty() { stderr } else { stdout };
Response::Logs { content }
}
Err(e) => Response::Err {
message: format!("get_logs: {e:#}"),
},
}
}
#[cfg(test)]
mod tests {
use super::*;