hyperhive/hive-c0re/src/rebuild_queue.rs

2158 lines
82 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Global rebuild queue — serialises all long-running container/meta
//! operations (rebuild, meta-update, first-spawn) through a single
//! background worker. Design rationale, kind taxonomy, dedup rules,
//! cascade parent tracking, and step labels:
//! `docs/coordinator.md::Rebuild queue`.
use std::collections::VecDeque;
use std::sync::Mutex;
use anyhow::Context as _;
use serde::{Deserialize, Serialize};
use tokio::sync::Notify;
/// What the queue can run. Each variant maps to a specific worker
/// execution path; `agent` (in `QueueEntry`) names the target where
/// relevant.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum QueueKind {
/// Rebuild a single agent's container (`auto_update::rebuild_agent`).
Rebuild,
/// Run `nix flake update` on the meta flake. Triggers cascade
/// `Rebuild` entries (with `parent_id`) once the lock bump lands.
MetaUpdate,
/// First-deploy spawn of a new agent (approval-driven).
Spawn,
/// Destroy with `--purge` (real fs work). Not yet routed here; the
/// variant exists so the wire shape doesn't need to change later.
#[allow(dead_code, reason = "wire shape — routed by a future PR")]
Destroy,
/// hive-c0re boot-time sweep: bumps the meta hyperhive lock then
/// enqueues a `Rebuild` child for every managed container. Completes
/// after the lock bump; children run as independent queue entries
/// grouped under this parent's `id`. `agent` = `"hyperhive"`.
StartupSweep,
/// Stop + start a container without touching config. Fast op (~5-10s).
/// Queued so it serialises against in-flight rebuilds for the same
/// agent — prevents a restart racing a rebuild mid-flight.
Restart,
/// Write a tool-group or capability change to the shared JSON file,
/// then rebuild the agent so the new env var takes effect.
/// Serialised through the queue so concurrent dashboard batch-apply
/// actions for different agents never race on the shared JSON file.
PermChange,
/// Gracefully stop a container: signal the harness to run one
/// stop-checkpoint turn (flush durable `/state`), then hand the drain-wait
/// to a detached watcher (freeing the build lane) which, once the agent
/// drains or `GRACEFUL_STOP_TIMEOUT` elapses, enqueues a fast-lane `Stop`
/// for the actual `nixos-container stop`. The build worker only does the
/// cheap signal, so whole-hive graceful stops overlap every agent's drain.
GracefulStop,
/// Start a stopped container (`lifecycle::start`). Routed through the
/// queue so the dashboard shows a visible queued→running transient — a
/// direct sub-second start only flashes the badge — and bulk starts
/// serialise legibly on the queue. Fast op.
Start,
/// Hard-stop a container (`lifecycle::kill`), no quiesce. Routed through
/// the queue for the same visible-progress reason as `Start`; the
/// quiescing variant is `GracefulStop`. Fast op.
Stop,
}
impl QueueKind {
pub fn as_str(self) -> &'static str {
match self {
QueueKind::Rebuild => "rebuild",
QueueKind::MetaUpdate => "meta_update",
QueueKind::Spawn => "spawn",
QueueKind::Destroy => "destroy",
QueueKind::StartupSweep => "startup_sweep",
QueueKind::Restart => "restart",
QueueKind::PermChange => "perm_change",
QueueKind::GracefulStop => "graceful_stop",
QueueKind::Start => "start",
QueueKind::Stop => "stop",
}
}
/// Fast-lane kinds: hard `Start` / `Stop`. These run on a separate
/// serial fast worker concurrently with the build lane (so a stop/start
/// never waits behind another container's slow build). `GracefulStop`
/// and `Restart` are deliberately NOT fast — they go through the build
/// lane (`GracefulStop` does the cheap harness signal then detaches the
/// drain-wait, enqueueing a fast-lane `Stop` for the real container stop;
/// `Restart` is a stop+start).
pub fn is_fast(self) -> bool {
matches!(self, QueueKind::Start | QueueKind::Stop)
}
}
/// Kind-specific payload for `QueueKind::PermChange` entries.
/// Carries the desired new value so the worker can apply the file
/// write (serialised, in FIFO order) without racing concurrent HTTP
/// handlers writing to the same shared JSON file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PermPayload {
/// Set the tool groups for one agent (`tool-groups.json`).
ToolGroups { groups: Vec<String> },
/// Set the capabilities for one agent (`capabilities.json`).
Capabilities { caps: Vec<String> },
/// Set both perm-types for one agent in a single entry — the batch
/// `POST /api/permissions` path. Either field `None` leaves that
/// file untouched (no write, no commit); the worker commits whichever
/// are present in one git commit, then rebuilds once. Collapses the
/// dedup key to `(kind, agent)` so caps + groups for one agent
/// produce a single rebuild rather than two.
Combined {
groups: Option<Vec<String>>,
caps: Option<Vec<String>>,
},
}
/// Where the enqueue request originated. Drives the "why" chip on the
/// dashboard and lets the UI group cascade entries under their parent
/// without parsing the reason text.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum QueueSource {
/// Operator clicked rebuild / update-all / meta-update on the
/// dashboard, or any other direct human action (CLI, manager tool).
Manual,
/// Spawned as a cascade from a `MetaUpdate` entry's lock-bump
/// fan-out. The `parent_id` on the `QueueEntry` points back at
/// the originating meta-update.
MetaUpdate,
/// `auto_update::run` startup sweep — rebuild every container on
/// hive-c0re boot. Legacy flat source (no parent); replaced by
/// `StartupSweep` for the parent entry and child rebuilds once the
/// queue introduced `parent_id` grouping. Kept for wire compatibility
/// with entries logged before the migration.
AutoUpdate,
/// Direct child of a `StartupSweep` queue entry — one per agent in
/// the boot-time rebuild sweep. Carries `parent_id` back-link so
/// the dashboard renders the sweep's per-agent rebuilds nested under
/// the parent header. The parent entry itself uses `QueueSource::AutoUpdate`
/// (automated boot action, not operator-driven).
StartupSweep,
/// Crash recovery path (future use — currently no auto-rebuild on
/// crash, but the variant exists for the imminent feature).
#[allow(dead_code, reason = "wire shape — used by a future feature")]
CrashRecover,
/// Operator approved a pending `Approval` row on the dashboard.
/// `QueueEntry.approval_id` points back at the source row so the
/// worker can fetch the kind-specific payload (`commit_ref`, inputs,
/// description) before dispatching.
Approval,
}
impl QueueSource {
pub fn as_str(self) -> &'static str {
match self {
QueueSource::Manual => "manual",
QueueSource::MetaUpdate => "meta_update",
QueueSource::AutoUpdate => "auto_update",
QueueSource::StartupSweep => "startup_sweep",
QueueSource::CrashRecover => "crash_recover",
QueueSource::Approval => "approval",
}
}
}
/// Lifecycle state of an entry. `Done` / `Failed` / `Cancelled` are
/// retained in the queue snapshot for a short tail (`MAX_HISTORY_PER_KIND`)
/// so the dashboard can show "last few" runs alongside live state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum QueueState {
Queued,
Running,
Done,
Failed,
Cancelled,
}
impl QueueState {
pub fn is_terminal(self) -> bool {
matches!(
self,
QueueState::Done | QueueState::Failed | QueueState::Cancelled
)
}
}
/// A single queue entry — what's pending, running, or recently finished.
/// Serialised verbatim onto the dashboard event channel and the
/// `/api/state` snapshot.
#[derive(Debug, Clone, Serialize)]
pub struct QueueEntry {
/// Monotonic per-process id. Stable for the lifetime of the entry
/// so SSE upserts land in place rather than churning the list.
pub id: u64,
/// Target agent name, or the literal `"hyperhive"` for entries
/// (`MetaUpdate`) that affect the meta flake rather than a single
/// agent.
pub agent: String,
pub kind: QueueKind,
pub state: QueueState,
pub source: QueueSource,
/// Groups cascade entries under their originating parent. For a
/// `MetaUpdate` entry this is `None`; for the per-agent rebuilds
/// the worker enqueues after the lock bump it's `Some(meta_id)`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<u64>,
/// Human-readable "why" — populated by the enqueuer (`"manual via
/// dashboard"`, `"meta-update cascade (hyperhive bumped)"`,
/// `"startup sweep"`). Free-form; dedup appends `(also requested
/// by …)` lines on repeated enqueues.
pub reason: String,
pub enqueued_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finished_at: Option<i64>,
/// Populated when `state == Failed`. Carries the worker's error
/// string (already truncated to a reasonable length by the caller).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// `MetaUpdate`-only payload: the list of meta flake inputs to run
/// through `nix flake update`. Empty / absent on `Rebuild` /
/// `Spawn` / `Destroy` entries; absent on the wire (never
/// serialised) when the entry kind doesn't have meaningful inputs.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub inputs: Vec<String>,
/// Source approval row id when this entry was created by an
/// operator-approve POST (`source == Approval`). The worker uses
/// it to re-fetch the kind-specific payload (`commit_ref` / inputs /
/// description / `fetched_sha`) and to fire `ApprovalResolved` on
/// completion. `None` for non-approval entries — preserved on
/// the wire that way too.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_id: Option<i64>,
/// Current sub-step inside the running entry. Worker mutates this
/// as the kind-specific pipeline
/// advances through phases (e.g. `"plant tags"` →
/// `"nixos-container update"` → `"finalize deploy"`). `None` while
/// `Queued` and after terminal — only meaningful with
/// `state == Running`. Each transition fires a fresh
/// `RebuildQueueChanged` snapshot so the dashboard can render
/// the label as a sub-line on the queue card. Free-form per
/// pipeline; the kind-specific worker is the source of truth.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<String>,
/// `PermChange`-only payload: the desired new permission value to
/// apply. Absent (`None`) on all other entry kinds — omitted from
/// the wire in those cases.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perm_payload: Option<PermPayload>,
/// Entries this entry must wait for before it can run. The worker
/// skips this entry until every id in the list has reached a
/// terminal state (`Done` / `Failed` / `Cancelled`) — or no longer
/// exists in the queue (evicted terminal entries are treated as
/// resolved, since `trim_history` only evicts terminals). Empty on
/// most entries; serialised only when non-empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub depends_on: Vec<u64>,
/// Row id of the associated `build_logs` entry (opened by the
/// lifecycle worker when `nixos-container update` starts). Set
/// shortly after `state` transitions to `Running`; `None` while
/// `Queued` or for entries that don't open a build log (`Restart`,
/// `PermChange` file-write phase, etc.). Links the queue card to
/// the live-streaming `/api/build-logs/id/{id}/stream` endpoint so
/// the operator can follow the nix build output in real time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build_log_id: Option<i64>,
}
/// How many terminal-state entries (`Done` / `Failed` / `Cancelled`)
/// to retain per kind in the snapshot. Older entries get evicted to
/// keep `/api/state` tight; the live event channel is unaffected.
const MAX_HISTORY_PER_KIND: usize = 5;
/// Inner state guarded by a single mutex. Held briefly — every
/// operation is constant-time relative to the queue's depth, and
/// the depths in practice are tiny (single-digit).
#[derive(Debug, Default)]
struct Inner {
entries: VecDeque<QueueEntry>,
next_id: u64,
}
/// Global rebuild queue. Lives on `Coordinator` (one per hive-c0re
/// process). The associated `Notify` wakes the worker when something
/// new arrives.
#[derive(Debug)]
pub struct RebuildQueue {
inner: Mutex<Inner>,
/// Build-lane worker wakes on this signal. The worker checks the queue
/// and loops back to `notified().await` when there's nothing to run.
/// Also nudged by the fast worker when a fast op finishes (a build may
/// have been deferred behind a `Running` fast op for the same agent).
pub(crate) notify: Notify,
/// Fast-lane worker wakes on this signal. Nudged on a fast `Start` /
/// `Stop` enqueue and by the build worker when a build finishes (a
/// deferred `Start` may now be runnable).
pub(crate) fast_notify: Notify,
}
impl Default for RebuildQueue {
fn default() -> Self {
Self {
inner: Mutex::new(Inner::default()),
notify: Notify::new(),
fast_notify: Notify::new(),
}
}
}
/// Full-shape submit spec for [`RebuildQueue::enqueue_full`] — every
/// `QueueEntry` field settable at submit time. The thinner `enqueue`
/// / `enqueue_with_inputs` / `enqueue_with_perm` wrappers build this
/// for the common cases.
pub struct FullEnqueue {
pub kind: QueueKind,
pub agent: String,
pub source: QueueSource,
pub reason: String,
pub parent_id: Option<u64>,
pub inputs: Vec<String>,
pub approval_id: Option<i64>,
pub perm_payload: Option<PermPayload>,
pub depends_on: Vec<u64>,
}
impl RebuildQueue {
pub fn new() -> Self {
Self::default()
}
/// Add an entry to the queue. Returns the entry's id (newly-allocated
/// or — on dedup — the existing entry's id with the new reason
/// appended).
///
/// Dedup rule:
/// - `Rebuild` / `Spawn` / `Destroy`: a `Queued` entry with the same
/// `(kind, agent, parent_id)` swallows the new request. `parent_id`
/// is part of the key so that a `MetaUpdate` cascade rebuild (with a
/// specific `parent_id`) never collapses into a standalone rebuild or
/// a cascade from a different `MetaUpdate`. Without this guard a
/// cascade rebuild pre-enqueued before the lock bump would be swallowed
/// by an existing `Queued` startup-sweep rebuild, causing the agent to
/// never rebuild against the post-bump meta.
/// - `MetaUpdate`: dedup ALSO requires the `inputs` field to match —
/// two meta-updates with different input lists are distinct work
/// and must queue separately, otherwise the second meta-update
/// would silently collapse into the first whenever it was still
/// `Queued`, losing the second's input set.
///
/// Running and terminal entries never dedup — operators are free
/// to re-queue a rebuild that's currently running (something
/// changed since it started) or re-run one that just finished.
pub fn enqueue(
&self,
kind: QueueKind,
agent: String,
source: QueueSource,
reason: String,
parent_id: Option<u64>,
) -> u64 {
self.enqueue_full(FullEnqueue {
kind,
agent,
source,
reason,
parent_id,
inputs: Vec::new(),
approval_id: None,
perm_payload: None,
depends_on: Vec::new(),
})
}
/// Same as `enqueue` but carries an `inputs` payload — used by
/// `MetaUpdate` enqueues to tell the worker which meta-flake
/// inputs to bump. For `MetaUpdate` the `inputs` value is part of
/// the dedup key (two meta-updates with different inputs are
/// distinct operations).
pub fn enqueue_with_inputs(
&self,
kind: QueueKind,
agent: String,
source: QueueSource,
reason: String,
parent_id: Option<u64>,
inputs: Vec<String>,
) -> u64 {
self.enqueue_full(FullEnqueue {
kind,
agent,
source,
reason,
parent_id,
inputs,
approval_id: None,
perm_payload: None,
depends_on: Vec::new(),
})
}
/// Enqueue a `PermChange` entry for `agent`. The worker applies the
/// JSON file write (serialised through FIFO) then rebuilds the
/// container so the updated env var takes effect.
pub fn enqueue_with_perm(
&self,
agent: String,
source: QueueSource,
reason: String,
payload: PermPayload,
) -> u64 {
self.enqueue_full(FullEnqueue {
kind: QueueKind::PermChange,
agent,
source,
reason,
parent_id: None,
inputs: Vec::new(),
approval_id: None,
perm_payload: Some(payload),
depends_on: Vec::new(),
})
}
/// Full-shape enqueue — every `QueueEntry` field that's settable
/// at submit time. Existing `enqueue` / `enqueue_with_inputs` /
/// `enqueue_with_perm` delegate to this; the approval-driven POST
/// handlers call it directly with the source row's id so the
/// worker can re-fetch the kind-specific payload.
pub fn enqueue_full(&self, spec: FullEnqueue) -> u64 {
let FullEnqueue {
kind,
agent,
source,
reason,
parent_id,
inputs,
approval_id,
perm_payload,
depends_on,
} = spec;
let mut inner = self.inner.lock().expect("rebuild_queue mutex poisoned");
// Dedup against a pending entry with the same (kind, agent) —
// and, for MetaUpdate, the same `inputs` list (see method
// docstring for why). Approval-driven entries also require the
// approval_id to match so two distinct approvals for the same
// agent never collapse into one queue slot. Rebuild (and Spawn /
// Destroy) entries also require parent_id to match so a
// MetaUpdate cascade rebuild is never swallowed by an unrelated
// queued rebuild (e.g. from the startup sweep). PermChange
// entries additionally check the perm type discriminant — a
// tool-groups change and a capabilities change for the same
// agent are distinct operations and must not collapse into one.
for entry in &mut inner.entries {
let perm_type_matches = matches!(
(&entry.perm_payload, &perm_payload),
(
Some(PermPayload::ToolGroups { .. }),
Some(PermPayload::ToolGroups { .. })
) | (
Some(PermPayload::Capabilities { .. }),
Some(PermPayload::Capabilities { .. })
) | (
Some(PermPayload::Combined { .. }),
Some(PermPayload::Combined { .. })
) | (None, None)
);
if entry.state == QueueState::Queued
&& entry.kind == kind
&& entry.agent == agent
&& (kind != QueueKind::MetaUpdate || entry.inputs == inputs)
&& entry.approval_id == approval_id
&& entry.parent_id == parent_id
&& perm_type_matches
&& entry.depends_on == depends_on
{
if !entry.reason.contains(&reason) {
use std::fmt::Write as _;
let _ = write!(entry.reason, "\nalso requested by: {reason}");
}
return entry.id;
}
}
inner.next_id += 1;
let id = inner.next_id;
let entry = QueueEntry {
id,
agent,
kind,
state: QueueState::Queued,
source,
parent_id,
reason,
enqueued_at: now_unix(),
started_at: None,
finished_at: None,
error: None,
inputs,
approval_id,
step: None,
perm_payload,
depends_on,
build_log_id: None,
};
inner.entries.push_back(entry);
// Wake the worker for this entry's lane (fast = Start/Stop, build =
// everything else). `notify_one` is a no-op when there's no waiter;
// the next `notified().await` returns immediately.
if kind.is_fast() {
self.fast_notify.notify_one();
} else {
self.notify.notify_one();
}
id
}
/// Claim the next runnable `Queued` entry for the **build** lane (every
/// kind except the fast `Start` / `Stop`) and mark it `Running`. See
/// [`Self::claim`] for the dependency + per-agent rules.
pub fn take_next_build(&self) -> Option<QueueEntry> {
self.claim(false)
}
/// Claim the next runnable `Queued` entry for the **fast** lane (hard
/// `Start` / `Stop`) and mark it `Running`. Runs on its own serial
/// worker concurrently with the build lane. See [`Self::claim`].
pub fn take_next_fast(&self) -> Option<QueueEntry> {
self.claim(true)
}
/// Pop the next `Queued` entry for one lane whose dependencies are
/// resolved and which doesn't race the same agent's other-lane work,
/// and mark it `Running`. Returns the entry (a clone — the original
/// stays in the queue so live state reflects "this is currently
/// running"). Returns `None` when nothing in this lane is runnable.
///
/// `want_fast` selects the lane: `true` = fast (`Start` / `Stop`),
/// `false` = build (everything else). The two lanes run on separate
/// serial workers, so this is called from both — the lane filter keeps
/// each worker to its own kinds.
///
/// A dependency is "resolved" when the dep's id is either:
/// - still in the queue AND in a terminal state (`Done` / `Failed`
/// / `Cancelled`), OR
/// - no longer in the queue (evicted by `trim_history` — only
/// terminal entries are ever evicted, so missing == completed).
///
/// Per-agent cross-lane guard (so a fast op never races that agent's
/// own build, and vice versa):
/// - a fast op waits while its agent has a build entry `Running`;
/// a `Start` additionally waits while its agent has a build entry
/// `Queued` (a start of a soon-to-be-rebuilt container is pointless);
/// - a build op waits while its agent has a fast op `Running`.
fn claim(&self, want_fast: bool) -> Option<QueueEntry> {
let mut inner = self.inner.lock().expect("rebuild_queue mutex poisoned");
// Collect ids that are still in the queue and terminal. Entries
// absent from the queue are also considered resolved (see above).
let terminal_ids: std::collections::HashSet<u64> = inner
.entries
.iter()
.filter(|e| e.state.is_terminal())
.map(|e| e.id)
.collect();
// Active (non-terminal) ids: Queued + Running. Named `active_ids`
// rather than `queued_ids` because Running entries are included;
// used to distinguish "still in flight" from "evicted (= resolved)".
let active_ids: std::collections::HashSet<u64> = inner
.entries
.iter()
.filter(|e| !e.state.is_terminal())
.map(|e| e.id)
.collect();
let pos = {
let entries = &inner.entries;
entries.iter().position(|e| {
e.state == QueueState::Queued
&& e.kind.is_fast() == want_fast
&& e.depends_on.iter().all(|dep_id| {
// Resolved if terminal in queue OR not in queue at all.
// Note: circular deps (A depends on B, B depends on A)
// silently deadlock — neither entry ever becomes
// runnable. Callers must ensure acyclic dep graphs.
terminal_ids.contains(dep_id) || !active_ids.contains(dep_id)
})
&& lane_clear(entries, e)
})
}?;
let entry = &mut inner.entries[pos];
entry.state = QueueState::Running;
entry.started_at = Some(now_unix());
Some(entry.clone())
}
/// Mark an entry terminal. `error` is populated for `Failed`;
/// `Done` / `Cancelled` ignore it. Trims the history tail.
/// Clears `step` — the field is only meaningful while `Running`,
/// and leaving a stale "in flight" label after a terminal
/// transition would mislead the dashboard render.
pub fn finish(&self, id: u64, state: QueueState, error: Option<String>) {
debug_assert!(
state.is_terminal(),
"finish() called with non-terminal {state:?}"
);
let mut inner = self.inner.lock().expect("rebuild_queue mutex poisoned");
if let Some(entry) = inner.entries.iter_mut().find(|e| e.id == id) {
entry.state = state;
entry.finished_at = Some(now_unix());
entry.error = error.filter(|_| state == QueueState::Failed);
entry.step = None;
}
Self::trim_history(&mut inner);
}
/// Set the current sub-step label on a `Running` entry.
/// Returns `true` when the row was found AND the label changed
/// (caller should emit a `RebuildQueueChanged` snapshot only on
/// `true` to avoid noisy duplicate frames). No-op for entries not
/// in `Running` — the field is conceptually undefined outside
/// that state.
pub fn set_step(&self, id: u64, step: impl Into<String>) -> bool {
let new_step = step.into();
let mut inner = self.inner.lock().expect("rebuild_queue mutex poisoned");
let Some(entry) = inner.entries.iter_mut().find(|e| e.id == id) else {
return false;
};
if entry.state != QueueState::Running {
return false;
}
if entry.step.as_deref() == Some(new_step.as_str()) {
return false;
}
entry.step = Some(new_step);
true
}
/// Link a `build_logs` row to a `Running` entry. Called by the
/// lifecycle worker when `nixos-container update` opens a build log
/// row so the dashboard can surface a "view logs" link while the
/// build is in flight. Returns `true` when the row was found and
/// the id was stored; `false` when the entry is no longer in the
/// queue or is not `Running`.
pub fn set_build_log_id(&self, id: u64, log_id: i64) -> bool {
let mut inner = self.inner.lock().expect("rebuild_queue mutex poisoned");
let Some(entry) = inner.entries.iter_mut().find(|e| e.id == id) else {
return false;
};
if entry.state != QueueState::Running {
return false;
}
entry.build_log_id = Some(log_id);
true
}
/// Snapshot the queue for `/api/state` and `RebuildQueueChanged`.
/// Cheap clone — entries are small (~hundreds of bytes each).
pub fn snapshot(&self) -> Vec<QueueEntry> {
let inner = self.inner.lock().expect("rebuild_queue mutex poisoned");
inner.entries.iter().cloned().collect()
}
/// Cancel every `Queued` entry whose `parent_id` matches `parent`.
/// Used when a `MetaUpdate` parent fails its lock bump — the
/// cascade rebuilds the enqueuer pre-queued no longer apply
/// (nothing actually changed, so they'd be wasted work). Running
/// children are left alone — they were started under the parent's
/// assumption and can't be cleanly aborted from the queue side.
/// Returns the count of cancelled entries.
pub fn cancel_children(&self, parent: u64) -> usize {
let mut inner = self.inner.lock().expect("rebuild_queue mutex poisoned");
let mut count = 0;
for entry in &mut inner.entries {
if entry.parent_id == Some(parent) && entry.state == QueueState::Queued {
entry.state = QueueState::Cancelled;
entry.finished_at = Some(now_unix());
count += 1;
}
}
if count > 0 {
Self::trim_history(&mut inner);
}
count
}
/// Cancel a `Queued` entry (no-op for `Running` / terminal — the
/// in-flight rebuild owns the agent's nix store and can't be
/// safely interrupted). Returns true when an entry was cancelled.
pub fn cancel(&self, id: u64) -> bool {
let mut inner = self.inner.lock().expect("rebuild_queue mutex poisoned");
if let Some(entry) = inner.entries.iter_mut().find(|e| e.id == id)
&& entry.state == QueueState::Queued
{
entry.state = QueueState::Cancelled;
entry.finished_at = Some(now_unix());
Self::trim_history(&mut inner);
return true;
}
false
}
/// Keep only the most recent `MAX_HISTORY_PER_KIND` terminal entries
/// per kind. Pending + running entries are never evicted.
fn trim_history(inner: &mut Inner) {
let mut counts: std::collections::HashMap<QueueKind, usize> =
std::collections::HashMap::new();
// Walk newest-first; keep the first MAX_HISTORY_PER_KIND
// terminals per kind, evict the rest.
let entries: Vec<QueueEntry> = inner
.entries
.iter()
.rev()
.filter(|e| {
if !e.state.is_terminal() {
return true;
}
let n = counts.entry(e.kind).or_insert(0);
*n += 1;
*n <= MAX_HISTORY_PER_KIND
})
.cloned()
.collect();
inner.entries = entries.into_iter().rev().collect();
}
}
/// Per-agent cross-lane guard for [`RebuildQueue::claim`]: returns true when
/// entry `e` is safe to start given the same agent's other-lane work in
/// `entries`. A fast op waits for the agent's `Running` build (and a `Start`
/// also for a `Queued` build); a build op waits for the agent's `Running`
/// fast op. Keeps a stop/start from racing that container's own rebuild.
fn lane_clear(entries: &VecDeque<QueueEntry>, e: &QueueEntry) -> bool {
let agent = e.agent.as_str();
if e.kind.is_fast() {
let build_blocking = entries.iter().any(|b| {
!b.kind.is_fast()
&& b.agent == agent
&& (b.state == QueueState::Running
|| (e.kind == QueueKind::Start && b.state == QueueState::Queued))
});
!build_blocking
} else {
!entries
.iter()
.any(|f| f.kind.is_fast() && f.agent == agent && f.state == QueueState::Running)
}
}
/// Background worker that drains the queue. Spawned once at hive-c0re
/// startup from `main.rs`. Loops forever:
/// 1. Pop the next `Queued` entry (`take_next` marks it `Running` and
/// fires a `RebuildQueueChanged` snapshot via the caller).
/// 2. Dispatch by kind — single-agent rebuild, meta-update + cascade,
/// or first-spawn.
/// 3. Mark the entry terminal (`finish`) and emit another snapshot.
/// 4. When the queue is empty, `await` on `notify` until something
/// new lands.
///
/// Shutdown semantics: subscribes to `coord.shutdown_rx()`. On a true
/// signal the worker exits after its current entry finishes; pending
/// `Queued` entries are dropped (they'll either be replayed by the
/// startup sweep on next boot or left for an operator to re-queue).
/// Max time the `GracefulStop` drain watcher waits for the harness to run its
/// stop-checkpoint turn + drain before falling back to a hard container stop.
/// Generous — a checkpoint turn can take a while — but bounded so a wedged
/// agent never blocks the stop indefinitely. The wait runs in a detached
/// watcher task (not the build worker), so a whole-hive graceful stop overlaps
/// every agent's drain instead of serialising N × this timeout.
const GRACEFUL_STOP_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(3);
/// Run one claimed queue entry to completion: snapshot, dispatch, mark
/// terminal, snapshot. Shared by both lane workers.
async fn run_one(coord: &std::sync::Arc<crate::coordinator::Coordinator>, entry: &QueueEntry) {
coord.emit_rebuild_queue_snapshot();
tracing::info!(
id = entry.id,
kind = entry.kind.as_str(),
agent = %entry.agent,
source = entry.source.as_str(),
"rebuild_queue: running"
);
match dispatch(coord, entry).await {
Ok(()) => {
coord.rebuild_queue.finish(entry.id, QueueState::Done, None);
tracing::info!(id = entry.id, "rebuild_queue: done");
}
Err(e) => {
let msg = format!("{e:#}");
let truncated = if msg.len() > 2_000 {
format!("{}", &msg[..2_000])
} else {
msg.clone()
};
coord
.rebuild_queue
.finish(entry.id, QueueState::Failed, Some(truncated));
tracing::warn!(id = entry.id, error = %msg, "rebuild_queue: failed");
}
}
coord.emit_rebuild_queue_snapshot();
}
/// Build-lane worker: drains every non-fast kind serially. Spawned once at
/// hive-c0re startup from `main.rs`, alongside [`run_fast_worker`] which
/// drains the fast `Start` / `Stop` lane concurrently.
///
/// Shutdown semantics: subscribes to `coord.shutdown_rx()`. On a true
/// signal the worker exits after its current entry finishes; pending
/// `Queued` entries are dropped (replayed by the startup sweep on next boot
/// or left for an operator to re-queue).
pub async fn run_worker(coord: std::sync::Arc<crate::coordinator::Coordinator>) {
let mut shutdown = coord.shutdown_rx();
loop {
while let Some(entry) = coord.rebuild_queue.take_next_build() {
run_one(&coord, &entry).await;
// A finished build may unblock a fast op that was deferred behind
// this agent's build — nudge the fast lane to re-check.
coord.rebuild_queue.fast_notify.notify_one();
}
tokio::select! {
biased;
res = shutdown.changed() => {
if res.is_err() || *shutdown.borrow() {
tracing::info!("rebuild_queue: build worker exiting on shutdown");
return;
}
}
() = coord.rebuild_queue.notify.notified() => {}
}
}
}
/// Fast-lane worker: drains hard `Start` / `Stop` serially, concurrently
/// with [`run_worker`], so a stop/start never waits behind another
/// container's slow build. Per-agent ordering vs that agent's own build is
/// enforced in [`RebuildQueue::claim`].
pub async fn run_fast_worker(coord: std::sync::Arc<crate::coordinator::Coordinator>) {
let mut shutdown = coord.shutdown_rx();
loop {
while let Some(entry) = coord.rebuild_queue.take_next_fast() {
run_one(&coord, &entry).await;
// A finished fast op may unblock a build deferred behind it.
coord.rebuild_queue.notify.notify_one();
}
tokio::select! {
biased;
res = shutdown.changed() => {
if res.is_err() || *shutdown.borrow() {
tracing::info!("rebuild_queue: fast worker exiting on shutdown");
return;
}
}
() = coord.rebuild_queue.fast_notify.notified() => {}
}
}
}
/// Run a single queue entry to completion. Kind-dispatched; failures
/// bubble up to the worker which marks the entry `Failed`.
///
/// Approval-driven entries (`approval_id.is_some()`) route through
/// `actions::run_approval_*` which carry the kind-specific commit
/// pipeline + the `ApprovalResolved` event fan-out. Non-approval
/// entries hit the original auto/manual rebuild paths.
/// Pick the right approval pipeline for a `Rebuild` queue entry. Both
/// `ApplyCommit` and `MergeConfigPr` approvals enqueue a `Rebuild` entry
/// (both end in a container rebuild); branch on the approval kind. Falls
/// back to the apply-commit path if the row can't be read — it re-fetches
/// + surfaces a clean error itself.
async fn dispatch_rebuild_approval(
coord: &std::sync::Arc<crate::coordinator::Coordinator>,
entry: &QueueEntry,
approval_id: i64,
) -> anyhow::Result<()> {
let kind = coord
.approvals
.get(approval_id)
.ok()
.flatten()
.map(|a| a.kind);
if kind == Some(hive_sh4re::ApprovalKind::MergeConfigPr) {
crate::actions::run_approval_merge_config_pr(coord, Some(entry.id), approval_id).await
} else {
crate::actions::run_approval_apply_commit(coord, Some(entry.id), approval_id).await
}
}
async fn dispatch(
coord: &std::sync::Arc<crate::coordinator::Coordinator>,
entry: &QueueEntry,
) -> anyhow::Result<()> {
match (entry.kind, entry.approval_id) {
(QueueKind::Rebuild, Some(approval_id)) => {
dispatch_rebuild_approval(coord, entry, approval_id).await
}
(QueueKind::Rebuild, None) => {
// A meta-update cascade has just set the meta lock; re-locking
// in the per-agent rebuild would revert it (the agent's own
// flake.lock wins). Every other source wants the relock so it
// advances to applied/<n>/main.
let relock = entry.source != QueueSource::MetaUpdate;
rebuild_for_entry(coord, entry, relock).await
}
(QueueKind::MetaUpdate, Some(approval_id)) => {
crate::actions::run_approval_update_meta_inputs(coord, Some(entry.id), approval_id)
.await
}
(QueueKind::MetaUpdate, None) => run_meta_update(coord, entry).await,
(QueueKind::Spawn, Some(approval_id)) => {
crate::actions::run_approval_spawn(coord, Some(entry.id), approval_id).await
}
(QueueKind::Spawn, None) => {
// Unreachable today: every Spawn entry is born from an
// approval (HostRequest::RequestSpawn → submit_kind →
// approve → enqueue with approval_id). The manager-side
// `RequestSpawn` surface that used to bypass approvals
// was removed; if a future direct-spawn admin path needs
// to skip the approval ride it should wire its own action
// call rather than route through here.
anyhow::bail!(
"rebuild_queue: Spawn entry id={} agent={} arrived without an approval_id — \
nothing should enqueue this shape today",
entry.id,
entry.agent,
)
}
(QueueKind::Destroy, _) => {
// Reserved for future `destroy --purge` integration.
anyhow::bail!("Destroy kind not yet implemented in rebuild_queue worker");
}
(QueueKind::StartupSweep, _) => {
// Bump meta's hyperhive input before per-agent child rebuilds
// run so they build against the latest base. Non-fatal on
// failure — child rebuilds proceed regardless. After the bump
// (or failure) this entry transitions to Done and the worker
// drains the pre-enqueued child Rebuild entries.
coord.set_queue_step(Some(entry.id), "nix flake update hyperhive");
if let Err(e) = crate::meta::lock_update_hyperhive().await {
tracing::warn!(error = ?e, "startup_sweep: meta lock_update_hyperhive failed");
}
// `finish` clears the step label; no explicit clear needed here.
Ok(())
}
(QueueKind::Restart, _) => {
let name = &entry.agent;
let _guard = coord.transient_guard(name, crate::coordinator::TransientKind::Restarting);
coord.set_queue_step(Some(entry.id), "nixos-container restart");
crate::lifecycle::restart(name).await?;
coord.kick_agent(name, "container restarted");
coord.rescan_containers_and_emit().await;
Ok(())
}
(QueueKind::PermChange, _) => {
let name = &entry.agent;
// Write + commit the perm file under META_LOCK so the
// working tree is never left dirty between the file write
// and the subsequent prepare_deploy git operations.
coord.set_queue_step(Some(entry.id), "writing + committing perm file");
match &entry.perm_payload {
Some(PermPayload::ToolGroups { groups }) => {
crate::meta::commit_tool_groups(name, groups)
.await
.with_context(|| format!("commit tool-groups for {name}"))?;
// Emit after the commit so the P3RM1SS10NS tab
// reflects the new assignment without the operator
// needing to navigate away and back.
coord.emit_tool_groups_snapshot();
}
Some(PermPayload::Capabilities { caps }) => {
crate::meta::commit_capabilities(name, caps)
.await
.with_context(|| format!("commit capabilities for {name}"))?;
coord.emit_capabilities_snapshot();
}
Some(PermPayload::Combined { groups, caps }) => {
// Batch perm change: commit whichever file(s) are
// present in a single git commit, then the rebuild
// below runs once — no double-rebuild for an agent
// whose caps AND groups both changed.
crate::meta::commit_perms(name, groups.as_deref(), caps.as_deref())
.await
.with_context(|| format!("commit perms for {name}"))?;
if groups.is_some() {
coord.emit_tool_groups_snapshot();
}
if caps.is_some() {
coord.emit_capabilities_snapshot();
}
}
None => {
anyhow::bail!(
"PermChange entry id={} agent={} is missing perm_payload",
entry.id,
entry.agent,
);
}
}
// Now rebuild so the updated HIVE_TOOL_GROUPS / HIVE_CAPABILITIES
// env var takes effect in the container.
rebuild_for_entry(coord, entry, true).await
}
(QueueKind::GracefulStop, _) => {
run_graceful_stop(coord, entry);
Ok(())
}
(QueueKind::Start, _) => run_start(coord, entry).await,
(QueueKind::Stop, _) => run_stop(coord, entry).await,
}
}
/// Queue-side container rebuild for `entry.agent`: resolves the current
/// flake rev and hands off to `rebuild_agent` with the entry's id +
/// source. Passing the source defers the start-after-rebuild to a
/// fast-lane `Start` follow-up (grouped under this entry via
/// `parent_id`), so the build lane is freed for the next entry instead
/// of waiting out the container boot.
async fn rebuild_for_entry(
coord: &std::sync::Arc<crate::coordinator::Coordinator>,
entry: &QueueEntry,
relock: bool,
) -> anyhow::Result<()> {
let current_rev =
crate::auto_update::current_flake_rev(&coord.hyperhive_flake).unwrap_or_default();
crate::auto_update::rebuild_agent(
coord,
&entry.agent,
&current_rev,
Some(entry.id),
relock,
Some(entry.source),
)
.await
}
/// Start a stopped container off the queue (`QueueKind::Start`), with a
/// `Starting` transient so the dashboard shows a visible queued→running
/// progression rather than the sub-second flash of a direct start.
/// Uses the cold-start fallback (stop + kill + start retry) so the
/// deferred start-after-rebuild keeps the same activation-error recovery
/// it had when it ran inline on the build lane.
///
/// If the hyperhive flake rev has changed since the container was last built
/// (i.e. the rev marker is stale or missing), the start is upgraded to a full
/// rebuild so the container runs current nix derivations. This is the
/// "deferred stopped container" path from `auto_update::run`.
async fn run_start(
coord: &std::sync::Arc<crate::coordinator::Coordinator>,
entry: &QueueEntry,
) -> anyhow::Result<()> {
let name = &entry.agent;
// Upgrade to rebuild+start if the rev marker is stale.
let current_rev = crate::auto_update::current_flake_rev(&coord.hyperhive_flake);
if let Some(ref rev) = current_rev {
let stored = std::fs::read_to_string(crate::auto_update::rev_marker_path(name)).ok();
if stored.as_deref() != Some(rev.as_str()) {
tracing::info!(%name, "start: rev stale — upgrading to rebuild+start");
return crate::auto_update::rebuild_agent(
coord,
name,
rev,
Some(entry.id),
true,
Some(entry.source),
)
.await;
}
}
let _guard = coord.transient_guard(name, crate::coordinator::TransientKind::Starting);
coord.set_queue_step(Some(entry.id), "nixos-container start");
crate::lifecycle::start_with_fallback(name).await?;
coord.kick_agent(name, "container started");
coord.rescan_containers_and_emit().await;
Ok(())
}
/// Hard-stop a container off the queue (`QueueKind::Stop`) — same teardown as
/// a direct kill (unregister + `Killed` event), but with a `Stopping`
/// transient for visible queue progress. The quiescing variant is
/// `run_graceful_stop`.
async fn run_stop(
coord: &std::sync::Arc<crate::coordinator::Coordinator>,
entry: &QueueEntry,
) -> anyhow::Result<()> {
let name = &entry.agent;
let _guard = coord.transient_guard(name, crate::coordinator::TransientKind::Stopping);
coord.set_queue_step(Some(entry.id), "nixos-container stop");
crate::lifecycle::kill(name).await?;
coord.unregister_agent(name);
coord.notify_manager(&hive_sh4re::HelperEvent::Killed {
agent: name.clone(),
});
coord.rescan_containers_and_emit().await;
Ok(())
}
/// Run one `GracefulStop` entry: signal the harness to quiesce (it returns
/// `GracefulStop` on its next `Recv`, runs one stop-checkpoint turn to flush
/// durable `/state`, then exits), then hand the drain-wait + container stop to
/// a detached watcher and return — freeing the build lane immediately.
///
/// This is the concurrency split: the build worker only does the cheap signal,
/// so a whole-hive graceful stop signals every agent up front and their
/// checkpoint drains overlap. The watcher waits for this agent's drain
/// (bounded by `GRACEFUL_STOP_TIMEOUT` so a wedged agent can't block forever),
/// then enqueues a fast-lane `Stop` for the actual `nixos-container stop`.
/// Routing the real stop through the fast lane means the container stops
/// serialise there (one stop at a time) while the drains ran in parallel.
fn run_graceful_stop(coord: &std::sync::Arc<crate::coordinator::Coordinator>, entry: &QueueEntry) {
let name = entry.agent.clone();
let parent_id = entry.id;
let source = entry.source;
// Signal the harness; the kick breaks an idle long-poll so it's seen promptly.
coord.set_queue_step(Some(entry.id), "graceful stop: signalling agent");
coord.mark_graceful_stop(&name);
coord.kick_agent(&name, "graceful stop requested");
// Detached watcher: wait for the drain (or timeout), then enqueue the
// container stop on the fast lane. The build entry itself is now Done —
// the dashboard groups the follow-up `Stop` under it via `parent_id`.
let coord = std::sync::Arc::clone(coord);
tokio::spawn(async move {
// Hold the `Stopping` transient across the drain so the dashboard keeps
// showing the agent quiescing; dropped before the fast `Stop` is
// enqueued (its `run_stop` re-establishes the transient) so the two
// never clobber each other's clear-on-drop.
let guard = coord.transient_guard(&name, crate::coordinator::TransientKind::Stopping);
// Wait for the harness to drain (it clears the flag via
// `GracefulStopComplete`) or fall back to a hard stop after the timeout.
let deadline = std::time::Instant::now() + GRACEFUL_STOP_TIMEOUT;
while coord.is_graceful_stop_pending(&name) {
if std::time::Instant::now() >= deadline {
tracing::warn!(agent = %name, "graceful stop: drain timed out — hard-stopping");
break;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
coord.clear_graceful_stop(&name);
drop(guard);
// Enqueue the actual container stop on the fast lane (same teardown as a
// plain kill — `run_stop`). `parent_id` links it to the graceful entry
// for dashboard grouping. `enqueue_full` nudges the fast worker itself.
coord.rebuild_queue.enqueue_full(FullEnqueue {
kind: QueueKind::Stop,
agent: name.clone(),
source,
reason: format!("container stop after graceful drain of {name}"),
parent_id: Some(parent_id),
inputs: Vec::new(),
approval_id: None,
perm_payload: None,
depends_on: Vec::new(),
});
coord.emit_rebuild_queue_snapshot();
});
}
/// Run one `MetaUpdate` entry: bump the meta flake's locks for the
/// requested inputs, then enqueue a cascade of `Rebuild` entries
/// (with `parent_id` set to this entry's id) for every agent affected
/// by the bump. Mirrors the previous `dashboard::run_meta_update`
/// semantics; that path now enqueues into this queue rather than
/// running the bump + rebuild loop inline.
async fn run_meta_update(
coord: &std::sync::Arc<crate::coordinator::Coordinator>,
entry: &QueueEntry,
) -> anyhow::Result<()> {
let _progress = coord.meta_update_guard();
let inputs = entry.inputs.clone();
tracing::info!(
?inputs,
parent = entry.id,
"rebuild_queue: meta-update starting"
);
coord.set_queue_step(Some(entry.id), "nix flake update");
let result = if inputs.is_empty() {
crate::meta::lock_update(&[]).await
} else {
crate::meta::lock_update(&inputs).await
};
if let Err(e) = result {
// Lock bump failed — cancel any pending cascade rebuilds the
// enqueuer pre-queued for this MetaUpdate. Their parent_id
// matches this entry; the children no longer make sense (we
// never bumped the lock that justified them).
let cancelled = coord.rebuild_queue.cancel_children(entry.id);
if cancelled > 0 {
tracing::warn!(
cancelled,
parent = entry.id,
"rebuild_queue: meta-update failed; cancelled cascade rebuilds"
);
coord.emit_rebuild_queue_snapshot();
}
return Err(e);
}
// Lock file changed — meta-inputs panel re-renders. The cascade
// rebuilds were already enqueued at MetaUpdate submission time,
// so no further enqueue is needed here.
crate::dashboard::emit_meta_inputs_snapshot(coord.as_ref());
Ok(())
}
/// Compute which agents a `nix flake update <inputs>` on the meta
/// flake would affect. Used by callers that pre-enqueue cascade
/// `Rebuild` entries at `MetaUpdate` submission time so the dashboard
/// can render the dependent work alongside its parent before the lock
/// bump actually runs.
///
/// Mirrors `run_meta_update`'s post-bump fan-out logic. Empty `inputs`
/// or any input under `hyperhive` → every container; otherwise just
/// the agents named by `agent-<name>` inputs.
pub async fn meta_update_cascade_agents(inputs: &[String]) -> Vec<String> {
let touched_hyperhive = inputs
.iter()
.any(|i| i == "hyperhive" || i.starts_with("hyperhive/"));
let touched_agents: Vec<String> = inputs
.iter()
.filter_map(|i| i.strip_prefix("agent-"))
.map(|rest| rest.split('/').next().unwrap_or(rest).to_owned())
.collect();
let mut names = if touched_hyperhive || inputs.is_empty() {
crate::lifecycle::list()
.await
.unwrap_or_default()
.into_iter()
.filter_map(|c| {
c.strip_prefix(crate::lifecycle::AGENT_PREFIX)
.map(str::to_owned)
})
.collect()
} else {
touched_agents
};
// Sort parents before children so the sequential queue worker
// always rebuilds a parent before any of its dependents.
let topo = crate::topology::read();
crate::auto_update::topology_sort(&mut names, &topo);
names
}
/// Current unix timestamp in seconds. `now()` calls are pulled into a
/// helper so tests can swap them out later.
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enqueue_and_take_in_order() {
let q = RebuildQueue::new();
let a = q.enqueue(
QueueKind::Rebuild,
"agent-a".to_owned(),
QueueSource::Manual,
"first".to_owned(),
None,
);
let b = q.enqueue(
QueueKind::Rebuild,
"agent-b".to_owned(),
QueueSource::Manual,
"second".to_owned(),
None,
);
assert_ne!(a, b);
let next = q.take_next_build().expect("queued");
assert_eq!(next.id, a);
assert_eq!(next.state, QueueState::Running);
let next = q.take_next_build().expect("queued");
assert_eq!(next.id, b);
assert!(q.take_next_build().is_none());
}
#[test]
fn dedup_pending_same_kind_and_agent() {
let q = RebuildQueue::new();
let a = q.enqueue(
QueueKind::Rebuild,
"agent-a".to_owned(),
QueueSource::Manual,
"first".to_owned(),
None,
);
let b = q.enqueue(
QueueKind::Rebuild,
"agent-a".to_owned(),
QueueSource::AutoUpdate,
"auto sweep".to_owned(),
None,
);
assert_eq!(a, b, "dedup should return existing id");
let snap = q.snapshot();
assert_eq!(snap.len(), 1);
assert!(snap[0].reason.contains("first"));
assert!(snap[0].reason.contains("auto sweep"));
}
#[test]
fn meta_update_dedup_matches_inputs() {
// Two MetaUpdate enqueues with identical inputs → dedup.
let q = RebuildQueue::new();
let a = q.enqueue_with_inputs(
QueueKind::MetaUpdate,
"hyperhive".to_owned(),
QueueSource::Manual,
"first".to_owned(),
None,
vec!["nixpkgs".to_owned()],
);
let b = q.enqueue_with_inputs(
QueueKind::MetaUpdate,
"hyperhive".to_owned(),
QueueSource::Manual,
"duplicate click".to_owned(),
None,
vec!["nixpkgs".to_owned()],
);
assert_eq!(a, b, "identical-inputs meta-updates should dedup");
assert_eq!(q.snapshot().len(), 1);
}
#[test]
fn meta_update_dedup_separates_distinct_inputs() {
// Two MetaUpdate enqueues with DIFFERENT inputs → distinct
// entries, not deduped.
let q = RebuildQueue::new();
let a = q.enqueue_with_inputs(
QueueKind::MetaUpdate,
"hyperhive".to_owned(),
QueueSource::Manual,
"bump nixpkgs".to_owned(),
None,
vec!["nixpkgs".to_owned()],
);
let b = q.enqueue_with_inputs(
QueueKind::MetaUpdate,
"hyperhive".to_owned(),
QueueSource::Manual,
"bump bitburner-agent".to_owned(),
None,
vec!["agent-bitburner/bitburner-agent".to_owned()],
);
assert_ne!(a, b, "different-inputs meta-updates must NOT dedup");
let snap = q.snapshot();
assert_eq!(snap.len(), 2);
// Both inputs lists are preserved.
let inputs: Vec<&[String]> = snap.iter().map(|e| e.inputs.as_slice()).collect();
assert!(inputs.iter().any(|i| *i == ["nixpkgs"]));
assert!(
inputs
.iter()
.any(|i| *i == ["agent-bitburner/bitburner-agent"])
);
}
#[test]
fn dedup_does_not_apply_across_kinds_or_agents() {
let q = RebuildQueue::new();
let a = q.enqueue(
QueueKind::Rebuild,
"agent-a".to_owned(),
QueueSource::Manual,
"r".to_owned(),
None,
);
let b = q.enqueue(
QueueKind::Rebuild,
"agent-b".to_owned(),
QueueSource::Manual,
"r".to_owned(),
None,
);
let c = q.enqueue(
QueueKind::Spawn,
"agent-a".to_owned(),
QueueSource::Manual,
"s".to_owned(),
None,
);
assert_ne!(a, b);
assert_ne!(a, c);
assert_eq!(q.snapshot().len(), 3);
}
#[test]
fn dedup_skips_running_entries() {
let q = RebuildQueue::new();
q.enqueue(
QueueKind::Rebuild,
"agent-a".to_owned(),
QueueSource::Manual,
"first".to_owned(),
None,
);
let running = q.take_next_build().expect("queued");
assert_eq!(running.state, QueueState::Running);
// While the original is running, re-enqueue is legitimate.
let again = q.enqueue(
QueueKind::Rebuild,
"agent-a".to_owned(),
QueueSource::Manual,
"config bumped during build".to_owned(),
None,
);
assert_ne!(running.id, again);
let snap = q.snapshot();
assert_eq!(snap.len(), 2);
}
#[test]
fn finish_marks_state_and_keeps_history() {
let q = RebuildQueue::new();
let id = q.enqueue(
QueueKind::Rebuild,
"agent-a".to_owned(),
QueueSource::Manual,
"r".to_owned(),
None,
);
q.take_next_build();
q.finish(id, QueueState::Done, None);
let snap = q.snapshot();
assert_eq!(snap.len(), 1);
assert_eq!(snap[0].state, QueueState::Done);
assert!(snap[0].finished_at.is_some());
assert!(snap[0].error.is_none());
}
#[test]
fn finish_with_failure_records_error() {
let q = RebuildQueue::new();
let id = q.enqueue(
QueueKind::Rebuild,
"agent-a".to_owned(),
QueueSource::Manual,
"r".to_owned(),
None,
);
q.take_next_build();
q.finish(id, QueueState::Failed, Some("nix build failed".to_owned()));
let snap = q.snapshot();
assert_eq!(snap[0].state, QueueState::Failed);
assert_eq!(snap[0].error.as_deref(), Some("nix build failed"));
}
#[test]
fn history_evicts_old_terminals_per_kind() {
let q = RebuildQueue::new();
for i in 0..(MAX_HISTORY_PER_KIND + 3) {
let id = q.enqueue(
QueueKind::Rebuild,
format!("agent-{i}"),
QueueSource::Manual,
"r".to_owned(),
None,
);
q.take_next_build();
q.finish(id, QueueState::Done, None);
}
let snap = q.snapshot();
assert_eq!(snap.len(), MAX_HISTORY_PER_KIND);
}
#[test]
fn cancel_clears_queued_entry() {
let q = RebuildQueue::new();
let id = q.enqueue(
QueueKind::Rebuild,
"agent-a".to_owned(),
QueueSource::Manual,
"r".to_owned(),
None,
);
assert!(q.cancel(id));
let snap = q.snapshot();
assert_eq!(snap[0].state, QueueState::Cancelled);
assert!(q.take_next_build().is_none());
}
#[test]
fn cancel_refuses_running_entry() {
let q = RebuildQueue::new();
let id = q.enqueue(
QueueKind::Rebuild,
"agent-a".to_owned(),
QueueSource::Manual,
"r".to_owned(),
None,
);
q.take_next_build();
assert!(!q.cancel(id));
let snap = q.snapshot();
assert_eq!(snap[0].state, QueueState::Running);
}
#[test]
fn parent_id_groups_cascade() {
let q = RebuildQueue::new();
let meta = q.enqueue(
QueueKind::MetaUpdate,
"hyperhive".to_owned(),
QueueSource::Manual,
"lock bump".to_owned(),
None,
);
let child = q.enqueue(
QueueKind::Rebuild,
"agent-a".to_owned(),
QueueSource::MetaUpdate,
"cascade".to_owned(),
Some(meta),
);
let snap = q.snapshot();
let child_entry = snap.iter().find(|e| e.id == child).expect("child queued");
assert_eq!(child_entry.parent_id, Some(meta));
}
#[test]
fn cancel_children_marks_queued_descendants() {
let q = RebuildQueue::new();
let meta = q.enqueue(
QueueKind::MetaUpdate,
"hyperhive".to_owned(),
QueueSource::Manual,
"lock bump".to_owned(),
None,
);
let a = q.enqueue(
QueueKind::Rebuild,
"agent-a".to_owned(),
QueueSource::MetaUpdate,
"cascade".to_owned(),
Some(meta),
);
let b = q.enqueue(
QueueKind::Rebuild,
"agent-b".to_owned(),
QueueSource::MetaUpdate,
"cascade".to_owned(),
Some(meta),
);
// An unrelated queued entry must not be cancelled.
let c = q.enqueue(
QueueKind::Rebuild,
"agent-c".to_owned(),
QueueSource::Manual,
"operator queued".to_owned(),
None,
);
let cancelled = q.cancel_children(meta);
assert_eq!(cancelled, 2);
let snap = q.snapshot();
let find = |id: u64| snap.iter().find(|e| e.id == id).expect("present");
assert_eq!(find(a).state, QueueState::Cancelled);
assert_eq!(find(b).state, QueueState::Cancelled);
assert_eq!(find(c).state, QueueState::Queued);
}
#[test]
fn approval_entries_keep_approval_id() {
let q = RebuildQueue::new();
let id = q.enqueue_full(FullEnqueue {
kind: QueueKind::Rebuild,
agent: "agent-a".to_owned(),
source: QueueSource::Approval,
reason: "approval 42 apply commit".to_owned(),
parent_id: None,
inputs: Vec::new(),
approval_id: Some(42),
perm_payload: None,
depends_on: Vec::new(),
});
let snap = q.snapshot();
let entry = snap.iter().find(|e| e.id == id).expect("entry present");
assert_eq!(entry.approval_id, Some(42));
assert_eq!(entry.source, QueueSource::Approval);
}
#[test]
fn approval_entries_dedup_only_on_matching_id() {
// Two pending approval-driven entries for the same agent but
// DIFFERENT approval ids must NOT collapse — each operator
// approve click is a separate piece of work even when the
// (kind, agent) pair matches.
let q = RebuildQueue::new();
let a = q.enqueue_full(FullEnqueue {
kind: QueueKind::Rebuild,
agent: "agent-a".to_owned(),
source: QueueSource::Approval,
reason: "approval #1".to_owned(),
parent_id: None,
inputs: Vec::new(),
approval_id: Some(1),
perm_payload: None,
depends_on: Vec::new(),
});
let b = q.enqueue_full(FullEnqueue {
kind: QueueKind::Rebuild,
agent: "agent-a".to_owned(),
source: QueueSource::Approval,
reason: "approval #2".to_owned(),
parent_id: None,
inputs: Vec::new(),
approval_id: Some(2),
perm_payload: None,
depends_on: Vec::new(),
});
assert_ne!(a, b);
assert_eq!(q.snapshot().len(), 2);
// Same approval_id submitted twice DOES dedup (rapid double-
// click on the dashboard's approve button is a single op).
let c = q.enqueue_full(FullEnqueue {
kind: QueueKind::Rebuild,
agent: "agent-a".to_owned(),
source: QueueSource::Approval,
reason: "approval #1 (duplicate)".to_owned(),
parent_id: None,
inputs: Vec::new(),
approval_id: Some(1),
perm_payload: None,
depends_on: Vec::new(),
});
assert_eq!(a, c);
assert_eq!(q.snapshot().len(), 2);
}
#[test]
fn cancel_children_skips_running_and_terminal() {
let q = RebuildQueue::new();
let meta = q.enqueue(
QueueKind::MetaUpdate,
"hyperhive".to_owned(),
QueueSource::Manual,
"lock bump".to_owned(),
None,
);
// Running child — must NOT be cancelled.
let running = q.enqueue(
QueueKind::Rebuild,
"agent-a".to_owned(),
QueueSource::MetaUpdate,
"cascade".to_owned(),
Some(meta),
);
q.take_next_build(); // pops meta, marks it Running
q.take_next_build(); // pops `running`, marks it Running
// Terminal child — must NOT be re-cancelled (its state stays Done).
let done = q.enqueue(
QueueKind::Rebuild,
"agent-b".to_owned(),
QueueSource::MetaUpdate,
"cascade".to_owned(),
Some(meta),
);
q.take_next_build();
q.finish(done, QueueState::Done, None);
// Queued child that should be cancelled.
let queued = q.enqueue(
QueueKind::Rebuild,
"agent-c".to_owned(),
QueueSource::MetaUpdate,
"cascade".to_owned(),
Some(meta),
);
let n = q.cancel_children(meta);
assert_eq!(n, 1);
let snap = q.snapshot();
let find = |id: u64| snap.iter().find(|e| e.id == id).expect("present");
assert_eq!(find(running).state, QueueState::Running);
assert_eq!(find(done).state, QueueState::Done);
assert_eq!(find(queued).state, QueueState::Cancelled);
}
#[test]
fn set_step_updates_running_entry_and_signals_change() {
let q = RebuildQueue::new();
let id = q.enqueue(
QueueKind::Rebuild,
"a".to_owned(),
QueueSource::Manual,
"test".to_owned(),
None,
);
// Queued — set_step should refuse (returns false).
assert!(!q.set_step(id, "plant tags"));
// Promote to Running.
let entry = q.take_next_build().expect("queued entry");
assert_eq!(entry.id, id);
// First label transition — true.
assert!(q.set_step(id, "plant tags"));
assert_eq!(
q.snapshot()
.iter()
.find(|e| e.id == id)
.and_then(|e| e.step.as_deref()),
Some("plant tags")
);
// Same label again — false (caller can skip the snapshot emit).
assert!(!q.set_step(id, "plant tags"));
// Different label — true.
assert!(q.set_step(id, "nixos-container update"));
assert_eq!(
q.snapshot()
.iter()
.find(|e| e.id == id)
.and_then(|e| e.step.as_deref()),
Some("nixos-container update")
);
}
#[test]
fn set_step_no_op_on_unknown_id() {
let q = RebuildQueue::new();
assert!(!q.set_step(999, "anything"));
}
/// A `MetaUpdate` cascade `Rebuild` (with `parent_id` = `Some(meta_id)`) must
/// NOT dedup into a pre-existing `Queued` `Rebuild` with a different `parent_id`
/// (e.g. from a startup sweep). Without the `parent_id` dedup guard the
/// cascade rebuild would be swallowed and the agent would never rebuild
/// against the post-lock-bump meta.
#[test]
fn meta_update_cascade_does_not_dedup_into_startup_sweep_rebuild() {
let q = RebuildQueue::new();
// Startup sweep enqueues a Rebuild for alice with its own parent_id.
let sweep = q.enqueue(
QueueKind::StartupSweep,
"hyperhive".to_owned(),
QueueSource::AutoUpdate,
"boot sweep".to_owned(),
None,
);
let sweep_rebuild = q.enqueue(
QueueKind::Rebuild,
"alice".to_owned(),
QueueSource::StartupSweep,
"startup sweep".to_owned(),
Some(sweep),
);
// MetaUpdate cascade pre-enqueues another Rebuild for alice.
let meta = q.enqueue(
QueueKind::MetaUpdate,
"hyperhive".to_owned(),
QueueSource::Manual,
"bump nixpkgs".to_owned(),
None,
);
let cascade_rebuild = q.enqueue(
QueueKind::Rebuild,
"alice".to_owned(),
QueueSource::MetaUpdate,
"meta-update cascade".to_owned(),
Some(meta),
);
// The two Rebuilds have different parent_ids — must NOT dedup.
assert_ne!(
sweep_rebuild, cascade_rebuild,
"cascade rebuild must be distinct from startup-sweep rebuild"
);
let snap = q.snapshot();
let rebuilds: Vec<_> = snap
.iter()
.filter(|e| e.kind == QueueKind::Rebuild && e.agent == "alice")
.collect();
assert_eq!(
rebuilds.len(),
2,
"both rebuilds must be present in the queue"
);
}
#[test]
fn finish_clears_step() {
let q = RebuildQueue::new();
let id = q.enqueue(
QueueKind::Rebuild,
"a".to_owned(),
QueueSource::Manual,
"test".to_owned(),
None,
);
q.take_next_build();
assert!(q.set_step(id, "running phase"));
q.finish(id, QueueState::Done, None);
assert_eq!(
q.snapshot()
.iter()
.find(|e| e.id == id)
.and_then(|e| e.step.as_deref()),
None
);
}
// --- depends_on tests ---
/// An entry whose dep is not yet terminal must be skipped by
/// `take_next`; it runs only after the dep finishes.
#[test]
fn depends_on_blocks_until_dep_is_terminal() {
let q = RebuildQueue::new();
let a = q.enqueue(
QueueKind::Rebuild,
"agent-a".to_owned(),
QueueSource::Manual,
"first".to_owned(),
None,
);
let b = q.enqueue_full(FullEnqueue {
kind: QueueKind::Rebuild,
agent: "agent-b".to_owned(),
source: QueueSource::Manual,
reason: "second (blocked on a)".to_owned(),
parent_id: None,
inputs: Vec::new(),
approval_id: None,
perm_payload: None,
depends_on: vec![a],
});
// B depends on A — take_next should give A first.
let first = q.take_next_build().expect("a is ready");
assert_eq!(first.id, a);
// A is Running, not terminal — B must still be blocked.
assert!(
q.take_next_build().is_none(),
"b must be blocked while a runs"
);
// Finish A → B should now be available.
q.finish(a, QueueState::Done, None);
let second = q.take_next_build().expect("b unblocked after a done");
assert_eq!(second.id, b);
}
/// An entry whose dep finished and was evicted from history is
/// treated as resolved (eviction only happens to terminal entries).
#[test]
fn depends_on_evicted_dep_counts_as_resolved() {
let q = RebuildQueue::new();
// Fill the history cap for Rebuild so old terminals get evicted.
for i in 0..MAX_HISTORY_PER_KIND {
let id = q.enqueue(
QueueKind::Rebuild,
format!("filler-{i}"),
QueueSource::Manual,
"filler".to_owned(),
None,
);
q.take_next_build();
q.finish(id, QueueState::Done, None);
}
// `dep` gets enqueued, run, finished, and evicted by the
// next history-trimming call.
let dep = q.enqueue(
QueueKind::Rebuild,
"dep-agent".to_owned(),
QueueSource::Manual,
"dep".to_owned(),
None,
);
q.take_next_build();
q.finish(dep, QueueState::Done, None);
// Push `dep` out of the per-kind history window: `trim_history`
// keeps the newest MAX_HISTORY_PER_KIND terminals per kind, so it
// takes that many newer terminals to evict `dep`.
for i in 0..MAX_HISTORY_PER_KIND {
let extra = q.enqueue(
QueueKind::Rebuild,
format!("extra-{i}"),
QueueSource::Manual,
format!("extra-{i}"),
None,
);
q.take_next_build();
q.finish(extra, QueueState::Done, None);
}
// `dep` should now be evicted.
assert!(
q.snapshot().iter().all(|e| e.id != dep),
"dep must be evicted from history"
);
// An entry that depends on the (evicted) dep must be immediately runnable.
let downstream = q.enqueue_full(FullEnqueue {
kind: QueueKind::Rebuild,
agent: "downstream".to_owned(),
source: QueueSource::Manual,
reason: "downstream (dep evicted = resolved)".to_owned(),
parent_id: None,
inputs: Vec::new(),
approval_id: None,
perm_payload: None,
depends_on: vec![dep],
});
let got = q
.take_next_build()
.expect("downstream runnable when dep evicted");
assert_eq!(got.id, downstream);
}
/// Dedup respects `depends_on`: two otherwise-identical entries with
/// different dep sets are distinct and must NOT collapse.
#[test]
fn depends_on_is_part_of_dedup_key() {
let q = RebuildQueue::new();
let dep1 = q.enqueue(
QueueKind::Rebuild,
"dep1".to_owned(),
QueueSource::Manual,
"d1".to_owned(),
None,
);
let dep2 = q.enqueue(
QueueKind::Rebuild,
"dep2".to_owned(),
QueueSource::Manual,
"d2".to_owned(),
None,
);
let a = q.enqueue_full(FullEnqueue {
kind: QueueKind::Rebuild,
agent: "target".to_owned(),
source: QueueSource::Manual,
reason: "r".to_owned(),
parent_id: None,
inputs: Vec::new(),
approval_id: None,
perm_payload: None,
depends_on: vec![dep1],
});
// Same kind+agent but different depends_on — must NOT dedup.
let b = q.enqueue_full(FullEnqueue {
kind: QueueKind::Rebuild,
agent: "target".to_owned(),
source: QueueSource::Manual,
reason: "r".to_owned(),
parent_id: None,
inputs: Vec::new(),
approval_id: None,
perm_payload: None,
depends_on: vec![dep2],
});
assert_ne!(a, b, "different depends_on must produce distinct entries");
// Same depends_on as a — must dedup.
let c = q.enqueue_full(FullEnqueue {
kind: QueueKind::Rebuild,
agent: "target".to_owned(),
source: QueueSource::Manual,
reason: "r again".to_owned(),
parent_id: None,
inputs: Vec::new(),
approval_id: None,
perm_payload: None,
depends_on: vec![dep1],
});
assert_eq!(a, c, "identical depends_on must dedup");
}
/// An entry with Failed dep is still resolved — the dependent runs
/// regardless of whether its upstream succeeded or not. Callers that
/// need to abort on dep failure should cancel the downstream manually.
#[test]
fn depends_on_failed_dep_counts_as_resolved() {
let q = RebuildQueue::new();
let a = q.enqueue(
QueueKind::Rebuild,
"a".to_owned(),
QueueSource::Manual,
"a".to_owned(),
None,
);
let b = q.enqueue_full(FullEnqueue {
kind: QueueKind::Rebuild,
agent: "b".to_owned(),
source: QueueSource::Manual,
reason: "b (blocked on a)".to_owned(),
parent_id: None,
inputs: Vec::new(),
approval_id: None,
perm_payload: None,
depends_on: vec![a],
});
q.take_next_build(); // pop a, mark Running
q.finish(a, QueueState::Failed, Some("nix build exploded".to_owned()));
let got = q.take_next_build().expect("b runnable after a failed");
assert_eq!(got.id, b);
}
// ---- fast lane (Start / Stop run on a separate concurrent worker) ----
#[test]
fn lanes_claim_only_their_own_kinds() {
let q = RebuildQueue::new();
let r = q.enqueue(
QueueKind::Rebuild,
"a".to_owned(),
QueueSource::Manual,
String::new(),
None,
);
let s = q.enqueue(
QueueKind::Stop,
"b".to_owned(),
QueueSource::Manual,
String::new(),
None,
);
let build = q.take_next_build().expect("build entry");
assert_eq!(build.id, r);
let fast = q.take_next_fast().expect("fast entry");
assert_eq!(fast.id, s);
assert!(q.take_next_build().is_none());
assert!(q.take_next_fast().is_none());
}
#[test]
fn start_defers_behind_same_agent_queued_build() {
let q = RebuildQueue::new();
let b = q.enqueue(
QueueKind::Rebuild,
"a".to_owned(),
QueueSource::Manual,
String::new(),
None,
);
q.enqueue(
QueueKind::Start,
"a".to_owned(),
QueueSource::Manual,
String::new(),
None,
);
assert!(
q.take_next_fast().is_none(),
"start blocked while same agent has a queued build"
);
q.take_next_build().expect("build runs");
q.finish(b, QueueState::Done, None);
let started = q
.take_next_fast()
.expect("start unblocked after build done");
assert_eq!(started.kind, QueueKind::Start);
}
#[test]
fn start_for_other_agent_runs_concurrently_with_a_build() {
let q = RebuildQueue::new();
q.enqueue(
QueueKind::Rebuild,
"a".to_owned(),
QueueSource::Manual,
String::new(),
None,
);
q.enqueue(
QueueKind::Start,
"a".to_owned(),
QueueSource::Manual,
String::new(),
None,
);
q.enqueue(
QueueKind::Start,
"b".to_owned(),
QueueSource::Manual,
String::new(),
None,
);
q.take_next_build().expect("a's build running");
let got = q
.take_next_fast()
.expect("start for b runs while a's build runs");
assert_eq!(got.agent, "b");
assert!(
q.take_next_fast().is_none(),
"start for a still blocked by a's running build"
);
}
#[test]
fn stop_jumps_queued_build_but_waits_running_build() {
// Stop jumps ahead of a *queued* build for the same agent.
let q = RebuildQueue::new();
q.enqueue(
QueueKind::Rebuild,
"a".to_owned(),
QueueSource::Manual,
String::new(),
None,
);
q.enqueue(
QueueKind::Stop,
"a".to_owned(),
QueueSource::Manual,
String::new(),
None,
);
let got = q
.take_next_fast()
.expect("stop jumps ahead of a's queued build");
assert_eq!(got.kind, QueueKind::Stop);
// But a stop waits for a *running* build of the same agent.
let q2 = RebuildQueue::new();
let b = q2.enqueue(
QueueKind::Rebuild,
"a".to_owned(),
QueueSource::Manual,
String::new(),
None,
);
q2.enqueue(
QueueKind::Stop,
"a".to_owned(),
QueueSource::Manual,
String::new(),
None,
);
q2.take_next_build().expect("a's build running");
assert!(
q2.take_next_fast().is_none(),
"stop waits for a's running build (no kill mid-rebuild)"
);
q2.finish(b, QueueState::Done, None);
assert!(
q2.take_next_fast().is_some(),
"stop runs once a's build is done"
);
}
#[test]
fn build_defers_behind_same_agent_running_fast_op() {
let q = RebuildQueue::new();
q.enqueue(
QueueKind::Stop,
"a".to_owned(),
QueueSource::Manual,
String::new(),
None,
);
q.enqueue(
QueueKind::Rebuild,
"a".to_owned(),
QueueSource::Manual,
String::new(),
None,
);
let s = q.take_next_fast().expect("stop running");
assert!(
q.take_next_build().is_none(),
"build waits while a's fast op is running"
);
q.finish(s.id, QueueState::Done, None);
assert!(
q.take_next_build().is_some(),
"build runs once the fast op is done"
);
}
}