refactor(#2815): the scheduler publishes pill edges, it doesn't own pills

mara on !2910: "transient guard as well - should be removable now?" — for
the queue path, yes.

`set_transient`'s own doc explained why the RAII guard existed: a
cancelled future must not leak an imperatively-set transient and pin the
dashboard on "rebuilding…" forever. That cannot happen to a derived set.
`running_transients()` is recomputed from the graph every loop, so a node
that stops running stops appearing — there is nothing to own and nothing
to leak.

So the scheduler no longer holds a guard per pill. It keeps the previous
derived value and publishes the transitions, which is the one thing a
derived read cannot express: the dashboard wants `TransientSet` /
`TransientCleared` edges, and the crash watcher wants the *moment* a pill
cleared, since its grace window is what stops an operator stop from
reading as a crash.

That also retires a hazard rather than restating it. The old code carried
a warning that stale guards had to be dropped before new ones were
created, because `TransientGuard::drop` clears by agent with no notion of
which label it was clearing — so a same-agent label change could clear
the pill it had just set. With no guards there is no ordering to get
wrong; clears are emitted before sets so a relabel reads as
clear-then-set rather than two overlapping pills.

`set_transient` / `clear_transient` become `pub(crate)`. The guard stays
for destroy and migration, which have no node behind them and where the
cancellation concern is real.

Checked with clippy (`--all-targets -D warnings`), `cargo test -p
hive-c0re -p hive-jobq` (322 + 41 passed) and `nix fmt`.
This commit is contained in:
atlas 2026-08-01 17:30:05 +02:00
commit 56202065d5
2 changed files with 57 additions and 36 deletions

View file

@ -1077,13 +1077,17 @@ impl Coordinator {
/// Mark an agent as in-progress (only one state per agent for now).
///
/// Private on purpose: the RAII [`TransientGuard`] (via
/// [`Coordinator::transient_guard`]) is the only door, so the paired
/// `clear_transient` always runs on drop even if the surrounding future
/// is cancelled (HTTP request aborted, runtime shutdown mid-rebuild,
/// panic). A bare set with no guaranteed clear would leak the transient
/// and leave the dashboard stuck in "rebuilding…" forever.
fn set_transient(&self, name: &str, label: String, deliberate_stop: bool) {
/// Two callers, for two different reasons:
/// - **Work with a queue node behind it** — the job-queue scheduler, which
/// publishes the edges of a *derived* set
/// ([`crate::job_queue::JobQueue::running_transients`]). It needs no guard:
/// nothing is owned, and a node that stops running stops appearing.
/// - **Work with no node** (destroy, migration) — via the RAII
/// [`TransientGuard`], so the paired `clear_transient` still runs if the
/// surrounding future is cancelled (HTTP request aborted, shutdown
/// mid-rebuild, panic). There a bare set really would leak the transient
/// and pin the dashboard on "rebuilding…" forever.
pub(crate) fn set_transient(&self, name: &str, label: String, deliberate_stop: bool) {
self.transient.lock().unwrap().insert(
name.to_owned(),
TransientState {
@ -1108,10 +1112,11 @@ impl Coordinator {
});
}
/// Clear an agent's transient state. Private: only reachable through
/// [`TransientGuard`]'s `Drop`, which guarantees it runs (see
/// [`Coordinator::set_transient`]).
fn clear_transient(&self, name: &str) {
/// Clear an agent's transient state. Reached either from
/// [`TransientGuard`]'s `Drop` (the no-node callers) or from the scheduler
/// when a derived pill stops being current — see
/// [`Coordinator::set_transient`].
pub(crate) fn clear_transient(&self, name: &str) {
let removed = self.transient.lock().unwrap().remove(name);
if let Some(state) = removed {
// Stamp the tombstone so the crash watcher can still see