fix(#3020): K3PT ST4T3 says what it shows, and stops reading the job graph

`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
This commit is contained in:
atlas 2026-08-03 17:55:45 +02:00 committed by mara
commit 6a8a729f58
5 changed files with 62 additions and 11 deletions

View file

@ -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` /

View file

@ -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' },

View file

@ -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);

View file

@ -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

View file

@ -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<String, Vec<crate::coordinator::TransientState>>,
) -> Vec<TombstoneView> {
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<Coordinator>) {
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,