feat: pause an agent's turn loop without stopping its container

A paused agent keeps its container, its claude session and its
dashboard/todo servers up, but stops driving turns. Messages queue
unacked and are drained on resume.

The whole protocol is a single marker file, `<harness>/paused`. That
directory is already a bind-mount shared between host and container, so
both sides just stat the same path: the harness reads it to decide
whether to drive a turn, hive-c0re reads it to render the badge and
writes/removes it for `hivectl pause|resume`. No new wire protocol, no
container round-trip, and it is sticky across restarts by construction.

Not calling `recv_next` while paused *is* the queueing semantic, so
there is no fencing to get wrong: reminders buffer in their unbounded
channel, the todo `Notify` permit coalesces, and a `request_next_turn`
that raced the pause survives because the gate sits above
`self_continue.take()`.

Graceful stop is handled host-side rather than in the harness: a paused
agent provably has no turn in flight, so `run_signal` skips the fence
entirely instead of eating the full `GRACEFUL_STOP_TIMEOUT` waiting for
a checkpoint turn that will never run.

`paused` is reported on `ContainerView` / `AgentStatusRow` for the
dashboard, orthogonal to `running` and reported for stopped containers
too.

Closes: hyperhive/hyperhive issue 2271
This commit is contained in:
atlas 2026-07-26 02:31:54 +02:00 committed by mara
commit 31008c83df
14 changed files with 305 additions and 1 deletions

View file

@ -200,6 +200,30 @@ selected for this agent (default `haiku` when absent). Written by
`Bus::new`. Path overridable via `HYPERHIVE_MODEL_FILE`.
Survives destroy/recreate, gone on `--purge`.
### `/harness/paused` (per agent)
Empty marker file. Its presence parks the agent's turn loop: the
harness keeps serving its web UI and MCP daemons but drives no turns,
and inbox messages queue unacked until it's removed (see
[turn loop](turn-loop.md#the-loop)).
Unusually, it's read and written from **both** sides of the harness
bind-mount, and that's the whole design: the harness stats it
in-container via `hive_sh4re::paths::paused_marker`, while hive-c0re
stats it on the host (`Coordinator::is_paused`) to populate the
`paused` field on the agent card, and creates/removes it
(`Coordinator::set_paused`) for `hivectl agents pause|resume` and the
dashboard toggle. Because the file itself is the only shared state
there's no protocol between them, no round-trip into the container, and
pause keeps working when the harness is wedged or the container is
stopped.
It lives in `/harness/` rather than `/state/` deliberately: `/state/`
is the agent's own space to fill, and this is harness control state.
Survives destroy/recreate, gone on `--purge` — so a paused agent comes
back paused after a restart, which is the intended behaviour rather
than an accident of storage.
## State dirs (per agent)
Under `/var/lib/hyperhive/agents/<name>/`:

View file

@ -139,11 +139,14 @@ hivectl agents list # roster: every agent's status + technical st
hivectl agents list --json # same data as raw JSON rows (for scripting)
hivectl agents restart iris # stop + start the `iris` container (no rebuild)
hivectl agents restart-all # stop + start every managed agent container in sequence
hivectl agents pause iris # park iris's turn loop, leave the container running
hivectl agents resume iris # let it drive turns again, draining what queued up
```
`list` prints a padded table with one row per managed agent —
`NAME STATUS REV PARENT REMIND`. STATUS collapses the health flags
(`running` / `stopped`, plus ` needs-login` / ` needs-update` when set);
(`running` / `stopped`, plus ` paused` / ` needs-login` / ` needs-update`
when set — `paused` is orthogonal to running, see below);
REV is the first 12 chars of the agent's locked config sha; PARENT is its
place in the topology tree (`-` for a root agent); REMIND is the count of
pending reminders. It reuses the same per-agent aggregation the dashboard
@ -155,6 +158,26 @@ when you need to kick a container from the host without going through
the agent hierarchy. Failures on `restart-all` are collected and
reported at the end rather than aborting mid-run.
`pause` / `resume` are the "stop burning tokens without losing the
container" pair. Pausing writes a marker file into the agent's harness
dir (`<state>/<name>/harness/paused`) which the harness re-stats every
5 s at the top of its serve loop; while it's there the agent drives no
turns, but the container, its mounts, its warm caches, its web UI and
its MCP daemons all stay up. Inbox messages queue **unacked**, so a
resume drains the backlog rather than dropping it. Points worth knowing:
- **Sticky.** The marker lives on the persistent harness mount, so a
paused agent stays paused across a container restart — and pausing a
*stopped* agent makes it come up parked.
- **Not a DAG.** Unlike `restart`/`stop`, there's no container operation
to sequence, so it applies immediately with nothing to wait on.
- **Stopping a paused agent is still fast.** The graceful-stop
handshake is skipped for a paused agent (it would never answer), which
is safe precisely because the pause check sits at the top of the loop:
a paused agent has no turn in flight to checkpoint.
- Visible as ` paused` in `agents list`'s STATUS column, as a `paused`
field on the JSON rows, and as a badge on the dashboard card.
## Choom
Drop into an interactive Claude session inside an agent container.

View file

@ -8,6 +8,15 @@ claude has access to in return.
Each agent harness (`hive-agent` — one serve-loop binary for all
agents) runs:
0. Check the pause marker (`<harness>/paused`). While it exists the
loop does nothing but re-stat it every 5 s — no broker poll, no
claude process. Because step 1 is never reached, messages stay
queued and unacked, so a resume drains the backlog instead of
losing it; reminders and todo wakes buffer in their channels. The
check runs before the self-continue slot is consumed, so a pending
`request_next_turn` survives the pause. Set it with
`hivectl agents pause <name>` or the dashboard toggle; see
[persistence](persistence.md#-harnesspaused-per-agent).
1. Long-poll `Recv` on its socket. The host-side broker
(`broker.rs::recv_blocking_batch`) returns immediately if there's
a pending message, otherwise waits up to 30 s for a broker `Sent`

View file

@ -40,6 +40,12 @@ const DEFAULT_SOCKET: &str = "/run/hive/mcp.sock";
/// Default web UI port — used when `HIVE_PORT` env is unset.
const DEFAULT_WEB_PORT: u16 = 8042;
/// How often the serve loop re-stats the pause marker while parked.
/// Only paid while an agent is actually paused, and only against the
/// local harness dir, so a tight-ish interval is cheap and keeps
/// `hivectl resume` feeling immediate.
const PAUSE_POLL: Duration = Duration::from_secs(5);
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@ -659,7 +665,45 @@ async fn serve_loop<S: Surface>(
// in-process instead of long-polling the broker. Never
// persisted: it lives entirely in this loop's stack.
let mut self_continue: Option<hive_sh4re::DeliveredMessage> = None;
// Tracks the last observed pause state so the transitions get logged
// once each instead of twelve lines a minute while parked.
let mut was_paused = false;
loop {
// Pause gate. While the marker is present this loop drives no
// turns at all — deliberately *before* the `self_continue.take()`
// below, so a `request_next_turn` that raced the pause is still
// waiting when the agent resumes rather than being consumed by
// a turn that never runs.
//
// Nothing here touches the broker: not calling `S::recv_next` is
// exactly the "messages queue unacked, resume drains the
// backlog" semantic, with no fencing and nothing to requeue.
// Reminders (unbounded channel) and todo wakes (a `Notify`
// permit) buffer on their own. The web UI and MCP daemons run as
// separate tasks, so the agent stays inspectable while parked.
//
// A `GracefulStop` can't be observed while parked, and doesn't
// need to be: hive-c0re skips the stop-checkpoint handshake for
// a paused agent, because this check sits at the top of the loop
// and so a paused agent provably has no turn in flight.
if hive_sh4re::paths::paused_marker().exists() {
if !was_paused {
tracing::info!("pause marker present — parking the turn loop");
bus.emit(LiveEvent::Note {
text: "paused: turn loop parked, messages will queue".into(),
});
was_paused = true;
}
tokio::time::sleep(PAUSE_POLL).await;
continue;
}
if was_paused {
tracing::info!("pause marker cleared — resuming the turn loop");
bus.emit(LiveEvent::Note {
text: "resumed: draining whatever queued while paused".into(),
});
was_paused = false;
}
let next = match self_continue.take() {
Some(msg) => msg,
None => match {

View file

@ -13,6 +13,15 @@ use serde::Serialize;
use crate::coordinator::Coordinator;
use crate::lifecycle::{self, AGENT_PREFIX};
// Independent per-agent flags, each its own badge on the dashboard card
// and each diffed separately by `rescan_containers_and_emit`. Grouping
// them into nested structs would need `serde(flatten)` to keep the JSON
// the frontend already consumes, for no readability gain — same
// rationale as `LifecycleScope` in hive-host-sock.
#[allow(
clippy::struct_excessive_bools,
reason = "flat wire projection of independent per-agent flags"
)]
#[derive(Serialize, Clone, PartialEq, Eq, Debug)]
pub struct ContainerView {
/// Logical agent name (no `h-` prefix). Used in action URLs.
@ -44,6 +53,15 @@ pub struct ContainerView {
/// for stopped containers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_model: Option<String>,
/// The agent's turn loop is parked: the container is up and still
/// serving its web UI / MCP daemons, but it drives no turns and its
/// inbox messages queue unacked until it resumes. Sourced from the
/// pause marker in the harness dir (`Coordinator::is_paused`), so it
/// stays true across a container restart — and, unlike `needs_login`,
/// is reported for stopped containers too: pausing a stopped agent is
/// a legitimate way to keep it idle when it next boots.
#[serde(default)]
pub paused: bool,
}
/// Build the full container list. Wraps `lifecycle::list()` and
@ -89,6 +107,7 @@ pub async fn build_all() -> Vec<ContainerView> {
} else {
None
};
let paused = Coordinator::is_paused(&logical);
out.push(ContainerView {
port: lifecycle::agent_web_port(logical.as_str()),
running,
@ -99,6 +118,7 @@ pub async fn build_all() -> Vec<ContainerView> {
deployed_sha,
parent,
active_model,
paused,
});
}
out

View file

@ -1444,6 +1444,50 @@ impl Coordinator {
crate::paths::agent_state_dir(name).join("harness")
}
/// Host-side path of the pause marker — the same file the harness
/// resolves in-container via `hive_sh4re::paths::paused_marker`,
/// reached through the harness bind-mount. Its presence means the
/// agent's turn loop is parked: the harness still serves its web UI
/// and MCP daemons, but drives no turns, so inbox messages queue up
/// unacked until the marker is removed.
///
/// Both sides only ever *stat* or create/remove this file, so there
/// is no protocol between them and pause survives a container
/// restart (and can be set on a stopped container).
pub fn agent_paused_marker(name: &hive_types::Ident) -> PathBuf {
Self::agent_harness_dir(name).join(hive_sh4re::paths::PAUSED_MARKER_FILE)
}
/// Whether `name` is currently paused. A stat error (missing agent
/// dir, permissions) reads as "not paused" — the pause indicator is
/// advisory on the host side, and the harness is the component that
/// actually enforces it.
#[must_use]
pub fn is_paused(name: &hive_types::Ident) -> bool {
Self::agent_paused_marker(name).exists()
}
/// Create or remove the pause marker. Idempotent in both
/// directions: pausing an already-paused agent (or resuming a
/// running one) is a no-op rather than an error, so the dashboard
/// toggle and `hivectl pause|resume` don't have to read-then-write.
pub fn set_paused(name: &hive_types::Ident, paused: bool) -> std::io::Result<()> {
let marker = Self::agent_paused_marker(name);
if paused {
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent)?;
}
// `create_new` would fail on the second pause; truncating an
// existing empty marker is the idempotent equivalent.
std::fs::write(&marker, b"")
} else {
match std::fs::remove_file(&marker) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
other => other,
}
}
}
/// Enumerate names that have a persistent state dir under
/// `/var/lib/hyperhive/agents/` (i.e. config / claude creds /
/// notes survive). Includes both currently-existing containers and

View file

@ -497,7 +497,18 @@ async fn run_stop_for_update(
/// Set the graceful fence + kick so the harness sees it promptly and
/// runs its one stop-checkpoint turn.
///
/// Skipped entirely for a paused agent: its loop parks on the pause
/// marker without polling the broker, so it would never observe the
/// fence and the downstream drain would just burn
/// `GRACEFUL_STOP_TIMEOUT`. Safe because the harness tests the marker at
/// the top of its loop — a paused agent has no turn in flight, so there
/// is nothing to checkpoint.
fn run_signal(coord: &Arc<Coordinator>, claim: &Claim, ctx: &Ctx<'_>) -> NodeOutput {
if hive_types::Ident::parse(&claim.agent).is_ok_and(|a| Coordinator::is_paused(&a)) {
ctx.step("graceful stop: agent paused, nothing to drain");
return NodeOutput::default();
}
ctx.step("graceful stop: signalling agent");
coord.mark_graceful_stop(&claim.agent);
coord.kick_agent(&claim.agent, "graceful stop requested");

View file

@ -101,6 +101,9 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
HostRequest::Restart { name } => {
submit_single(&coord, name.as_str(), Verb::Restart).await
}
HostRequest::SetPaused { name, paused } => {
handle_set_paused(&coord, name, *paused).await
}
HostRequest::RestartAll => handle_restart_all(&coord).await?,
HostRequest::RestartScoped { scope, graceful } => {
handle_restart_scoped(&coord, scope, *graceful).await?
@ -297,6 +300,29 @@ async fn handle_spawn(coord: &Arc<Coordinator>, name: &str) -> Result<HostRespon
Ok(HostResponse::success())
}
/// `hivectl pause|resume` / the dashboard toggle: write or remove the
/// agent's pause marker.
///
/// Deliberately not a lifecycle DAG. There's no container operation to
/// sequence — it's one marker file, and the harness picks it up on its
/// next poll — so queueing it would only add latency and a lease. That
/// also means it works on a stopped agent: the marker is sticky, so the
/// agent comes up paused.
async fn handle_set_paused(
coord: &std::sync::Arc<Coordinator>,
name: &hive_types::Ident,
paused: bool,
) -> HostResponse {
if let Err(e) = Coordinator::set_paused(name, paused) {
return HostResponse::error(format!("set paused={paused} for {name}: {e}"));
}
tracing::info!(%name, paused, "agent pause marker updated");
// Refresh the dashboard's view so the paused badge flips without
// waiting for the next periodic rescan.
coord.rescan_containers_and_emit().await;
HostResponse::success()
}
/// Collect per-agent status rows for `hivectl status` and the dashboard.
async fn handle_agent_status() -> HostResponse {
let rows = crate::container_view::build_all()
@ -316,6 +342,7 @@ async fn handle_agent_status() -> HostResponse {
// whether to drop the column entirely.
pending_reminders: 0,
parent: v.parent,
paused: v.paused,
})
.collect();
HostResponse::agent_statuses(rows)

View file

@ -252,6 +252,7 @@ mod tests {
deployed_sha: None,
parent: None,
active_model: None,
paused: false,
}
}

View file

@ -93,6 +93,19 @@ pub enum HostRequest {
/// For "kick the container" operations that don't touch the flake or
/// nspawn flags. Mirrors `lifecycle::restart` (kill + start).
Restart { name: Ident },
/// Park (or un-park) an agent's turn loop without touching its
/// container: `hivectl pause|resume <name>` and the dashboard
/// toggle. Writes/removes the marker file the harness polls, so the
/// container stays up and keeps serving its web UI and MCP daemons
/// while burning no tokens. Inbox messages queue unacked and the
/// backlog drains on resume. Not a lifecycle DAG — it's a single
/// marker write, so it applies immediately and works on a stopped
/// container too (the pause is sticky and takes effect at next boot).
SetPaused {
name: Ident,
/// `true` pauses, `false` resumes. Idempotent either way.
paused: bool,
},
/// Stop and restart all managed containers in sequence. Convenience
/// wrapper for `hivectl agents restart-all`; iterates the live
/// container list and restarts each one.

View file

@ -366,6 +366,18 @@ pub struct ContainerInfo {
/// projection of the dashboard's per-agent `ContainerView`. Carries the
/// agent's running/health flags plus the technical state an operator
/// wants in a roster overview (`hivectl agents list`).
//
// Four orthogonal, independently-observed facts about one agent, each
// rendered as its own column/token by `hivectl agents list` and read
// individually by `--json` consumers. Any combination is meaningful
// (a stopped agent can be paused and need an update), so folding them
// into a state machine or nested flag structs would only add
// `serde(flatten)` indirection to preserve the same flat JSON. Same
// rationale as `LifecycleScope` in hive-host-sock.
#[allow(
clippy::struct_excessive_bools,
reason = "flat wire projection of independent per-agent flags"
)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentStatusRow {
/// Logical agent name (no `h-` prefix).
@ -385,6 +397,12 @@ pub struct AgentStatusRow {
/// Count of this agent's pending reminders.
#[serde(default)]
pub pending_reminders: u64,
/// The agent's turn loop is parked (pause marker present in its
/// harness dir): the container may well be up and serving, it just
/// drives no turns. Orthogonal to `running` — an agent can be
/// paused while stopped, and pause survives a restart.
#[serde(default)]
pub paused: bool,
/// Parent in the topology tree. `None` marks a root-level agent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,

View file

@ -28,3 +28,26 @@ pub fn harness_dir() -> PathBuf {
let label = std::env::var("HIVE_LABEL").unwrap_or_default();
PathBuf::from(format!("/agents/{label}/harness"))
}
/// File name of the pause marker inside the harness dir. Shared so the
/// in-container resolver below and hive-c0re's host-side one (which
/// builds the same path from `/var/lib/hyperhive/agents/{name}/harness`)
/// cannot drift apart.
pub const PAUSED_MARKER_FILE: &str = "paused";
/// Marker file whose presence means "this agent is paused": the harness
/// keeps serving its web UI and MCP daemons but drives no turns, so
/// inbox messages queue up unacked until it's removed.
///
/// It lives in the harness dir rather than `state/` because `state/` is
/// the agent's own scratch space — this is harness control state. The
/// harness dir is bind-mounted from the host, so the marker is the
/// single source of truth for both sides: the harness stats it to gate
/// the turn loop, and hive-c0re stats it to render the paused
/// indicator and creates/removes it for `hivectl pause|resume`. Being a
/// plain file, it survives container restarts — pause is sticky by
/// construction, and works even when the harness isn't running.
#[must_use]
pub fn paused_marker() -> PathBuf {
harness_dir().join(PAUSED_MARKER_FILE)
}

View file

@ -57,6 +57,9 @@ async fn agents_list(socket: &Path, json: bool) -> Result<()> {
// the common case (`running`) stays short and anomalies stand out.
let status_of = |r: &hive_sh4re::AgentStatusRow| -> String {
let mut s = if r.running { "running" } else { "stopped" }.to_owned();
if r.paused {
s.push_str(" paused");
}
if r.needs_login {
s.push_str(" needs-login");
}
@ -127,6 +130,32 @@ async fn agents_restart_all(socket: &Path, no_wait: bool) -> Result<()> {
wait_for_dags(socket, resp.queued_dags.unwrap_or_default(), no_wait).await
}
/// `hivectl agents pause|resume` — flip the agent's pause marker. Not a
/// DAG, so there's nothing to wait on: the daemon writes the marker and
/// the harness picks it up on its next poll.
async fn set_paused(socket: &Path, name: &str, paused: bool) -> Result<()> {
let resp = crate::client::request(
socket,
HostRequest::SetPaused {
name: crate::util::parse_ident(name)?,
paused,
},
)
.await
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
if resp.ok {
let verb = if paused { "paused" } else { "resumed" };
println!("{verb}: {name}");
Ok(())
} else {
bail!(
"{} {name}: {}",
if paused { "pause" } else { "resume" },
resp.error.as_deref().unwrap_or("unknown error")
)
}
}
/// Dispatch `hivectl agents <verb>` — container lifecycle over the host
/// admin socket.
pub(crate) async fn run_agents(socket: &Path, cmd: AgentsCmd) -> Result<()> {
@ -134,6 +163,8 @@ pub(crate) async fn run_agents(socket: &Path, cmd: AgentsCmd) -> Result<()> {
AgentsCmd::List { json } => agents_list(socket, json).await,
AgentsCmd::Restart { name, no_wait } => agents_restart(socket, &name, no_wait).await,
AgentsCmd::RestartAll { no_wait } => agents_restart_all(socket, no_wait).await,
AgentsCmd::Pause { name } => set_paused(socket, &name, true).await,
AgentsCmd::Resume { name } => set_paused(socket, &name, false).await,
AgentsCmd::Spawn { name } => {
let name = crate::util::parse_ident(&name)?;
render(crate::client::request(socket, HostRequest::Spawn { name }).await?)

View file

@ -500,6 +500,22 @@ pub enum AgentsCmd {
#[arg(long)]
no_wait: bool,
},
/// Park an agent's turn loop, leaving the container running.
///
/// The harness stops driving turns but keeps serving its web UI and
/// MCP daemons, so the container, its mounts and its warm caches stay
/// up while it burns no tokens. Inbox messages queue unacked and the
/// backlog drains on `resume`. Sticky: it survives a restart, and
/// pausing a stopped agent makes it come up paused.
Pause {
/// Agent name.
name: String,
},
/// Resume a paused agent — it drains whatever queued up while parked.
Resume {
/// Agent name.
name: String,
},
/// Spawn a new agent container directly, bypassing the approval queue.
///
/// Operator-on-the-host only; use `request-spawn` for an approval-gated