Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
309adec07f | ||
|
|
20d8618624 | ||
|
|
31008c83df |
15 changed files with 342 additions and 1 deletions
|
|
@ -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>/`:
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ This document contains the help content for the `hivectl` command-line program.
|
|||
* [`hivectl agents list`↴](#hivectl-agents-list)
|
||||
* [`hivectl agents restart`↴](#hivectl-agents-restart)
|
||||
* [`hivectl agents restart-all`↴](#hivectl-agents-restart-all)
|
||||
* [`hivectl agents pause`↴](#hivectl-agents-pause)
|
||||
* [`hivectl agents resume`↴](#hivectl-agents-resume)
|
||||
* [`hivectl agents spawn`↴](#hivectl-agents-spawn)
|
||||
* [`hivectl agents request-spawn`↴](#hivectl-agents-request-spawn)
|
||||
* [`hivectl agents kill`↴](#hivectl-agents-kill)
|
||||
|
|
@ -339,6 +341,8 @@ Lifecycle actions on managed agent containers. Needs the hive-c0re daemon runnin
|
|||
* `list` — Show all managed agents with their status and technical state
|
||||
* `restart` — Stop and start a single agent container without rebuilding config
|
||||
* `restart-all` — Restart all managed agent containers
|
||||
* `pause` — Park an agent's turn loop, leaving the container running
|
||||
* `resume` — Resume a paused agent — it drains whatever queued up while parked
|
||||
* `spawn` — Spawn a new agent container directly, bypassing the approval queue
|
||||
* `request-spawn` — Queue a spawn request for operator approval
|
||||
* `kill` — Stop a managed container (graceful)
|
||||
|
|
@ -388,6 +392,32 @@ Restart all managed agent containers
|
|||
|
||||
|
||||
|
||||
## `hivectl agents pause`
|
||||
|
||||
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.
|
||||
|
||||
**Usage:** `hivectl agents pause <NAME>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<NAME>` — Agent name
|
||||
|
||||
|
||||
|
||||
## `hivectl agents resume`
|
||||
|
||||
Resume a paused agent — it drains whatever queued up while parked
|
||||
|
||||
**Usage:** `hivectl agents resume <NAME>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<NAME>` — Agent name
|
||||
|
||||
|
||||
|
||||
## `hivectl agents spawn`
|
||||
|
||||
Spawn a new agent container directly, bypassing the approval queue.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1444,6 +1444,57 @@ 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.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the underlying `io::Error` when the marker can't be
|
||||
/// created (agent dir missing and un-creatable, permissions, disk
|
||||
/// full) or can't be removed. A `NotFound` on removal is *not* an
|
||||
/// error — that's the idempotent resume case.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -252,6 +252,7 @@ mod tests {
|
|||
deployed_sha: None,
|
||||
parent: None,
|
||||
active_model: None,
|
||||
paused: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue