From 31008c83dfc744c577a82ca37fc4ab4fe72abb48 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 26 Jul 2026 02:31:54 +0200 Subject: [PATCH] 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, `/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 --- docs/persistence.md | 24 +++++++++++++++++ docs/tools/hivectl.md | 25 +++++++++++++++++- docs/turn-loop.md | 9 +++++++ hive-agent/src/main.rs | 44 +++++++++++++++++++++++++++++++ hive-c0re/src/container_view.rs | 20 ++++++++++++++ hive-c0re/src/coordinator.rs | 44 +++++++++++++++++++++++++++++++ hive-c0re/src/job_queue/exec.rs | 11 ++++++++ hive-c0re/src/server.rs | 27 +++++++++++++++++++ hive-c0re/src/stats/host_stats.rs | 1 + hive-host-sock/src/lib.rs | 13 +++++++++ hive-sh4re/src/lib.rs | 18 +++++++++++++ hive-sh4re/src/paths.rs | 23 ++++++++++++++++ hivectl/src/agents.rs | 31 ++++++++++++++++++++++ hivectl/src/cli.rs | 16 +++++++++++ 14 files changed, 305 insertions(+), 1 deletion(-) diff --git a/docs/persistence.md b/docs/persistence.md index e6f8489c..3bc7d87b 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -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//`: diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 487a771b..15806453 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -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 (`//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. diff --git a/docs/turn-loop.md b/docs/turn-loop.md index 17c5523b..2c2321c6 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -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 (`/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 ` 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` diff --git a/hive-agent/src/main.rs b/hive-agent/src/main.rs index 4a4db3b3..179f87d3 100644 --- a/hive-agent/src/main.rs +++ b/hive-agent/src/main.rs @@ -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( // in-process instead of long-polling the broker. Never // persisted: it lives entirely in this loop's stack. let mut self_continue: Option = 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 { diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index b76d30de..78be3b87 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -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, + /// 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 { } 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 { deployed_sha, parent, active_model, + paused, }); } out diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index ea27baea..b910af2b 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -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 diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 1aa2f9f8..6d038de2 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -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, 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"); diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index d117f977..9aa00a37 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -101,6 +101,9 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> 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, name: &str) -> Result, + 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) diff --git a/hive-c0re/src/stats/host_stats.rs b/hive-c0re/src/stats/host_stats.rs index 1ccf80bd..53985762 100644 --- a/hive-c0re/src/stats/host_stats.rs +++ b/hive-c0re/src/stats/host_stats.rs @@ -252,6 +252,7 @@ mod tests { deployed_sha: None, parent: None, active_model: None, + paused: false, } } diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 1b5ffcdd..ac8fbbb7 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -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 ` 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. diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 08dc82b5..ac49b852 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -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, diff --git a/hive-sh4re/src/paths.rs b/hive-sh4re/src/paths.rs index 1a390d73..8c7f7d0d 100644 --- a/hive-sh4re/src/paths.rs +++ b/hive-sh4re/src/paths.rs @@ -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) +} diff --git a/hivectl/src/agents.rs b/hivectl/src/agents.rs index 16fa73d6..0c689a93 100644 --- a/hivectl/src/agents.rs +++ b/hivectl/src/agents.rs @@ -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 ` — 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?) diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index 22644438..a87c0908 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -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