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

@ -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 {