fix(dashboard): name the offender when the slots flush fails to serialize - #8888
Conversation
…lize A non-serializable value in slot state breaks every slots read path, but the resulting TypeError names neither the slot nor the field, and when the owed flush of suspend_slots_push raises during exception unwind it replaces the body's own exception in the caller's view, demoting the actual fault to __context__. Both made kirodotdev#6522 read as a broadcast bug. Two additive PEP 678 traceback notes, no semantics change: - _do_slots_broadcast hoists the general-frame json.dumps and annotates a TypeError/ValueError with the offending slot key, field name, and value type (value withheld: slot state can carry user text). Both coalescing branches funnel through this method, so both report identically by construction. - suspend_slots_push annotates a flush exception raised while unwinding over an in-flight body exception, naming the buried original and pointing at __context__. Separable diagnostic half of kirodotdev#8745; the failure-semantics decision (catch-at-flush vs fail-loud vs validate-at-write) stays open there. Refs kirodotdev#8745
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of The diff checks out against the base: the hoisted dump reuses the same Design-Verdict: PASS Additive PEP 678 diagnostics on an evidenced failure seam, with semantics deliberately untouched and the open failure-semantics decision correctly left to #8745. Suggestions
[DESIGN-REVIEWED] 3000bd5 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. The sole candidate concerns only the wording of a PEP-678 traceback note: when [OPUS-REVIEWED] 3000bd5 |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All checks done. The change holds up under the gate; one depth finding and one shrink survive. First-Principles-Verdict: CONCERNS The offender note lands only on the broadcast path while the description itself counts three broken read paths; the other two still raise the bare TypeError. What this change shipsIntent: when a non-serializable value poisons slot state, make the one traceback name the offending slot/field and stop the flush's exception from burying the body's real fault — a FIX (diagnostic half of a reported defect, no semantics change).
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 3000bd5 |
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (2 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: diagnostics-only fix for the slots-broadcast serialization failure -- names the offending slot/field in a PEP-678 note and stops an owed flush's exception from masking the body's in-flight exception during unwind; same exception types, same propagation, no behaviour change. CodeQL is not applicable on this fork PR (default-setup emits no check-run); SAST coverage is Semgrep only, latest run success with 0 annotations.
…odotdev#8745 class) The slots-broadcast diagnostic from kirodotdev#8888 named the slot and field that break JSON serialization, but only on the coalesced broadcast. The three sibling read paths serialize the same projection and still failed with the bare 'Object of type X is not JSON serializable': - the dashboard-user WS frame (_slots_ws_frame, covering both its send sites) — the enriched projection, so a value only enrichment adds raised here and nowhere else; - the WS connect snapshot — which sits under 'except Exception: pass', so a broken snapshot meant an empty sidebar with zero evidence; it now also logs a WARNING (the note alone would vanish with the swallowed exception), with the exception flow itself unchanged; - GET /api/chat/slots — web.json_response ran the dump internally; the handler now dumps explicitly (json_response is Response(text=dumps(..)) so the healthy response is unchanged) and annotates the failure. The note helper gains a path label naming which read path raised, and its two shape-check branches are removed: every caller passes serialize_slots() output (list-of-dicts by construction), and any surprise still degrades to the generic note through the defensive except instead of raising. The connect snapshot now sends the pre-dumped string (the established shape on every other slots send path), so the WS test doubles gain a send_str that parses back to the dict frames their assertions read; one double's folders_generation had returned a raw MagicMock, which a fake send_json never serialized and the real dump now rightly rejects.
The slots-broadcast diagnostic from #8888 named the slot and field that break JSON serialization, but only on the coalesced broadcast. The three sibling read paths serialize the same projection and still failed with the bare 'Object of type X is not JSON serializable': - the dashboard-user WS frame (_slots_ws_frame, covering both its send sites) — the enriched projection, so a value only enrichment adds raised here and nowhere else; - the WS connect snapshot — which sits under 'except Exception: pass', so a broken snapshot meant an empty sidebar with zero evidence; it now also logs a WARNING (the note alone would vanish with the swallowed exception), with the exception flow itself unchanged; - GET /api/chat/slots — web.json_response ran the dump internally; the handler now dumps explicitly (json_response is Response(text=dumps(..)) so the healthy response is unchanged) and annotates the failure. The note helper gains a path label naming which read path raised, and its two shape-check branches are removed: every caller passes serialize_slots() output (list-of-dicts by construction), and any surprise still degrades to the generic note through the defensive except instead of raising. The connect snapshot now sends the pre-dumped string (the established shape on every other slots send path), so the WS test doubles gain a send_str that parses back to the dict frames their assertions read; one double's folders_generation had returned a raw MagicMock, which a fake send_json never serialized and the real dump now rightly rejects.
Problem / Motivation
A non-serializable value in slot state breaks every slots read path at once (
GET /api/chat/slots, the WS snapshot, and the debounced broadcast all serialize the same projection), but the operator gets two unhelpful tracebacks (#8745, and #6522 before it):main(235d36a), with one slot'stitlepoisoned:suspend_slots_pushraises while the body's own exception is already unwinding, the flush's exception replaces the body's in the caller's view — the actual fault is demoted to__context__, which is exactly how api_chat_slot_create 500s (bare StopIteration) when config.agents is empty — main CI red since #6465 #6522 was first misread as a broadcast bug:Why it matters
This is the diagnostic half that issue #8745 explicitly asks to land regardless of the wider decision ("Land the masking diagnostic regardless, since it changes no observable behaviour"). Without it, the next poisoned-slot incident starts the same way #6522 did: an operator staring at a bare
TypeErrorat the top of a traceback whose real story is one exception down, with no pointer to which slot or field to look at.What changed (motivation → approach → change)
_do_slots_broadcastwith no context attached, andsuspend_slots_push's finally-block flush is positioned to raise over an in-flight body exception, where Python's implicit chaining preserves the original only as__context__without saying so.requires-python >= 3.12); no semantics change — same exception types, same propagation, same chaining:_do_slots_broadcasthoists the general-framejson.dumpsand, onTypeError/ValueError, annotates it via a new_slots_serialization_note()helper that names the offending slot key, field name, and value type. Values are withheld by design: slot state can carry user text, and the type is enough to find the producer. Both coalescing branches (leading edge and trailing timer) funnel through this method, so both report identically by construction. The helper is defensive end-to-end — any surprise in the offender walk degrades to a generic note rather than raising.suspend_slots_pushcapturessys.exc_info()before the owed flush and, if the flush raises during unwind over a distinct in-flight exception, annotates the flush's exception naming the buried original and pointing at__context__.Same probe, after:
Deliberately not in scope: the failure-semantics decision the issue frames as options 1/2/3 (catch-at-flush vs fail-loud vs validate-at-write). That is a behavior choice the issue leaves open, and this diagnostic is useful under any of the three outcomes.
Tests
Five tests added to
test/test_open_slots_persistence.py(file: 67 passed; slots push/broadcast neighbors: 191 passed). Before the fix, the four diagnostic tests fail (4 failed, 1 passed); after,5 passed:test_leading_edge_serialization_failure_names_the_offender— the immediate broadcast annotates the raisingTypeErrorwith slot key, field, and type, and never the value.test_trailing_flush_serialization_failure_names_the_offender— the trailing-edge callback funnels through the same annotated dump (pins the both-branches claim).test_flush_failure_during_unwind_names_the_masked_exception— implicit chaining survives (__context__is the body'sRuntimeError), the note names the buried original, and the suspend depth still unwinds to zero.test_flush_failure_without_inflight_exception_has_no_unwind_note— a clean-exit flush failure gets the offender note only; no phantom unwind note.test_healthy_flush_is_unchanged_by_the_diagnostics— benign control: exactly one broadcast on the happy path, no new denials, no behavior change.Manual verification
N/A — unit coverage sufficient: the seam is deterministic (poisoned projection in → annotated exception out) and the probe outputs above are the manual scenario, pasted from a fresh run on this branch.
Related Issues
Refs #8745 (the separable diagnostic half; the failure-semantics decision stays open there). Context: #6522.
Pattern harvest
Pattern: a cleanup step positioned to raise during exception unwind masks the body's exception (visible only as an unlabeled
__context__), and serialization errors deep in a shared projection name no offender.Rule candidate: review-prompt — "any
finally/__exit__/context-manager cleanup that can itself raise should annotate its exception when unwinding over an in-flight one; anyjson.dumpson a composite projection should name the offending element on failure."Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)suspend_slots_pushand the new helper are the doc-of-recordContribution License Agreement
Per the template placeholder (CLA text pending): offered under the same terms as my prior merged contributions to this repository (#8835).