Compare commits

..
Author SHA1 Message Date
damocles
6d22b57a6d hivectl: regenerate docs for the start --paused flag 2026-08-02 20:04:37 +02:00
damocles
af3976a76a hivectl/dashboard: add --paused / ?paused=1 to agent start 2026-08-02 19:52:11 +02:00
5 changed files with 123 additions and 17 deletions

View file

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

View file

@ -26,9 +26,18 @@ import { el } from '@hive/shared/dom.js';
import { themedConfirm, themedToast } from '@hive/shared/modal.js'; import { themedConfirm, themedToast } from '@hive/shared/modal.js';
import '@hive/shared/hive-menu.js'; // registers <hive-menu> — side-effect import import '@hive/shared/hive-menu.js'; // registers <hive-menu> — side-effect import
// Single-agent POST helper shared by all menu items. // Single-agent POST helper shared by all menu items. `flags` is an object
async function agentMenuPost(actionPath, name, body, graceful) { // of boolean query params to set truthy (e.g. `{ graceful: true }` or
const url = actionPath + encodeURIComponent(name) + (graceful ? '?graceful=true' : ''); // `{ 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 { try {
const resp = await fetch(url, { const resp = await fetch(url, {
method: 'POST', method: 'POST',
@ -79,20 +88,25 @@ class HiveAgentMenu extends HTMLElement {
}, label); }, label);
item.addEventListener('click', async () => { item.addEventListener('click', async () => {
close(); close();
let graceful = false; let flags = {};
if (opts.confirm) { 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({ const r = await themedConfirm({
message: opts.confirm, message: opts.confirm,
danger: true, danger: true,
confirmLabel: opts.confirmLabel || 'confirm', confirmLabel: opts.confirmLabel || 'confirm',
checkboxes: opts.graceful checkboxes,
? [{ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let the agent finish its turn and flush state before the container stops' }]
: [],
}); });
if (!r) return; 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); li.append(item);
return li; return li;
@ -127,7 +141,12 @@ class HiveAgentMenu extends HTMLElement {
); );
} else { } else {
dropdown.append( 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 // Pause/resume is orthogonal to running: a paused stopped agent boots

View file

@ -164,26 +164,64 @@ pub(super) async fn post_restart(
(StatusCode::OK, "ok").into_response() (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 <name> 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( #[utoipa::path(
post, post,
path = "/api/start/{name}", path = "/api/start/{name}",
params(("name" = String, Path, description = "agent name")), params(
("name" = String, Path, description = "agent name"),
StartParams,
),
responses( 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 = 400, description = "bad agent name"),
(status = 404, description = "no such agent"), (status = 404, description = "no such agent"),
(status = 500, description = "pause marker write failed"),
), ),
tag = "lifecycle_ops" tag = "lifecycle_ops"
)] )]
pub(super) async fn post_start( pub(super) async fn post_start(
State(state): State<AppState>, State(state): State<AppState>,
AxumPath(name): AxumPath<String>, AxumPath(name): AxumPath<String>,
Query(params): Query<StartParams>,
) -> Response { ) -> Response {
let logical = strip_container_prefix(&name); let logical = strip_container_prefix(&name);
if let Some(reject) = guard_agent_name(&state, &logical).await { if let Some(reject) = guard_agent_name(&state, &logical).await {
return reject; 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( submit::start(
&state.coord, &state.coord,
&logical, &logical,

View file

@ -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 /// scope-based path), just scoped to this one name — a separate
/// per-agent wire request would have duplicated logic this scope-based /// per-agent wire request would have duplicated logic this scope-based
/// one already covers. /// 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? { if !crate::util::agent_exists(socket, name).await? {
bail!( bail!(
"no such agent: '{name}' (no state dir under {}/) — use 'hivectl agent {name} create' to provision a brand-new agent", "no such agent: '{name}' (no state dir under {}/) — use 'hivectl agent {name} create' to provision a brand-new agent",
hive_host_sock::AGENTS_ROOT 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( render(
crate::client::request( crate::client::request(
socket, 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<bool> {
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 <name> stop` — graceful-only stop (signal → drain → /// `hivectl agent <name> stop` — graceful-only stop (signal → drain →
/// reconcile), never escalating to a hard kill. Reuses the hive-wide /// reconcile), never escalating to a hard kill. Reuses the hive-wide
/// `hivectl stop --graceful` DAG (`HostRequest::Stop`'s scope-based /// `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::Restart { no_wait } => agents_restart(socket, name, no_wait).await,
AgentCmd::Pause => set_paused(socket, name, true).await, AgentCmd::Pause => set_paused(socket, name, true).await,
AgentCmd::Resume => set_paused(socket, name, false).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 => { AgentCmd::Create => {
let name = crate::util::parse_ident(name)?; let name = crate::util::parse_ident(name)?;
render(crate::client::request(socket, HostRequest::Spawn { name }).await?) render(crate::client::request(socket, HostRequest::Spawn { name }).await?)

View file

@ -471,7 +471,15 @@ pub enum AgentCmd {
/// Start this EXISTING agent container. Fails immediately if `name` /// Start this EXISTING agent container. Fails immediately if `name`
/// has no config/topology entry at all — it never attempts /// has no config/topology entry at all — it never attempts
/// first-time creation. Use `create` for that. /// 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 /// Create this agent container from scratch (full first-time
/// provisioning), bypassing the approval queue. /// provisioning), bypassing the approval queue.
/// ///