Three fields — head.blockNumber, head.updatedAt and heartbeatFailures — were
read by statusSnapshot() and never assigned, and State::Degraded appeared only
in stateName(). The heartbeat was fire-and-forget with a comment pointing at a
pollHeartbeat() that does not exist, so nothing ever observed its outcome:
status().head stayed "" for the life of the process and a proxy whose sync had
died still reported "running".
CallSlot now carries a Kind, so the trampoline can tell a user call from a
heartbeat or a head probe. Three consecutive heartbeat failures degrade the
proxy and one success clears it; head is refreshed by a separate
eth_blockNumber probe every fifth beat, since eth_syncing answers a hardcoded
`false` and cannot report it. live() joins Running and Degraded for callers
making lifecycle decisions, leaving running() strict for health.
fetchFinalizedRoot() is new, and lives here rather than in the panel because
Basecamp sandboxes ui_qml plugins away from the network: an XMLHttpRequest from
a view is refused outright. It is a convenience, not a trust anchor, and says
so. Adds libcurl, used for that one request and nothing else.
Tests spin on the condition rather than sleeping a fixed interval — the first
draft was green on an idle machine and red under a parallel build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>