Skip to content

XERK-444: merge /api/agents snapshots instead of clobbering live SSE patches - #617

Merged
xerhab merged 1 commit into
mainfrom
XERK-444
Sep 1, 2026
Merged

XERK-444: merge /api/agents snapshots instead of clobbering live SSE patches#617
xerhab merged 1 commit into
mainfrom
XERK-444

Conversation

@xerhab

@xerhab xerhab commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What

The dashboard (turma/public/index.html), usage (turma/public/usage.html), and sessions
(turma/public/sessions.html) pages each replaced their whole fleet cache with the body of an
/api/agents fetch (cache = await r.json()), while SSE (/api/events) patched individual
per-host records into that same cache via applyAgent. A fetch already in flight when a newer
per-agent SSE record arrived overwrote it with the older snapshot on the fetch's arrival —
showing stale per-host data (an old version, token count, session state). It self-heals on that
host's next heartbeat (~20s) but the window is unbounded for a host that has just gone quiet.

Why / the fix

Each page now:

  • keeps a monotonic sseClock, bumped by stampPatch(key) on every SSE mutation of
    cache.agents (inside applyAgent, and in the removed handler);
  • refresh() / refetchSoon() capture since = sseClock before the await fetch(...), and
    pass the reply through mergeSnapshot(body, since) instead of a wholesale replace;
  • mergeSnapshot takes membership and every non-agents key from the snapshot, but for any host
    patched after since keeps the live cached record. The ticket's named traps are handled: a host
    the snapshot legitimately dropped is not resurrected (membership stays the snapshot's); a key
    a same-window removed evicted is dropped, not re-added; a host that arrived via SSE mid-fetch
    and is absent from the older snapshot is carried over. On index/usage it re-filters retiredUsage
    against the merged live set (the XERK-338 returning-host double-count guard).

Android is unaffected: FleetRepository polls whole snapshots and never interleaves a partial SSE
patch with a full one (structurally immune) — no android/ change, not user-facing, no PARITY.md
line.

How verified

  • Unit tests — XERK-444 cases in dashboard-tiles.test.js, usage.test.js,
    sessions.test.js: in-flight no-clobber, snapshot membership, removed-mid-fetch no-resurrect.
    Mutation-verified against a naive wholesale replace (the no-clobber and no-resurrect tests fail
    under it). Full turma suite: 1695 pass / 1 skip / 0 fail.
  • Adversarial QA (PASS, first pass). Since this pod has no chromium/docker, QA extracted each
    shipped page's <script> and drove the real mergeSnapshot/applyAgent/refresh/
    refetchSoon/SSE-handler code through a hold-and-release fetch stub, reproducing the exact
    interleave the ticket describes (hold /api/agents, deliver a newer SSE patch while held, release
    the stale snapshot). All four falsifiable claims confirmed; 23/23 scenarios green; each protective
    behavior independently broken by a targeted mutation. The > (strict) comparison is load-bearing
    (mutating to >= pins a host to a stale SSE value forever — caught).
    • Not verified: the real-browser DOM/pointer layer of the <select>-guard re-arm (no
      browser in this pod; that layer is unchanged by this diff). Server/agent unchanged, not
      re-exercised.

Follow-up (out of scope — pre-existing, filed separately)

QA found the same clobber shape still affects cache.migrations (sessions) and cache.orgColors
(all three), which mergeSnapshot takes wholesale from the snapshot. Pre-existing (the old
cache = await r.json() clobbered everything), low-impact and self-healing (sessions polls once at
load while SSE is healthy; orgColors also lives in org.js's live map). Scoped out of this ticket
(per-host agent records) and tracked in a follow-up ticket.

…patches

The dashboard, usage, and sessions pages each replaced their fleet cache
wholesale with the body of an /api/agents fetch (cache = await r.json()),
while SSE patched individual per-host records into that same cache. A fetch
already in flight when a newer per-agent record arrived overwrote it with the
older snapshot on arrival. It self-heals on that host's next beat (~20s), but
the window is unbounded for a host that has just gone quiet.

Stamp every SSE mutation of cache.agents with a monotonic sseClock (applyAgent
and the removed handler); refresh()/refetchSoon() capture it before the await
and mergeSnapshot() keeps any record a live patch has touched since, taking
membership and every other key from the snapshot otherwise. A naive per-key
merge would resurrect a host the snapshot legitimately dropped, so membership
stays the snapshot's and a key a same-window removed evicted is dropped, not
re-added; a host that arrived via SSE mid-fetch and is absent from the older
snapshot is carried over. retiredUsage is re-filtered against the merged live
set so the merge can't reintroduce a returning host's spend as retired.

Android is unaffected: it polls whole snapshots and never interleaves a partial
patch with a full one.

Tests: XERK-444 cases in dashboard-tiles.test.js, usage.test.js,
sessions.test.js (in-flight no-clobber, snapshot membership, removed-mid-fetch
no-resurrect); mutation-verified against a naive wholesale replace.
@xerhab
xerhab merged commit 11df865 into main Sep 1, 2026
5 checks passed
@xerhab
xerhab deleted the XERK-444 branch September 1, 2026 19:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant