From 6a8a729f58cc62d01a9d9cb2b1941fcd7ff9970f Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 3 Aug 2026 17:55:45 +0200 Subject: [PATCH] fix(#3020): K3PT ST4T3 says what it shows, and stops reading the job graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build_tombstone_views` folded `transient_snapshot`'s keys into its `live` set, so an agent with in-flight transient work was treated as not-a-tombstone. Since #3010 that set is derived from the running job graph, which made a page about on-disk state a function of the scheduler. Dropping the filter exposes what was always true underneath: nothing records a destroy. Every definition-side artifact — state subvolume, proposed + applied repos, `deployed/0`, meta registration, topology entry — is written by `Provision` before the container exists and survives `lifecycle::destroy`. So a mid-spawn agent is byte-identical on disk to a tombstone. Per mara on #3020: remove the filter, warn on the page, keep the issue open for the swarm-controller / snapshot-storage rework where the problem shape changes anyway. - dashboard/tombstones.rs: drop the param + the chain; document the real semantics - core.js: amber caveat banner above the rows; row badge `destroyed` -> `offline`, which is what an absent container actually proves - dashboard.css: `.tombstone-warn`, modelled on `.port-conflict` but amber and without the pulse — a permanent banner that pulses trains you to ignore it - docs/web-ui/dashboard.md: the pane was described as "destroyed-but-state-kept agents", now the exact wrong claim --- docs/web-ui/dashboard.md | 13 ++++++++- frontend/packages/dashboard/src/core.js | 16 ++++++++++- frontend/packages/dashboard/src/dashboard.css | 15 +++++++++++ hive-c0re/src/dashboard/state_snapshot.rs | 2 +- hive-c0re/src/dashboard/tombstones.rs | 27 +++++++++++++------ 5 files changed, 62 insertions(+), 11 deletions(-) diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 3fb1067d..53786013 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -136,11 +136,22 @@ page — see below.) **K3PT ST4T3** — two sub-sections on one pane: -*Tombstones*: destroyed-but-state-kept agents (size + age + +*Tombstones*: agents with kept state and **no container** (size + age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a Spawn approval; existing state is reused), `PURG3` (wipes state + applied dirs; `POST /api/purge-tombstone/{name}`). +⚠️ **Not only *destroyed* agents.** Nothing records a destroy: every +definition-side artifact (state subvolume, proposed + applied repos, +meta registration, topology entry) is written by `Provision` *before* +the container exists and survives `lifecycle::destroy`. So an agent +part-way through a spawn is byte-identical on disk to a tombstone, and +both are listed. The pane carries a standing warning to that effect, and +the row badge says `offline` rather than `destroyed` — the absence of a +container is all the backend can actually prove. Fixing it properly +needs a recorded destroy: **#3020**, deferred to the swarm-controller / +snapshot-storage rework where the problem changes shape. + *Stale permission entries*: agents with explicit capability or tool-group JSON entries but no live container — typically renamed or deleted agents whose entries persisted in `capabilities.json` / diff --git a/frontend/packages/dashboard/src/core.js b/frontend/packages/dashboard/src/core.js index 3a454aca..3f056d6c 100644 --- a/frontend/packages/dashboard/src/core.js +++ b/frontend/packages/dashboard/src/core.js @@ -46,13 +46,27 @@ function renderTombstones(s) { if (d === 1) return '1 day ago'; return d + ' days ago'; }; + // Only shown alongside actual rows — a caveat over an empty list is noise. + // Wording is deliberately about what the list *is* rather than what it + // isn't: nothing records a destroy, so "container absent" is the only thing + // the backend can actually tell. + const warn = el('p', { class: 'tombstone-warn' }); + warn.append( + el('strong', {}, 'shows every agent whose container is absent'), + ' — not only destroyed ones. An agent part-way through being spawned ' + + 'looks identical here, because nothing records a destroy. Check it is ' + + 'really gone before you PURG3.', + ); + root.append(warn); const ul = el('ul', { class: 'containers' }); for (const t of s.tombstones) { const li = el('li', { class: 'container-row tombstone' }); const head = el('div', { class: 'head' }); head.append( el('span', { class: 'name' }, t.name), - el('span', { class: 'badge badge-muted' }, 'destroyed'), + // Was `destroyed`, which the backend cannot actually know — see the + // caveat above. `offline` is what the absence of a container proves. + el('span', { class: 'badge badge-muted' }, 'offline'), ); if (t.has_creds) head.append(el('span', { class: 'badge badge-muted' }, 'creds kept')); head.append(el('span', { class: 'meta' }, diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index bb2a59ca..4bfa11c7 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -444,6 +444,21 @@ hive-agent-menu { } .container-row.tombstone .name { color: var(--muted); } +/* K3PT ST4T3 caveat. Amber, not red like `.port-conflict`, and no pulse: this + is a standing "read the list this way" note, not an incident — a pulsing + banner that is always present just teaches you to stop seeing it. + Nothing records a destroy, so this list is every agent whose container is + absent — which includes one being spawned right now. */ +.tombstone-warn { + background: color-mix(in srgb, var(--amber) 8%, transparent); + border: 1px solid var(--amber); + color: var(--amber); + padding: 0.5em 0.8em; + margin-bottom: 0.6em; + border-radius: 4px; +} +.tombstone-warn strong { color: var(--amber); } + /* Notification controls — sit between the banner and the containers. */ .port-conflict { background: color-mix(in srgb, var(--red) 8%, transparent); diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index 96e9acab..78d8f822 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -375,7 +375,7 @@ pub(super) async fn api_state( .into_iter() .map(history_view) .collect(); - let tombstones = build_tombstone_views(&state.coord, &containers, &transient_snapshot); + let tombstones = build_tombstone_views(&state.coord, &containers); let port_conflicts = build_port_conflicts(&containers); // Both operator-targeted and peer threads surface on the dashboard diff --git a/hive-c0re/src/dashboard/tombstones.rs b/hive-c0re/src/dashboard/tombstones.rs index cb54d1ba..b8c6c605 100644 --- a/hive-c0re/src/dashboard/tombstones.rs +++ b/hive-c0re/src/dashboard/tombstones.rs @@ -32,17 +32,29 @@ pub struct TombstoneView { /// State-dir names that don't appear in the live container list. Each /// one surfaces in the dashboard as a row with R3V1V3 + PURG3 actions. +/// +/// ⚠️ **This lists every agent whose container is absent, not only destroyed +/// ones** — a mid-spawn agent (state dir seeded by `Provision`, container not +/// yet made by `Create`) is indistinguishable from a tombstone here, because +/// *nothing records a destroy*. Every definition-side artifact — state +/// subvolume, proposed + applied repos, `deployed/0`, meta registration, +/// topology entry — is written before the container exists and survives +/// `lifecycle::destroy`, which removes the container and leaves the rest. +/// +/// This used to be papered over by treating agents with in-flight transient +/// work as live. That made the page's contents a function of the **job +/// graph** (`transient_snapshot` is derived from running nodes), which is the +/// wrong dependency for "what state is on disk" — so the filter is gone and +/// the page says what it actually shows. The real fix is to record the +/// destroy rather than infer it from an absence, deferred to the +/// swarm-controller / snapshot-storage rework where this changes shape anyway. pub(super) fn build_tombstone_views( coord: &Coordinator, containers: &[ContainerView], - transient_snapshot: &std::collections::HashMap>, ) -> Vec { let _ = coord; // kept_state_names is a free fn but takes &self by future plan - let live: std::collections::HashSet<&str> = containers - .iter() - .map(|c| c.name.as_str()) - .chain(transient_snapshot.keys().map(String::as_str)) - .collect(); + let live: std::collections::HashSet<&str> = + containers.iter().map(|c| c.name.as_str()).collect(); Coordinator::kept_state_names() .into_iter() .filter(|name| !live.contains(name.as_str())) @@ -96,8 +108,7 @@ fn dir_size_bytes(root: &Path) -> u64 { /// is tiny. pub(crate) async fn emit_tombstones_snapshot(coord: &Arc) { let containers = coord.containers_snapshot().await; - let transient_snapshot = coord.transient_snapshot(); - let tombstones = build_tombstone_views(coord, &containers, &transient_snapshot); + let tombstones = build_tombstone_views(coord, &containers); coord.emit_dashboard_event(crate::dashboard_events::DashboardEvent::TombstonesChanged { seq: coord.next_seq(), tombstones,