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:
parent
99bd912ae0
commit
58ef299c3c
60 changed files with 153 additions and 1828 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue