Watch
0
0
Fork
You've already forked hyperhive
0

hive-c0re: remove the approvals subsystem

Nothing submits an approval since scheduled prompts left hive-c0re, so the
queue only acted on rows written before that.

- hive-c0re: the approvals store and its migrations, approve/deny/
  resolve actions, the ResolveApproval tail node (and meta_update's
  approval_id), POST /api/approve and /api/deny, the approval views on
  /api/state, the approval_resolved dashboard event, the orphan GC,
  destroy/purge failing pending rows, and the approval rows in
  get_loose_ends. DestroyBookkeeping loses the purge flag, which only
  worded the approval-failure note.
- The CancelLooseEnd request goes: its only hive-c0re arm was the
  approval one. cancel_loose_end handles reminder and todo in the harness.
- Wire types: hive_sh4re::approvals (ReminderStats moves to inbox),
  LooseEnd::Approval, CancelLooseEndKind, HelperEvent::ApprovalResolved,
  ToolGroup::Approvals, HostRequest::{Pending,Approve,Deny} and
  HostResponse.approvals. HOST_SOCK_VERSION goes to 2 for the removed
  field.
- hivectl loses the approvals verbs; docs/tools/hivectl-cli.md is
  regenerated.
- The system prompt drops approval_resolved.
- The approvals table stays in broker.sqlite, unread, the same way the
  operator_questions table was left when its store was removed.
- An `approvals` entry still in tool-groups.json is skipped with a warning
  by ToolGroup::parse_list, like any unknown group.
- docs/agent-lifecycle/approvals.md is renamed to config-changes.md so the
  code's doc pointers resolve; its content follows in the docs commit.
This commit is contained in:
atlas 2026-10-04 23:55:03 +02:00
commit 58ef299c3c
60 changed files with 153 additions and 1828 deletions

View file

@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex, OnceLock};
use anyhow::{Context as _, Result};
use hive_jobq::{NodeId, TerminalState};
use hive_jobq::NodeId;
use super::model::NodeKind;
use crate::coordinator::Coordinator;
@ -79,8 +79,8 @@ pub(super) async fn run_node(
run_purge_state(agent).await;
Ok(())
}
NodeKind::DestroyBookkeeping { purge, .. } => {
run_destroy_bookkeeping(coord, agent, *purge).await;
NodeKind::DestroyBookkeeping { .. } => {
run_destroy_bookkeeping(coord, agent).await;
Ok(())
}
NodeKind::MetaLock {
@ -121,13 +121,6 @@ pub(super) async fn run_node(
// takes it directly instead of re-matching the kind behind a `bail!`
// that could never fire.
NodeKind::WritePermFile { payload, .. } => run_write_perm_file(coord, agent, payload).await,
NodeKind::ResolveApproval {
approval_id,
outcome,
} => {
run_resolve_approval(coord, coord.job_queue.root_of(id), *approval_id, *outcome);
Ok(())
}
NodeKind::EmitRebuilt { ok, .. } => {
run_emit_rebuilt(coord, agent, coord.job_queue.root_of(id), *ok).await;
Ok(())
@ -266,25 +259,6 @@ async fn run_wanted_pull(coord: &Arc<Coordinator>) -> Result<()> {
crate::workers::wanted::pull(coord).await
}
/// Resolve the DAG's approval row the way this node's own `outcome` says.
///
/// Nothing is inspected: a template emits one of these per outcome, each edged to
/// accept only that one, so *which* node the scheduler let run already is the
/// answer. Best-effort — a resolution failure is logged inside
/// [`crate::actions::resolve_approval_dag`], never surfaced as a node failure,
/// since the work already happened and failing the tail would only misreport it.
fn run_resolve_approval(
coord: &Arc<Coordinator>,
dag_id: Option<u64>,
approval_id: i64,
outcome: TerminalState,
) {
let reason = (outcome == TerminalState::Failed)
.then(|| dag_id.and_then(|dag| coord.job_queue.first_error(dag)))
.flatten();
crate::actions::resolve_approval_dag(coord, approval_id, outcome, reason.as_deref());
}
/// Rerender the meta flake from whatever containers still exist on disk.
/// Idempotent — a no-op when nothing changed. Lives here because the destroy
/// tail is its only caller; it moved with `destroy` when that became a DAG.
@ -349,23 +323,13 @@ async fn run_purge_state(agent: &str) {
/// none of this is undoable — a failed meta sync or power-store write is a
/// bookkeeping drift to log, not a reason to red a DAG whose container is
/// already gone.
async fn run_destroy_bookkeeping(coord: &Arc<Coordinator>, agent: &str, purge: bool) {
async fn run_destroy_bookkeeping(coord: &Arc<Coordinator>, agent: &str) {
// Meta flake: drop the agent's input + nixosConfiguration so a future spawn
// under the same name re-seeds cleanly, and so the meta lock doesn't
// reference a vanished applied repo.
if let Err(e) = sync_meta_after_lifecycle(coord).await {
tracing::warn!(error = ?e, %agent, "meta sync after destroy failed");
}
if let Err(e) = coord.approvals.fail_pending_for_agent(
agent,
if purge {
"agent purged"
} else {
"agent destroyed"
},
) {
tracing::warn!(%agent, error = ?e, "approvals: failing pending on destroy failed");
}
// Drop the durable power intent — a future agent of the same name seeds
// fresh from its observed state.
if let Err(e) = coord.power.remove(agent) {

View file

@ -15,11 +15,10 @@
//! parent axis (a root's rolled-up state *is* its subtree's), so membership is
//! a graph walk with no host-side side-tables. The lease is owned by a subtree
//! root and borrowed by its descendants (continuity);
//! - terminal work is an ordinary **tail node**
//! ([`NodeKind::ResolveApproval`] / [`NodeKind::EmitRebuilt`]) that the builder
//! appends in [`templates`], edged onto the job's group roots by the outcome it
//! reports. Templates emit one tail per outcome and the graph runs exactly one,
//! so nothing branches at runtime.
//! - terminal work is an ordinary **tail node** ([`NodeKind::EmitRebuilt`])
//! that the builder appends in [`templates`], edged onto the job's group
//! roots by the outcome it reports. Templates emit one tail per outcome and
//! the graph runs exactly one, so nothing branches at runtime.
//!
//! The queue is runtime-only (no persistence): an empty graph on boot; desired
//! state is re-derived by the reconcile sweep. A single scheduler task
@ -44,7 +43,6 @@ use hive_jobq::{Graph, NodeId};
use hive_jobq_wire::{GraphNode, GraphWire};
use tokio::sync::Notify;
pub use hive_jobq::TerminalState;
pub use model::{NodeKind, PermPayload, State};
use resource::Resource;
@ -222,9 +220,7 @@ impl JobQueue {
///
/// **Nodes that explicitly observe cancellation are spared** — a node whose
/// edge names [`hive_jobq::TerminalState::Cancelled`] is asking to run when
/// the work it follows was dropped, which is exactly what an approval tail
/// needs: cancel the work, and the tail still fires to resolve the approval
/// row rather than leaving it dangling forever.
/// the work it follows was dropped, so it stays pending and runs.
///
/// Nothing is special-cased by node kind. `AFTER_ANY` deliberately does *not*
/// accept `Cancelled`, so an ordinary weak-edged step (rebuild's `Reconcile`,

View file

@ -15,8 +15,6 @@
pub use hive_host_sock::jobs::{PermPayload, State};
use serde::Serialize;
use hive_jobq::TerminalState;
/// The primitive operations — each kind maps to one executor fn in
/// `exec.rs`, a thin wrapper over existing `lifecycle.rs` / `meta.rs`
/// code. Concurrency is gated by two resource classes (see
@ -61,7 +59,7 @@ pub enum NodeKind {
/// `remove_dir_all`) plus its state and applied dirs.
PurgeState { agent: String },
/// The post-destroy bookkeeping tail.
DestroyBookkeeping { agent: String, purge: bool },
DestroyBookkeeping { agent: String },
/// Meta flake lock bump. `sweep = false`: `meta::lock_update` with this
/// node's own `inputs`; `sweep = true`: `meta::lock_update_hyperhive`
/// (boot sweep, `inputs` empty). See _Notable collapses_ for how the
@ -105,17 +103,6 @@ pub enum NodeKind {
/// Group root of a rebuild subtree — the **brace**, scoped to one
/// agent. Why braces exist and what they cost: _Braces_.
AgentWindow { agent: String },
/// Tail node of an approval-carrying DAG (a meta-inputs update): resolve
/// the approval row from how the work ended.
ResolveApproval {
approval_id: i64,
/// Which outcome this node reports. A template emits **one per
/// outcome**, each edged to accept only that one, so exactly one is
/// ever runnable. The `Cancelled` one is also the node
/// [`super::JobQueue::cancel`] spares, since its edge is the only
/// one that accepts a dropped dependency.
outcome: TerminalState,
},
/// Tail node of a rebuild / perm-change: emit this agent's `Rebuilt`
/// manager event. One node per agent *and* per outcome; no cancel
/// variant (a cancelled DAG never ran, so there is no rebuild to
@ -175,8 +162,8 @@ impl hive_jobq_wire::WireNode for NodeKind {
impl NodeKind {
/// The agent this node targets, or `""` for agentless kinds
/// ([`NodeKind::MetaLock`] on the `hyperhive` pseudo-agent,
/// [`NodeKind::ResolveApproval`] which acts on an approval row).
/// ([`NodeKind::MetaLock`] on the `hyperhive` pseudo-agent, and the
/// swarm-wide sweeps and pulls).
#[must_use]
pub fn agent(&self) -> &str {
match self {
@ -203,7 +190,6 @@ impl NodeKind {
| NodeKind::EmitRebuilt { agent, .. }
| NodeKind::SetWanted { agent, .. } => agent,
NodeKind::MetaLock { .. }
| NodeKind::ResolveApproval { .. }
| NodeKind::ForgeSweep
| NodeKind::MatrixSweep
| NodeKind::KnowledgePull

View file

@ -10,10 +10,9 @@
//! label tracks the DAG's progress (signal → swap → reconcile) instead of
//! repeating one intent the template declared before any of it started.
//!
//! Per-DAG terminal work (approval resolution, `Rebuilt`) is not drained here:
//! it runs as the DAG's focused terminal node (`ResolveApproval` /
//! `EmitRebuilt`), dispatched through `exec::run_node` like any other node once
//! the DAG settles.
//! Per-DAG terminal work (`Rebuilt`) is not drained here: it runs as the DAG's
//! focused terminal node (`EmitRebuilt`), dispatched through `exec::run_node`
//! like any other node once the DAG settles.
//!
//! In-DAG growth (a `MetaLock` growing rebuild subgraphs, a `Reconcile` fanning
//! its `Start`/`Stop`) is declared onto the builder each node is handed, and
@ -55,7 +54,7 @@ type TransientSeen = HashMap<(String, String), bool>;
/// `nixos-container update`, which is itself declarative). That's a property
/// of the node set, not of the queue — it holds today but isn't enforced by
/// the type system, so it's worth re-checking whenever a new `NodeKind` is
/// added. The side-effect tails (`EmitRebuilt`, `ResolveApproval`) sit
/// added. The side-effect tail (`EmitRebuilt`) sits
/// closest to the edge: dropping or re-running one affects something outside
/// the graph (today, a missed or duplicated notification) rather than
/// reconverging silently.

View file

@ -20,8 +20,6 @@
//! `lifecycle::is_running` read), so [`super::power`] assembles them out of
//! the primitives this module exports ([`rebuild_nodes`]).
use hive_jobq::TerminalState;
use super::model::{NodeKind, PermPayload};
use super::resource::Resource;
use super::{Handle, JobBuilder};
@ -57,26 +55,6 @@ fn emit_rebuilt_tails(builder: &JobBuilder, agent: &str, roots: &[Handle<'_>]) {
);
}
/// The approval-resolving tails for an approval-carrying DAG: one per outcome of
/// the DAG's single group-root `root`, each accepting only its own.
///
/// The `Cancelled` node is what keeps a dropped approval DAG from dangling its
/// row forever — its edge is the only one [`super::JobQueue::cancel`] spares.
fn resolve_approval_tails(builder: &JobBuilder, approval_id: i64, root: Handle<'_>) {
for outcome in [
TerminalState::Done,
TerminalState::Failed,
TerminalState::Cancelled,
] {
let _ = builder
.node(NodeKind::ResolveApproval {
approval_id,
outcome,
})
.on_outcome(root, &[outcome]);
}
}
/// Declare one rebuild subgraph per agent onto the builder a running
/// [`NodeKind::MetaLock`] was handed.
///
@ -349,9 +327,6 @@ pub fn rebuild(builder: &JobBuilder, agent: &str, relock: bool) -> Vec<hive_jobq
/// borrow it. A failure cancel-cascades the rest — unlike rebuild there's no
/// recovery-reconcile (nothing to converge if the container was never created).
///
/// No approval tail: the operator authorised the creation at swarm level, and
/// the deploy request carries that authorisation.
///
/// Returns the group root so a caller can wait on the whole subtree.
pub fn first_deploy(builder: &JobBuilder, agent: &str) -> Vec<hive_jobq::NodeGuid> {
let a = || agent.to_owned();
@ -423,7 +398,7 @@ pub fn destroy(builder: &JobBuilder, agent: &str, purge: bool) {
destroy
};
let _tail = builder
.node(NodeKind::DestroyBookkeeping { agent: a(), purge })
.node(NodeKind::DestroyBookkeeping { agent: a() })
.part_of(stop)
.after_ok(last);
}
@ -458,8 +433,8 @@ pub fn perm_change(builder: &JobBuilder, agent: &str, payload: PermPayload) {
/// so the "hyperhive" pseudo-agent gets no pill), giving each cascade agent
/// crash-watch suppression during its `Swap` — the property the old child
/// `Rebuild` DAGs carried via their own transient.
pub fn meta_update(builder: &JobBuilder, inputs: Vec<String>, approval_id: Option<i64>) {
let lock = builder
pub fn meta_update(builder: &JobBuilder, inputs: Vec<String>) {
let _lock = builder
.node(NodeKind::MetaLock {
sweep: false,
fanout: None,
@ -467,14 +442,6 @@ pub fn meta_update(builder: &JobBuilder, inputs: Vec<String>, approval_id: Optio
})
.needs(Resource::BuildSlot)
.needs(Resource::MetaWindow);
// The bump itself has no side effect, so an operator-driven one ends
// at the `MetaLock`; an approval-driven one still has its row to
// resolve and gets the per-outcome tails edged onto that single
// group-root — whose roll-up covers the rebuild subgraphs `MetaLock`
// grows into itself.
if let Some(approval_id) = approval_id {
resolve_approval_tails(builder, approval_id, lock);
}
}
// The boot is assembled inline in `workers/auto_update.rs::submit_boot_tree`

View file

@ -16,6 +16,7 @@
use super::model::NodeKind;
use super::*;
use hive_jobq::TerminalState;
/// Insert a declared job, naming nothing — the shape assertions read the whole
/// graph. A test that needs a handle calls `q.insert` directly and names the
@ -101,10 +102,9 @@ struct Declared {
/// Render a dep's outcome set as the outcomes it actually accepts.
///
/// ⚠️ Spelled out rather than bucketed into `ok` / `any` / other. The first
/// version of this did bucket, and a template's three `ResolveApproval` tails —
/// which differ ONLY in their accepted outcomes — all rendered as `"other"`.
/// A helper that prints two structurally different nodes identically turns an
/// ⚠️ Spelled out rather than bucketed into `ok` / `any` / other: tails that
/// differ ONLY in their accepted outcomes would render identically, and a
/// helper that prints two structurally different nodes identically turns an
/// assertion into a tautology.
fn when_tag(when: hive_jobq::DepWhen) -> String {
[
@ -194,19 +194,6 @@ fn pending_kinds_for(q: &JobQueue, agent: &str) -> Vec<&'static str> {
pending_kinds_filtered(q, &|kind: &NodeKind| kind.agent() == agent)
}
/// The payloads of every node still `Pending`, for the cases where *which* of a
/// family of same-kind nodes survived is the assertion — a template emits one
/// tail per outcome and they differ only in what they carry.
fn pending_payloads(q: &JobQueue) -> Vec<NodeKind> {
let sched = q.sched().lock().expect("job_queue mutex poisoned");
let graph = sched.graph();
graph
.nodes()
.filter(|n| n.state == State::Pending)
.map(|n| n.payload.clone())
.collect()
}
fn pending_kinds_filtered(q: &JobQueue, keep: &dyn Fn(&NodeKind) -> bool) -> Vec<&'static str> {
let sched = q.sched().lock().expect("job_queue mutex poisoned");
let graph = sched.graph();
@ -1189,43 +1176,6 @@ fn cancelled_power_op_runs_no_compensating_node() {
}
}
// ---- terminal reporting + lease release ----
/// A DAG cancelled while fully queued must still **run its tail**, or a queued
/// approval DAG cancelled by the operator would dangle its approval forever.
///
/// This is the load-bearing case for sparing tails in [`JobQueue::cancel`]: the
/// work nodes all cancel, but `ResolveApproval` is weak-edged, so a `Cancelled`
/// dep satisfies its edge and it becomes claimable instead of being cancelled
/// along with everything else. It reads `Cancelled` off its own deps and resolves
/// the approval as "cancelled before completion".
#[test]
fn cancelled_dag_still_runs_its_approval_tail() {
let q = JobQueue::new(1);
insert(&q, |builder| {
templates::meta_update(builder, Vec::new(), Some(7));
});
// Found by kind, not returned: `meta_update` names nothing, because the
// approval row is how an operator follows it.
let lock = node_of(&q, "meta_lock").get();
assert!(q.cancel(lock), "fully-queued dag cancels");
// The `Cancelled` tail is the only node whose edge accepts a dropped
// dependency, so it is the only one `cancel` spares — and *which* tail
// survives names how the approval row is about to be resolved.
let spared = pending_payloads(&q);
assert!(
matches!(
spared.as_slice(),
[NodeKind::ResolveApproval {
approval_id: 7,
outcome: TerminalState::Cancelled
}]
),
"only the cancelled-outcome tail is spared, got {spared:?}"
);
assert_eq!(state_of(&q, lock), State::Cancelled);
}
// ---- history ----
//
// The node → build-log link is no longer queue state: the log row carries