.maestro/playbooks/2026-03-12-CM-Issues-PRs/2026-03-12-Issues-PRs-Triage/TRIAGE-02-Spinning-Logo-Fix.md
Created: 2026-03-12 7:55 PM PDT Branch: feat/factory-ai Status: Planned
The spinning logo has three independent failure modes that cause "weird" behavior:
Root cause: broadcastProcessingStatus() is reactive only — it fires on session events (prompt received, observation queued, session completed). There is no periodic heartbeat. If the final broadcastProcessingStatus() call computes isProcessing: true (because a message was briefly in processing state) but the message completes before the next broadcast, the UI never receives the isProcessing: false update.
Contributing factor: hasAnyPendingWork() (PendingMessageStore.ts:404) resets stuck messages >5 minutes old as a side-effect, but this method is only called when broadcastProcessingStatus() runs — which requires an event trigger. No events = no self-healing check.
Evidence: The stale session reaper runs every 2 minutes (worker-service.ts:476) but does NOT call broadcastProcessingStatus() after reaping. Even when stale sessions are cleaned up, the UI isn't notified.
Root cause: SSEBroadcaster.broadcast() (SSEBroadcaster.ts:58) calls client.write(data) with no try/catch. When a client's TCP connection dies silently (network change, laptop sleep), the Response object stays in the sseClients Set but writes fail silently. The dead client never receives status updates.
Contributing factor: No application-level heartbeat/ping. The HTTP Connection: keep-alive header (ViewerRoutes.ts:74) relies on TCP keepalive, which has OS-dependent timeouts (often 2+ hours on macOS).
Evidence: The 'close' event listener (SSEBroadcaster.ts:26-28) only fires when the client explicitly closes the connection — not when the network drops.
Root cause: useSpinningFavicon.ts:59 uses a fixed rotation increment per frame ((2 * Math.PI) / 90), assuming 60fps. Browsers throttle requestAnimationFrame to ~1fps on background tabs. Result: the favicon rotates 4° per second instead of 240°/s, appearing frozen or jerky.
Non-issue: The CSS .spinning animation on the header logomark is unaffected — CSS animations continue at normal speed even on background tabs.
| Method | File:Line | Purpose |
|---|---|---|
broadcastProcessingStatus() | worker-service.ts:872 | Computes and broadcasts isProcessing + queueDepth |
isAnySessionProcessing() | SessionManager.ts:431 | Delegates to PendingMessageStore.hasAnyPendingWork() |
hasAnyPendingWork() | PendingMessageStore.ts:404 | SQL check + 5-min stuck reset side-effect |
resetStaleProcessingMessages() | PendingMessageStore.ts:160 | Resets stuck messages (configurable threshold) |
claimNextMessage() | PendingMessageStore.ts:93 | Has 60-second self-healing for per-session stuck messages |
SSEBroadcaster.broadcast() | SSEBroadcaster.ts:45 | Writes to all clients, no error handling |
SSEBroadcaster.addClient() | SSEBroadcaster.ts:21 | Registers client, listens for close event |
useSpinningFavicon() | useSpinningFavicon.ts:7 | Canvas rAF animation, frame-count rotation |
useSSE() | useSSE.ts:8 | EventSource with 3s reconnect on error |
| Stale session reaper | worker-service.ts:476 | 2-minute interval, does NOT broadcast status |
What: Add a periodic broadcastProcessingStatus() call to self-correct stale spinner state.
In worker-service.ts, after the stale session reaper setup (~line 485), add a processing status heartbeat interval:
this.broadcastProcessingStatus()shutdown()worker-service.ts:476-485In the stale session reaper callback (~line 478), add this.broadcastProcessingStatus() after reaping completes — so UI updates immediately when stale sessions are cleaned.
grep -n 'broadcastProcessingStatus' src/services/worker-service.ts shows the new interval setupWhat: Add write error handling and application-level heartbeat to SSEBroadcaster.
In SSEBroadcaster.ts:broadcast() (~line 58), wrap client.write(data) in try/catch:
this.removeClient(client) to clean up dead connectionsIn SSEBroadcaster.ts:addClient() (~line 21), start a heartbeat:
:ping\n\n (SSE comment) every 30 secondsEventSource but keep the TCP connection aliveremoveClient()What: Switch from frame-count rotation to timestamp-based rotation.
useSpinningFavicon.ts, replace the animation loop (~lines 52-69):
startTime using performance.now() instead of rotationRef.current += incrementconst elapsed = performance.now() - startTime; const rotation = (elapsed / 1500) * 2 * Math.PI;What: Fix the StreamEvent interface gap.
src/ui/viewer/types.ts, add queueDepth?: number to the StreamEvent interface (it's already used at useSSE.ts:88 but not typed)npx tsc --noEmit passes with no errors related to queueDepthnpm test passes