Chat click sometimes shows loading splash forever: state_push silently dropped in DEGRADED mode + phantom polling fallback
Summary
Sometimes clicking a chat in the sidebar shows the loading splash indefinitely — the chat never renders. The only recovery is clicking a different chat and back, which always works. The server actually delivers the snapshot, but the client silently discards it.
Reproduction
- Have a busy instance (many chat contexts, agent tasks running).
- Click a chat in the sidebar. Intermittently (more likely under load), the loading splash appears and never clears.
- Click another chat, then back to the first — it loads instantly.
Intermittent because the trigger is a timing race (see below): the handshake ACK must exceed 2 seconds.
Root cause
Two cooperating defects in webui/components/sync/sync-store.js:
Defect 1 — 2s handshake timeout is too aggressive for busy instances
response = await stateSocket.request("state_request", payload, { timeoutMs: 2000 });On a busy self-hosted instance (100+ contexts), building/ACKing the state handshake can exceed 2s. The client then transitions to DEGRADED mode (_setMode(SYNC_MODES.DEGRADED, "state_request failed")).
Defect 2 — all state_push events are dropped while DEGRADED
async _handlePush(envelope) {
if (this.mode === SYNC_MODES.DEGRADED) {
debug("[syncStore] ignoring state_push while DEGRADED");
return; // ← the snapshot containing the requested chat is discarded here
}
...The server sends the state_push containing the chat messages anyway, but the client drops it. The chat-loading splash only clears inside applySnapshot (finishChatLoading(snapshot.context) in webui/index.js), so it spins forever.
Clicking another chat calls sendStateRequest again — a fresh handshake that succeeds within 2s, mode returns to HEALTHY, pushes are processed again. That is why the "click away and back" workaround always fixes it.
Bonus inconsistency: the DEGRADED toast says "WebSocket connection problems - using polling fallback", but there is no polling fallback in the sync layer — /poll was removed in the websocket migration. The promised fallback does not exist, so DEGRADED mode does not degrade gracefully; it starves the UI.
Proposed fixes (both tested locally)
- Raise the handshake timeout so busy instances don't spuriously degrade:
response = await stateSocket.request("state_request", payload, { timeoutMs: 10000 });- Process pushes while DEGRADED instead of dropping them. Pushes are self-validating: the existing
runtime_epochmismatch, seq-gap, andlog_guidguards in_handlePushdetect divergence and trigger a proper resync — processing a stale push is safe and strictly better than starving the UI:
async _handlePush(envelope) {
if (this.mode === SYNC_MODES.DEGRADED) {
debug("[syncStore] state_push received while DEGRADED (processing)");
}
...Optionally also fix the misleading toast text (there is no polling fallback), or implement an actual poll fallback for DEGRADED mode.
Before/after
| Scenario | Before | After |
|---|---|---|
| Chat click, ACK < 2s | Loads | Loads (unchanged) |
| Chat click, ACK > 2s (busy server) | DEGRADED → snapshot dropped → splash forever | ACK allowed 10s; even if DEGRADED, snapshot is processed → chat loads |
| Stale push while DEGRADED | Dropped (UI starves) | Processed → guards trigger proper resync |
Environment
- Agent Zero: current Docker image (
agent0ai/agent-zero), self-hosted, ~100+ chat contexts - Reproduces in any Chromium browser; load-dependent
Source: agent0ai/agent-zero