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. ///