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
**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 '@hive/shared/hive-menu.js'; // registers <hive-menu> — 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

View file

@ -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 <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(
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<AppState>,
AxumPath(name): AxumPath<String>,
Query(params): Query<StartParams>,
) -> 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,

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
/// 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<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 →
/// 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?)

View file

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