Files
6krrt/plans/tui-live-routing-panel-review.md
adlee-was-taken 3523dcf93e docs(plans): give every plan a Status line so the queue is greppable
plans/ held 58 documents and exactly one said whether it was open. The rest
mixed finished work, reviews of shipped work, parked specs and genuinely
pending ones, with nothing distinguishing them, so "how many plans are in
the queue" had no answer short of reading all 58.

Now `grep -H '^Status:' plans/*.md` is the answer:

    50 done   3 in progress   2 planned   2 reference   1 parked

Statuses were derived rather than guessed: CLAUDE.md's own built list and
"What's NOT built yet" section, plus checking the subject exists in the
code. A review of work that shipped counts as done -- it records what was
found, it is not a request for anything. `reference` separates the two docs
that are conventions rather than work items (admin-design-standards,
admin-work-framework), which otherwise read as permanently-open plans.

The vocabulary is deliberately five words. A larger one invites "mostly
done" and "blocked-ish", which is how the directory became unreadable.

test_plans_declare_status.py keeps it from rotting: a new plan without a
marker fails, as does an unknown status, one buried below the eighth line,
or an open status with no reason -- "planned" alone is the state that rots,
since nobody can tell later whether it waits on a decision, a dependency,
or just nobody's turn.

Also updates the sweep plan with what landed and what did not, including
that #9 was not a defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRQXz5SYZYVWscxS1QqF6U
2026-09-08 18:55:16 -04:00

11 KiB

Review: live routing-decisions panel (events.py, tui_sse.py, SSE endpoint)

Status: done -- review of shipped work

Scope. Commits f0ebd83 ("feat(tui): live routing-decisions panel with SSE, detail popup, breakdown") and 765a4a6 ("docs: sweep for live routing panel + AGENTS.md"), i.e. everything since the last-reviewed commit 5f7716e. 13 files, +1188/-130: new events.py (in-memory decision broker), new tui_sse.py (background SSE consumer thread), a new GET /events/decisions endpoint in dispatcher.py, and the corresponding tui.py/tui_model.py/tui_screens.py wiring. Full suite: 562/562 passing.

Run via /code-review high --since 5f7716e (forked, finder-angle + verify phases), then independently re-derived and confirmed against the actual files rather than trusted as-is — every finding below was re-read against the committed source at the cited file/line before being included.

Findings

1. events.py's subscriber set is mutated from multiple threads with no lock — events.py:24-41,61,67

FastAPI runs its sync def endpoints (/dispatch, /route, /v1/chat/completions, and the new /events/decisions) in a thread pool. _subscribers is a plain set(). publish_decision iterates it directly (for subscriber in _subscribers:, line 36) while subscribe()/unsubscribe() mutate it with .add()/.discard() from whatever thread is serving a concurrent dashboard connect/disconnect. A decision recorded at the same instant a dashboard connects or drops raises RuntimeError: Set changed size during iteration. Confirmed by reading — this is a plain, unguarded set, no threading.Lock anywhere in the file.

Where it lands matters: raised inside persist_route_decision it's caught by that function's broad except Exception and silently swallowed — the live fan-out for that one decision is just dropped, no crash, no log. Raised inside events.subscribe() at the top of _decision_event_stream (dispatcher.py:1312), before that generator's own try/finally, it's unhandled and can break a new dashboard connection outright.

Directly contradicts the module's own docstring ("thread-safe fan-out to SSE subscribers" — CLAUDE.md, and events.py:1-12 describes the same intent without ever establishing it).

2. A subscriber dropped for a full queue is never told, so its SSE stream idles forever — events.py:39-41 + dispatcher.py:1317-1323

When a dashboard falls behind and its 100-slot queue fills, publish_decision silently evicts it from _subscribers (no close, no sentinel). The matching _decision_event_stream generator has no idea — it keeps calling subscriber.get(timeout=SSE_HEARTBEAT_SECONDS) on the now-orphaned queue, which can only ever time out, so it emits :heartbeat forever (dispatcher.py:1317-1323). The HTTP connection never errors, so tui_sse.DecisionStream's reconnect loop never fires. The dashboard looks alive — table renders, connection stays open — but silently stops receiving any new decision until the process is restarted. Confirmed by reading both sides of the queue handoff.

3. Live decisions are appended with no id-based dedup, so every reconnect (and the very first connect) duplicates rows already shown — tui.py:234-249

_decision_event_stream always replays the ring buffer on connect (events.subscribe(replay=True), dispatcher.py:1312), and tui_sse.DecisionStream.run() reconnects automatically 5s after any transient error (tui_sse.py:45-61). _handle_live_decision (tui.py:238) unconditionally insert(0, ...)s every decision it receives into recent_decisions with no check against ids already present. Since the initial /metrics poll on startup already populates the same recent decisions, the very first SSE replay duplicates them immediately; every later reconnect duplicates again. build_category_breakdown runs over this same list (line 246), so the per-category counts and "majority model" in the breakdown panel skew from replay noise, not real traffic. Confirmed by reading — no id set or seen-check anywhere in _handle_live_decision or decision_row.

4. tui_sse.py's reconnect loop only catches network/parse errors, logs nothing, and can be permanently killed by its own callback — tui_sse.py:45-61

self.callback(...) (line 56) is App.call_from_thread, which can raise once the Textual app's event loop is gone — e.g. during shutdown, since stop() (line 63) only sets an Event and does not interrupt a blocking iter_lines() read, so the thread can still be mid-callback for up to STREAM_TIMEOUT seconds after on_unmount calls stop(). That's not a requests.RequestException or ValueError, so it isn't caught by the except on line 57 — it propagates out of run() and ends the thread for good, no further reconnect attempts, ever.

Separately, even the errors that are caught are swallowed with a bare pass (line 60) — no logs.warning(...) call, unlike the identical "this must never raise" pattern used elsewhere in this codebase (e.g. persist_route_decision's except block, which does log). An ordinary dropped VPN tunnel or router restart produces zero diagnostic trace here.

5. /events/decisions is a sync endpoint that blocks in the shared threadpool for the life of each SSE connection — dispatcher.py:1328-1329

Confirmed: def events_decisions():, not async def. FastAPI runs sync endpoints in its bounded default threadpool, the same pool serving /dispatch, /route, and /v1/chat/completions. A handful of connected dashboards, or DecisionStream's reconnect loop flapping through repeated transient failures (finding #4 makes that worse — a dead thread means a textual restart reconnects from scratch, briefly doubling in-flight connections), can hold enough concurrent long-lived streams to exhaust the pool and stall real routing/dispatch requests behind idle SSE connections.

6. DecisionDetailScreen.on_key risked a double-dismiss on Enter — tui_screens.py:65 (as committed in 765a4a6)

As committed: if event.key in ("escape", "q", "enter"): self.dismiss(None) with no event.stop(). When the Close button has focus and Enter is pressed, Textual delivers the key to the focused Button first; Button has no key handler of its own, so the event bubbles unstopped to this on_key, which dismisses — but the event can also continue bubbling to the App's binding resolution and match Button.BINDINGS's own enter binding, firing action_press() → Button.Pressed → on_button_pressed → a second self.dismiss(None) on an already-popped modal. Confirmed against the exact committed line via git show 765a4a6.

Already independently fixed in the working tree, uncommitted, as of this review — git diff -- tui_screens.py shows on_key narrowed to if event.key == "escape": only, dropping "enter"/"q" handling from this method entirely (Enter now only ever reaches Button's own binding, q's docstring/label mention removed too). This resolves the race by construction rather than by adding event.stop(). No action needed here — noting it so the fix isn't lost if the working tree changes again before it's committed.

7. Two independent datetime.now() calls give the SSE payload a different observed_at than the persisted row for the same decision — dispatcher.py:923 vs dispatcher.py:954

The INSERT is stamped at line 923; events.publish_decision(...)'s payload is stamped by a second, separate call at line 954, several statements later and after conn.commit(). A dashboard that correlates the live SSE event for decision id=N against the same row fetched later via /metrics sees observed_at differ by the insert/commit latency — small in practice, but it breaks the assumption (implicit in the code's own comment at line 949-950, "the row id becomes the ordering handle") that the SSE payload mirrors the persisted row exactly. Trivial fix: reuse one timestamp for both.

8. _decision_event_stream hand-rolls SSE frame construction as a third, inconsistent implementation — dispatcher.py:1303-1323

dispatcher.py already builds text/event-stream frames twice elsewhere (the OpenAI-compatible streaming wrapper and the real streaming proxy), both via yield f"data: {json.dumps(...)}\n\n".encode(). The new generator reimplements the same primitive and is the only one of the three that doesn't .encode() the yielded string (relying on Starlette's StreamingResponse to encode str chunks for it, which does work — this isn't a bug — but it's now three copies of one pattern that can silently drift apart on the next edit to any one of them). Minor; a shared helper would remove the inconsistency rather than fix a defect.

9. Every live decision triggers a full sort + Counter rebuild over the whole recent-decisions list — tui_model.py (build_category_breakdown) via tui.py:246-248

_handle_live_decision calls build_category_breakdown over the entire (up to 50-item) list on every single SSE event, doing a full sort and Counter rebuild on the Textual UI thread each time, when only the one (category, tier) bucket the new decision falls into actually changed. Not a correctness bug — under realistic traffic volumes (a handful of decisions/sec at most) this is imperceptible — but under a burst it's doing O(n log n) work per event for an O(1) update, on the UI thread. Lowest priority of the nine; noted for completeness rather than urgency.

What's solid

  • The core SSE plumbing works end-to-end: replay-then-live, heartbeats, retry: hint, and the unsubscribe-in-finally shutdown path are all correctly shaped for the common case (one dashboard, no reconnect storms).
  • /events/decisions's docstring is accurate about what it does and doesn't carry (no conversation text, prompt, or session_dir — matches the same privacy invariant already enforced for route_decisions rows).
  • _drain_queue's non-blocking drain-then-block pattern (dispatcher.py:1293-1300, 1315-1323) is the right shape for "flush anything buffered, then wait" and is itself correct in isolation.
  • 562/562 tests pass, including new coverage in test_events.py, test_metrics_endpoint.py, and test_tui.py for the parts of this feature that are correct.

Recommendation

Findings #1 and #2 are the ones worth fixing before this sees real multi-dashboard or flaky-network use — #1 is a live RuntimeError under ordinary concurrent access (not a rare race window; any connect/disconnect overlapping a publish_decision call triggers it), and #2 silently breaks the exact feature this commit exists to ship. #4 (dead reconnect thread) and #3 (duplicate rows on every reconnect) compound #1/#2 — a dashboard that hit the RuntimeError and then can't reconnect because its thread died, showing stale-but-plausible data with no error, is a bad failure mode for something meant to be watched passively. #5, #7, #8, #9 are all real but low-severity and can ride along with the same pass. #6 is already resolved in the uncommitted working tree.