wip(#3001): remove submit layer, rescue power ops into job_queue/power.rs

TREE IS RED ON PURPOSE — there is no compiling intermediate between
deleting submit and converting every caller. Checkpoint commit so the
work is durable; do not "fix" it by restoring submit.

Done:
- JobQueue::submit -> JobQueue::insert (no source/reason/container;
  returns the ids insert_job names).
- submit.rs deleted. Its 6 pure chain builders + 3 async *_many
  gatherers were NOT wrapper code and are rescued into
  job_queue/power.rs (templates.rs documents power ops as living
  outside it, because their shape needs a live is_running read).
- Converted: meta_inputs 1, topology 2, permissions 3, auto_update 2,
  actions 3, lifecycle_handlers 3.
- Dropped source/reason at every converted site: nothing ever read
  NodeKind::Dag's fields (only `{ .. }` matches exist), so they are
  write-only. Dead reason-only locals deleted; the boot sweep's summary
  became a tracing::info! rather than being lost.

Remaining: dashboard/lifecycle_ops 7, server.rs 7, and the test suite —
tests.rs has its own submit() helper whose u64 return is used as the
handle to navigate the inserted DAG, so those need a different way to
find nodes, not a mechanical port.
This commit is contained in:
atlas 2026-08-04 11:17:37 +02:00 committed by mara
commit 7c0d9d2379
9 changed files with 198 additions and 301 deletions

View file

@ -29,9 +29,9 @@
pub mod exec;
pub mod model;
pub mod power;
pub mod resource;
pub mod scheduler;
pub mod submit;
pub mod templates;
#[cfg(test)]
mod tests;
@ -208,20 +208,17 @@ impl JobQueue {
/// # Errors
/// Propagates a graph-insert error (dependencies that aren't
/// dependency-topological).
pub fn submit(
pub fn insert(
&self,
source: Source,
reason: String,
declare: impl FnOnce(&JobBuilder),
) -> anyhow::Result<u64> {
declare: impl FnOnce(&JobBuilder) -> Vec<hive_jobq::NodeGuid>,
) -> anyhow::Result<Vec<NodeId>> {
let mut inner = self.lock();
let container = inner
.append(NodeKind::Dag { source, reason }, Vec::new(), None)
.map_err(|e| anyhow::anyhow!("job_queue: container insert failed: {e}"))?;
insert_group(&mut inner, declare, Some(container))?;
let named = inner
.insert_job(None, declare)
.map_err(|e| anyhow::anyhow!("job_queue: graph insert failed: {e}"))?;
drop(inner);
self.notify.notify_one();
Ok(container.get())
Ok(named)
}
/// The scheduler itself, for `hive_jobq`'s run-loop seam

View file

@ -1,59 +1,26 @@
//! Request-level submit API — the surface the dashboard POST handlers,
//! the MCP socket handlers, and `hivectl` paths call.
//! Power ops (`stop` / `start` / `restart`) — the DAG shapes whose per-agent
//! form depends on **live** container state, so they cannot be static
//! [`super::templates`] entries.
//!
//! The **power ops** (`stop` / `start` / `restart`) are built here, not in
//! `templates.rs`: each agent's subgraph shape depends on its *live* running
//! state, which needs an async `lifecycle::is_running` read that a pure/sync
//! template can't do. So these fns are async — they read each agent's state,
//! assemble a per-agent subgraph out of the shared pure primitives
//! (`JobBuilder::node` + `templates::rebuild_nodes`), all declaring into ONE job
//! (independent per-agent roots, concurrent on their own leases).
//! The split is the purity line, not the subject matter: the `*_chain` /
//! `*_nodes` builders below are pure (they take `running` / `stale` as
//! parameters, which is what keeps them unit-testable without a container),
//! and only the `*_many` entry points do the async `lifecycle::is_running`
//! read that produces those parameters.
//!
//! Dynamic shape rule: `stop`/`start` carry a head `SetWanted(w)` (durable
//! intent write) — `restart` does NOT (it bounces the container but leaves
//! `wanted` untouched, so a deliberately-stopped agent isn't forced up). The
//! tail `Reconcile` (the convergence guarantee — cheap, noops when already
//! converged) is ALWAYS present; only the *mechanical* nodes
//! (`Signal`/`Drain`/`StopForUpdate`) are state-conditional (skipped for a
//! down agent — nothing to quiesce/stop). Keeping `Reconcile` in every shape
//! closes the TOCTOU window: if an agent flips state between the `is_running`
//! read and node execution, the tail `Reconcile` still converges it in-DAG,
//! with `StopForUpdate`-noop as the backstop — no reliance on an external
//! reconcile sweep. Every helper emits a fresh queue snapshot so the
//! dashboard shows the new DAG immediately.
//! Each entry point declares its group and inserts it. There is no metadata
//! parameter and no container node: attribution is not something every caller
//! has to invent, and a DAG is addressed by the nodes a template names.
use std::sync::Arc;
use super::model::NodeKind;
use super::resource::Resource;
use super::templates::rebuild_nodes;
use super::{JobBuilder, Source, templates};
use hive_jobq::NodeId;
use super::JobBuilder;
use super::templates;
use crate::coordinator::Coordinator;
use crate::lifecycle;
fn submit_and_emit(
coord: &Arc<Coordinator>,
source: Source,
reason: String,
declare: impl FnOnce(&JobBuilder),
) -> u64 {
let id = coord
.job_queue
.submit(source, reason, declare)
.expect("template-declared shapes are acyclic");
coord.emit_rebuild_queue_snapshot();
id
}
/// Manual/approval-independent rebuild (always relocks the agent's
/// meta input — the meta-update cascade grows its own rebuild subgraphs
/// in-DAG instead of going through this surface).
pub fn rebuild(coord: &Arc<Coordinator>, agent: &str, source: Source, reason: String) -> u64 {
submit_and_emit(coord, source, reason, |builder| {
templates::rebuild(builder, agent, true);
})
}
// ---- dynamic power-op DAG assembly ----------------------------------------
//
// The pure per-agent chain builders below take `running` (and `stale`)
@ -226,63 +193,44 @@ pub(crate) fn restart_nodes(builder: &JobBuilder, targets: &[(String, bool)], gr
}
}
/// Restart a single agent. Thin wrapper over [`restart_many`].
pub async fn restart(coord: &Arc<Coordinator>, agent: &str, source: Source, reason: String) -> u64 {
restart_many(coord, &[agent.to_owned()], false, source, reason).await
}
// ---- entry points ---------------------------------------------------------
//
// Each reads the live state its shape depends on, then declares + inserts.
/// Graceful restart of a single agent (signal → drain → stop → reconcile,
/// when running). Thin wrapper over [`restart_many`] with `graceful = true`.
pub async fn graceful_restart(
coord: &Arc<Coordinator>,
agent: &str,
source: Source,
reason: String,
) -> u64 {
restart_many(coord, &[agent.to_owned()], true, source, reason).await
}
/// Restart `agents` (one or many) in a **single** DAG — one per-agent
/// subgraph each, built dynamically from live running state and run
/// concurrently on their own leases. A running agent gets the stop→reconcile
/// chain (`graceful` prepends signal→drain); a down agent gets just a lone
/// `Reconcile` (nothing to stop). Restart never writes `wanted`, so the
/// Restart `agents` in a **single** DAG — one per-agent subgraph each, built
/// from live running state and run concurrently on their own leases. A running
/// agent gets the stop→reconcile chain (`graceful` prepends signal→drain); a
/// down agent gets a lone `Reconcile`. Restart never writes `wanted`, so the
/// tail `Reconcile` converges each agent to its EXISTING intent — a
/// deliberately-stopped agent stays down. The whole hive-wide
/// `hivectl restart` is one DAG.
/// deliberately-stopped agent stays down.
///
/// # Errors
/// Propagates a graph-insert error.
pub async fn restart_many(
coord: &Arc<Coordinator>,
agents: &[String],
graceful: bool,
source: Source,
reason: String,
) -> u64 {
) -> anyhow::Result<Vec<NodeId>> {
let mut targets = Vec::with_capacity(agents.len());
for agent in agents {
targets.push((agent.clone(), lifecycle::is_running(agent).await));
}
submit_and_emit(coord, source, reason, |builder| {
restart_nodes(builder, &targets, graceful);
})
let ids = coord.job_queue.insert(|b| {
restart_nodes(b, &targets, graceful);
Vec::new()
})?;
coord.emit_rebuild_queue_snapshot();
Ok(ids)
}
/// Start a single agent. Thin wrapper over [`start_many`].
pub async fn start(coord: &Arc<Coordinator>, agent: &str, source: Source, reason: String) -> u64 {
start_many(coord, &[agent.to_owned()], source, reason).await
}
/// Start `agents` (one or many) in a **single** DAG — one per-agent subgraph
/// each, built dynamically from live state and run concurrently on their own
/// leases. A down agent gets `SetWanted(Up) → Reconcile` (or, rev stale, a
/// rebuild-then-start so it comes up on current derivations); an already-
/// running agent gets `SetWanted(Up) → Reconcile` (the reconcile noops). The
/// whole hive-wide `hivectl start` is one DAG.
pub async fn start_many(
coord: &Arc<Coordinator>,
agents: &[String],
source: Source,
reason: String,
) -> u64 {
/// Start `agents` in a **single** DAG. A down agent gets
/// `SetWanted(Up) → Reconcile` (or, rev stale, a rebuild-then-start so it comes
/// up on current derivations); an already-running agent gets the same shape
/// with the reconcile noop'ing.
///
/// # Errors
/// Propagates a graph-insert error.
pub async fn start_many(coord: &Arc<Coordinator>, agents: &[String]) -> anyhow::Result<Vec<NodeId>> {
let current = crate::auto_update::current_flake_rev(&coord.hyperhive_flake);
let mut targets = Vec::with_capacity(agents.len());
for agent in agents {
@ -296,88 +244,34 @@ pub async fn start_many(
}
targets.push((agent.clone(), running, stale));
}
submit_and_emit(coord, source, reason, |builder| {
start_nodes(builder, &targets);
})
let ids = coord.job_queue.insert(|b| {
start_nodes(b, &targets);
Vec::new()
})?;
coord.emit_rebuild_queue_snapshot();
Ok(ids)
}
/// Hard stop a single agent. Thin wrapper over [`stop_many`].
pub async fn stop(coord: &Arc<Coordinator>, agent: &str, source: Source, reason: String) -> u64 {
stop_many(coord, &[agent.to_owned()], false, source, reason).await
}
/// Graceful stop of a single agent (signal → drain → reconcile, when
/// running). Thin wrapper over [`stop_many`] with `graceful = true`.
pub async fn graceful_stop(
coord: &Arc<Coordinator>,
agent: &str,
source: Source,
reason: String,
) -> u64 {
stop_many(coord, &[agent.to_owned()], true, source, reason).await
}
/// Stop `agents` (one or many) in a **single** DAG — one per-agent subgraph
/// each, built dynamically from live state and run concurrently on their own
/// leases. A running agent gets `SetWanted(Off) → [Signal → Drain →](graceful)
/// Reconcile`; a down agent gets just `SetWanted(Off) → Reconcile` (skips the
/// pointless quiesce, keeps the Reconcile as the race-up backstop). The whole
/// hive-wide `hivectl stop` is one DAG.
/// Stop `agents` in a **single** DAG. A running agent gets
/// `SetWanted(Off) → [Signal → Drain →](graceful) Reconcile`; a down agent
/// skips the pointless quiesce but keeps the `Reconcile` as the race-up
/// backstop.
///
/// # Errors
/// Propagates a graph-insert error.
pub async fn stop_many(
coord: &Arc<Coordinator>,
agents: &[String],
graceful: bool,
source: Source,
reason: String,
) -> u64 {
) -> anyhow::Result<Vec<NodeId>> {
let mut targets = Vec::with_capacity(agents.len());
for agent in agents {
targets.push((agent.clone(), lifecycle::is_running(agent).await));
}
submit_and_emit(coord, source, reason, |builder| {
stop_nodes(builder, &targets, graceful);
})
}
/// Perm change: commit the JSON file(s) then rebuild.
pub fn perm_change(
coord: &Arc<Coordinator>,
agent: &str,
source: Source,
reason: String,
payload: super::PermPayload,
) -> u64 {
submit_and_emit(coord, source, reason, |builder| {
templates::perm_change(builder, agent, payload);
})
}
/// Meta-input lock bump; cascade rebuilds fan out on completion.
pub fn meta_update(
coord: &Arc<Coordinator>,
inputs: Vec<String>,
source: Source,
reason: String,
) -> u64 {
submit_and_emit(coord, source, reason, |builder| {
templates::meta_update(builder, inputs, None);
})
}
/// Topology move(s) as a queue DAG. `moves` is `(child, new_parent)` pairs —
/// one entry for `set-parent`, N for `set-parent-bulk`. Fire-and-forget like
/// everything else in this module: submits and returns a DAG id
/// immediately, the caller learns the outcome async (dashboard job view /
/// `hivectl`'s `QueueDag` poll). Wired from `server.rs`'s `HostRequest::
/// SetParent` (hivectl) and `dashboard/topology.rs`'s `set-parent`/
/// `set-parent-bulk` handlers.
pub fn reparent(
coord: &Arc<Coordinator>,
moves: Vec<(hive_types::Ident, Option<hive_types::Ident>)>,
source: Source,
reason: String,
) -> u64 {
submit_and_emit(coord, source, reason, |builder| {
templates::reparent(builder, moves);
})
let ids = coord.job_queue.insert(|b| {
stop_nodes(b, &targets, graceful);
Vec::new()
})?;
coord.emit_rebuild_queue_snapshot();
Ok(ids)
}

View file

@ -36,17 +36,17 @@ fn rebuild(builder: &JobBuilder, agent: &str) {
/// Restart shape with every agent treated as **running** — the online
/// shape (`[Signal→Drain→] StopForUpdate → Reconcile`, no `SetWanted` head)
/// most queue-mechanics tests assume. Mirrors the pre-dynamic
/// `templates::restart` (which is now the state-aware `submit::restart_nodes`).
/// `templates::restart` (which is now the state-aware `power::restart_nodes`).
fn restart_online(builder: &JobBuilder, agents: &[&str], graceful: bool) {
let targets: Vec<(String, bool)> = agents.iter().map(|a| ((*a).to_owned(), true)).collect();
submit::restart_nodes(builder, &targets, graceful);
power::restart_nodes(builder, &targets, graceful);
}
/// Stop shape with every agent treated as **running** — the online shape
/// (`SetWanted → [Signal→Drain→](graceful) Reconcile`).
fn stop_online(builder: &JobBuilder, agents: &[&str], graceful: bool) {
let targets: Vec<(String, bool)> = agents.iter().map(|a| ((*a).to_owned(), true)).collect();
submit::stop_nodes(builder, &targets, graceful);
power::stop_nodes(builder, &targets, graceful);
}
// `Claimed` / `ClaimReady` / `CompleteNode` lived here: a claim snapshot type
@ -682,7 +682,7 @@ fn multi_agent_start_one_dag_folds_per_agent_stale_rebuild() {
// fresh: offline + not stale → SetWanted → Reconcile.
// stale: offline + stale → SetWanted → «rebuild subgraph».
let id = submit(&q, "hive-wide start", |builder| {
submit::start_nodes(
power::start_nodes(
builder,
&[
("fresh".to_owned(), false, false),
@ -742,13 +742,13 @@ fn offline_agents_skip_mechanical_nodes_but_keep_reconcile() {
let q = JobQueue::new(4);
// Offline graceful stop → SetWanted(Off) → Reconcile (no Signal/Drain).
let stop = submit(&q, "stop down", |builder| {
submit::stop_nodes(builder, &[("down".to_owned(), false)], true);
power::stop_nodes(builder, &[("down".to_owned(), false)], true);
});
// Offline restart → a lone Reconcile (no SetWanted, no StopForUpdate):
// nothing to bounce, and restart never rewrites intent, so the tail
// Reconcile converges the down agent to its existing `wanted`.
let restart = submit(&q, "restart down", |builder| {
submit::restart_nodes(builder, &[("down2".to_owned(), false)], true);
power::restart_nodes(builder, &[("down2".to_owned(), false)], true);
});
let shape = |id: u64| -> Vec<String> {
// The group's work nodes: its subtree minus the container itself,
@ -1153,19 +1153,19 @@ fn cancelled_power_op_runs_no_compensating_node() {
let q = JobQueue::new(1);
let id = submit(&q, "bounce", |builder| {
submit::restart_nodes(builder, &targets, graceful);
power::restart_nodes(builder, &targets, graceful);
});
assert_cancels_clean(&q, id, false, &format!("restart {case}"));
let q = JobQueue::new(1);
let id = submit(&q, "stop", |builder| {
submit::stop_nodes(builder, &targets, graceful);
power::stop_nodes(builder, &targets, graceful);
});
assert_cancels_clean(&q, id, true, &format!("stop {case}"));
let q = JobQueue::new(1);
let id = submit(&q, "start", |builder| {
submit::start_nodes(builder, &[("agent-a".to_owned(), running, false)]);
power::start_nodes(builder, &[("agent-a".to_owned(), running, false)]);
});
assert_cancels_clean(&q, id, true, &format!("start {case}"));
}