From af3976a76abff806e1ff16a511f92e5ade4e63ca Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 2 Aug 2026 19:52:11 +0200 Subject: [PATCH 1/2] hivectl/dashboard: add --paused / ?paused=1 to agent start --- .../src/agent-menu/hive-agent-menu.js | 39 +++++++++++----- hive-c0re/src/dashboard/lifecycle_ops.rs | 44 +++++++++++++++++-- hivectl/src/agents.rs | 41 ++++++++++++++++- hivectl/src/cli.rs | 10 ++++- 4 files changed, 118 insertions(+), 16 deletions(-) diff --git a/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js b/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js index bfed9242..637ee768 100644 --- a/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js +++ b/frontend/packages/dashboard/src/agent-menu/hive-agent-menu.js @@ -26,9 +26,18 @@ import { el } from '@hive/shared/dom.js'; import { themedConfirm, themedToast } from '@hive/shared/modal.js'; import '@hive/shared/hive-menu.js'; // registers — side-effect import -// Single-agent POST helper shared by all menu items. -async function agentMenuPost(actionPath, name, body, graceful) { - const url = actionPath + encodeURIComponent(name) + (graceful ? '?graceful=true' : ''); +// Single-agent POST helper shared by all menu items. `flags` is an object +// of boolean query params to set truthy (e.g. `{ graceful: true }` or +// `{ paused: true }`) — every menu action so far needs at most one, but +// this stays a plain object rather than a single named arg so a future +// action needing two doesn't have to touch this signature again. +async function agentMenuPost(actionPath, name, body, flags) { + const params = new URLSearchParams(); + for (const [k, v] of Object.entries(flags || {})) { + if (v) params.set(k, 'true'); + } + const qs = params.toString(); + const url = actionPath + encodeURIComponent(name) + (qs ? '?' + qs : ''); try { const resp = await fetch(url, { method: 'POST', @@ -79,20 +88,25 @@ class HiveAgentMenu extends HTMLElement { }, label); item.addEventListener('click', async () => { close(); - let graceful = false; + let flags = {}; if (opts.confirm) { + const checkboxes = []; + if (opts.graceful) { + checkboxes.push({ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let the agent finish its turn and flush state before the container stops' }); + } + if (opts.paused) { + checkboxes.push({ name: 'paused', label: opts.pausedLabel || 'start paused — come up without driving turns until resumed' }); + } const r = await themedConfirm({ message: opts.confirm, danger: true, confirmLabel: opts.confirmLabel || 'confirm', - checkboxes: opts.graceful - ? [{ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let the agent finish its turn and flush state before the container stops' }] - : [], + checkboxes, }); if (!r) return; - graceful = !!r.graceful; + flags = { graceful: !!r.graceful, paused: !!r.paused }; } - await agentMenuPost(opts.action, c.name, opts.body || null, graceful); + await agentMenuPost(opts.action, c.name, opts.body || null, flags); }); li.append(item); return li; @@ -127,7 +141,12 @@ class HiveAgentMenu extends HTMLElement { ); } else { dropdown.append( - menuItem('▶ ST4RT', { action: '/api/start/', confirm: `start ${c.name}?` }), + menuItem('▶ ST4RT', { + action: '/api/start/', + confirm: `start ${c.name}?`, + paused: true, + pausedLabel: 'start paused — come up without driving turns until resumed', + }), ); } // Pause/resume is orthogonal to running: a paused stopped agent boots diff --git a/hive-c0re/src/dashboard/lifecycle_ops.rs b/hive-c0re/src/dashboard/lifecycle_ops.rs index ff6855a6..298f9653 100644 --- a/hive-c0re/src/dashboard/lifecycle_ops.rs +++ b/hive-c0re/src/dashboard/lifecycle_ops.rs @@ -164,26 +164,64 @@ pub(super) async fn post_restart( (StatusCode::OK, "ok").into_response() } -/// `POST /api/start/{name}` — start `name`. +/// Query params for `post_start`. `?paused=1` writes the pause marker +/// before (or instead of) starting — see `post_start`'s doc. +#[derive(Deserialize, IntoParams)] +pub(super) struct StartParams { + #[serde(default)] + paused: bool, +} + +/// `POST /api/start/{name}?paused=1` — start `name`, optionally paused. +/// +/// Plain `?paused=1` mirrors `hivectl agent start --paused`: if +/// `name` is already running, this just writes the pause marker in place +/// and returns without submitting a start DAG (nothing to start). If it's +/// down, the marker is written *before* the start DAG is submitted, so +/// the container comes up paused rather than racing the harness's own +/// pause-gate poll against an already-in-flight start. #[utoipa::path( post, path = "/api/start/{name}", - params(("name" = String, Path, description = "agent name")), + params( + ("name" = String, Path, description = "agent name"), + StartParams, + ), responses( - (status = 200, description = "start queued", body = String), + (status = 200, description = "start queued (or paused in place)", body = String), (status = 400, description = "bad agent name"), (status = 404, description = "no such agent"), + (status = 500, description = "pause marker write failed"), ), tag = "lifecycle_ops" )] pub(super) async fn post_start( State(state): State, AxumPath(name): AxumPath, + Query(params): Query, ) -> Response { let logical = strip_container_prefix(&name); if let Some(reject) = guard_agent_name(&state, &logical).await { return reject; } + if params.paused { + let ident = match Ident::parse(&logical) { + Ok(i) => i, + Err(e) => { + return (StatusCode::BAD_REQUEST, format!("bad agent name: {e}")).into_response(); + } + }; + let already_running = lifecycle::is_running(&logical).await; + if let Err(e) = crate::coordinator::Coordinator::set_paused(&ident, true).await { + return error_response(&format!("pause {logical}: {e}")); + } + state.coord.rescan_containers_and_emit().await; + if already_running { + // Already up — pausing in place is the whole request, no DAG + // to submit. + return (StatusCode::OK, "ok").into_response(); + } + } submit::start( &state.coord, &logical, diff --git a/hivectl/src/agents.rs b/hivectl/src/agents.rs index e37f33b9..c66f7e5d 100644 --- a/hivectl/src/agents.rs +++ b/hivectl/src/agents.rs @@ -43,13 +43,29 @@ async fn agents_restart(socket: &Path, name: &str, no_wait: bool) -> Result<()> /// scope-based path), just scoped to this one name — a separate /// per-agent wire request would have duplicated logic this scope-based /// one already covers. -async fn agents_start(socket: &Path, name: &str) -> Result<()> { +/// +/// `paused`: if the agent is already running, sets the pause marker in +/// place and returns without submitting a start DAG at all (nothing to +/// start). Otherwise sets the marker *before* the start request, so the +/// container comes up paused rather than racing the harness's own +/// pause-gate poll against a start that's already in flight — the same +/// "pausing a stopped agent makes it come up paused" guarantee `pause` +/// already provides on its own. +async fn agents_start(socket: &Path, name: &str, paused: bool) -> Result<()> { if !crate::util::agent_exists(socket, name).await? { bail!( "no such agent: '{name}' (no state dir under {}/) — use 'hivectl agent {name} create' to provision a brand-new agent", hive_host_sock::AGENTS_ROOT ); } + if paused { + let already_running = agent_running(socket, name).await?; + set_paused(socket, name, true).await?; + if already_running { + eprintln!("'{name}' is already running — paused in place, not (re)started"); + return Ok(()); + } + } render( crate::client::request( socket, @@ -64,6 +80,27 @@ async fn agents_start(socket: &Path, name: &str) -> Result<()> { ) } +/// Whether `name`'s container is currently running, per the same +/// `AgentStatus` roster `hivectl list-agents` renders. No per-agent wire +/// request exists for this (nor should one, for a single boolean a rarely +/// called CLI flag needs) — filter the hive-wide roster down to one row. +async fn agent_running(socket: &Path, name: &str) -> Result { + let resp = crate::client::request(socket, HostRequest::AgentStatus) + .await + .with_context(|| format!("connect to daemon socket {}", socket.display()))?; + if !resp.ok { + bail!( + "agent status: {}", + resp.error.as_deref().unwrap_or("unknown error") + ); + } + Ok(resp + .agent_statuses + .unwrap_or_default() + .iter() + .any(|r| r.name == name && r.running)) +} + /// `hivectl agent stop` — graceful-only stop (signal → drain → /// reconcile), never escalating to a hard kill. Reuses the hive-wide /// `hivectl stop --graceful` DAG (`HostRequest::Stop`'s scope-based @@ -198,7 +235,7 @@ pub(crate) async fn run_agent(socket: &Path, name: &str, cmd: AgentCmd) -> Resul AgentCmd::Restart { no_wait } => agents_restart(socket, name, no_wait).await, AgentCmd::Pause => set_paused(socket, name, true).await, AgentCmd::Resume => set_paused(socket, name, false).await, - AgentCmd::Start => agents_start(socket, name).await, + AgentCmd::Start { paused } => agents_start(socket, name, paused).await, AgentCmd::Create => { 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 5bed6734..9f0761dc 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -471,7 +471,15 @@ pub enum AgentCmd { /// Start this EXISTING agent container. Fails immediately if `name` /// has no config/topology entry at all — it never attempts /// first-time creation. Use `create` for that. - Start, + Start { + /// Start (or leave) the agent paused: if it's currently down, the + /// pause marker is written before the container boots, so it comes + /// up paused instead of driving turns immediately. If it's already + /// running, this pauses it in place and does not attempt a + /// (re)start. + #[arg(long)] + paused: bool, + }, /// Create this agent container from scratch (full first-time /// provisioning), bypassing the approval queue. /// From 6d22b57a6d7b8171f88d2c17ef59a307f0de489d Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 2 Aug 2026 20:04:37 +0200 Subject: [PATCH 2/2] hivectl: regenerate docs for the start --paused flag --- docs/tools/hivectl-cli.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 4b48d834..a3bab285 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -398,7 +398,11 @@ Resume this paused agent — it drains whatever queued up while parked Start this EXISTING agent container. Fails immediately if `name` has no config/topology entry at all — it never attempts first-time creation. Use `create` for that -**Usage:** `hivectl agent start` +**Usage:** `hivectl agent start [OPTIONS]` + +###### **Options:** + +* `--paused` — Start (or leave) the agent paused: if it's currently down, the pause marker is written before the container boots, so it comes up paused instead of driving turns immediately. If it's already running, this pauses it in place and does not attempt a (re)start