fix(dashboard): persist a slot create inside the slots suspension - #8665
Conversation
`suspend_slots_push`'s `__exit__` flushes the owed push, and on the
coalescing window's leading edge that flush broadcasts synchronously. An
exception there escaped `__exit__` and unwound the rest of
`api_chat_slot_create` -- including the `save_slot_off_loop(force=True)`
that is the only durable record of a recreate's folder filing or pinned
title. The slot survived in memory, so nothing reconciled the two: a
restart resurrected the placement the user had already changed.
Move the durable write inside the suspension, ahead of the broadcast --
the ordering `session_control.py`'s create span already uses ("the whole
allocation-to-persist span runs under `suspend_slots_push`"). Two paths
mint a slot through this context manager; only this one had the write
outside it.
Ordering change only. The broadcast still raises into the handler, and
what the broadcast contains is untouched.
Closes #6532
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
The whole-file black run reformatted six pre-existing call sites. The repo's formatting gate is baselined (scripts/check_black_formatting.py) and main's copy of this file is not black-clean, so that churn was never required -- it only widened the diff. Rebuilt from the pristine base with this change's two blocks re-inserted verbatim.
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A real ordering defect (durability sequenced after a best-effort broadcast) fixed at its root, matching the in-repo twin's already-shipped pattern, with the accepted cost documented and the ordering pinned by tests. [DESIGN-REVIEWED] 1c3bf5a |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All verifications done. The twin ordering at First-Principles-Verdict: PASS A durable write no longer sits behind a best-effort broadcast that can veto it; the fix is a pure move that makes both create paths obey one invariant. What this change shipsIntent: make a slot create's folder/title survive a restart even when the exit broadcast raises — a FIX.
Sibling count: 3 [FIRST-PRINCIPLES-REVIEWED] 1c3bf5a |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe single candidate is a documented, deliberate latency tradeoff. Verified against the code: the No findings. [OPUS-REVIEWED] 1c3bf5a Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
dwu96
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: pure ordering fix — moves the existing force=True durable slot save inside the suspend_slots_push span so a raising exit broadcast can no longer unwind past an already-acknowledged folder/title write; call arguments including the expected_history_key rebind pin are moved verbatim, plus two red-first ordering tests.
1. What is the problem?
api_chat_slot_createwraps its whole set-up instate.suspend_slots_push(). That context manager's__exit__flushes the owed push, and on the coalescing window's leading edgepush_slots_updatebroadcasts synchronously. An exception raised there escapes__exit__and unwinds the remainder of the handler — which, before this change, included theawait save_slot_off_loop(..., force=True)that persists the create's folder filing and pinned title, plusschedule_eager_spawnand the200.That write is not a duplicate of anything. For a message-less or freshly re-filed slot,
_save_slot_to_history's forced merge is the only durable record offolder_id/ a pinnedtitle— its own comment names "the recreate PATCH (folder or pinned title)" among the routes that "persist ONLY through this save, so a folder-only merge would silently drop their acknowledged writes on restart".Reachability, stated plainly: this is latent, not live. A
json.dumpsTypeErrorcannot originate in caller data — a value parsed from a JSON body is JSON-serializable by construction — so it needs a server-constructed object reaching slot state. In #6522 that was a test fixture, and #6522's actual root cause (bareStopIterationfromresolve_agent_bindingson an emptyconfig.agents) already landed as #6517 /e48ea4266. No production path is demonstrated here, and this PR does not claim one.2. Why this issue matters to the user
The two timing branches disagree about what the user is told, and the disagreement falls the wrong way.
elapsed >= _SLOTS_BROADCAST_INTERVAL_S, i.e. 0.2 s since the last slots push): the flush broadcasts inline, the exception reaches aiohttp, the caller gets a 500 and the durable write never runs.loop.call_later(_trailing_slots_flush), the handler returns 200, and the same exception surfaces later as a log line throughcrash_guard._asyncio_exception_handler.So a busy dashboard gets 200 plus a log, and an idle one — a person doing a single deliberate new-chat, with no other slot activity in the preceding 200 ms — gets the 500. The failure lands on the least-loaded, most deliberate case, which is the worst possible distribution for a phantom error: the create did commit in memory, so the user is told their action failed while the session exists.
And the lost write is the half nothing repairs. A dropped slots frame is cosmetic — four independent readers reconcile it (mount
fetchSlots(), reconnectfetchSlots()inuseWebSocket.ts, the WS-connect snapshot built fromserialize_slots, and the 5 s poll inWorldsPopout). Four independent repair paths is what makes the frame cosmetic; one would not have been enough. None of them repairs a write that never happened: memory holds the new folder, disk holds the old one, and the divergence surfaces on the next restart.3. How our fix solves it
Move the durable write inside the suspension, ahead of the broadcast. Nothing else.
This is an ordering change with an in-repo precedent, not a new design.
session_control.py:1131-1139— the other path that mints a slot through this same context manager — already does exactly this, with the reason written down: "The whole allocation-to-persist span runs undersuspend_slots_push… It also covers the persist and its failure retraction, so a slot whose birth write fails is never broadcast at all." Two create paths, one context manager, and only this one had its persist outside. The fix makes the broken branch agree with the branch that already ships.Chain from symptom to cause:
POST /api/chat/slotsanswers 500 and the folder/title the user just chose is absent after a restart.__exit__, so every statement after thewithblock is skipped.Two deliberate non-changes, both scoped out on purpose:
Closes #6532therefore covers the ordering defect that is real; the declined half is recorded in the issue, not silently absorbed by this merge.best_effort(the default) logs the failure and marks the slot dirty so the periodic flush retries it — the retry the metadata mutation routes already rely on._SLOTS_BROADCAST_INTERVAL_Sis untouched, the coalescing is untouched, and what the broadcast contains is untouched.The comment being replaced argued the write belonged outside because the process-wide suspension would make other clients' slot updates wait behind one session's history lock. That cost is real and the new comment keeps it on the record — it is accepted for the same reason the twin accepts it (which awaits a cross-process metadata write inside its own suspension), and because this span was never await-free anyway: it already suspends on the workspace-conflict probe a few lines above. Coalescing defers other pushes; it does not block their callers.
4. What tests we did
Two tests in
test/test_chat_slot_create_folder.py, a file whose docstring already says "The fix is ordering, so the tests assert ordering".They pin the ordering, not the exception:
_broadcastis stubbed to raise on aslotsframe, so any future raise out of the flush is covered rather than just today'sTypeError. Each waits out_SLOTS_BROADCAST_INTERVAL_Sfirst — without that the flush lands on the deferred trailing timer, the handler answers 200, and the test would prove nothing.test_raising_exit_broadcast_cannot_skip_the_folder_writetest_raising_exit_broadcast_cannot_skip_the_pinned_title_writeBoth assert the on-disk metadata line via
conversation_log._read_metadata, and both assert the response is still 500 — so the tests also pin that this PR does not swallow the error.Mutation-verified, with the mutation proven to apply. The handler was reverted with
git checkout origin/main -- src/kiro_crew/dashboard/chat_handlers.py, and the revert was confirmed bygit diff --stat origin/mainon that path returning zero lines before the run. Against that pre-fix tree both tests fail on the disk assertion —assert None == 'f-design'andassert None == 'Pinned'— while thestatus == 500assertion passes in both states, which is the evidence that the change moves durability and not the response. The fix patch was then re-applied (git apply --checkclean) and both pass.Suites run (targeted, never the full suite):
test/test_chat_slot_create_folder.py— 20 passedtest/test_chat_slot_create_folder.py+test/test_forced_save_history_key_pin.py+test/test_slots_broadcast_coalesce.py— 38 passedtest/test_open_slots_persistence.py -k "suspend or push_slots"— 4 passedtest/test_dashboard_chat.py -k "slot_create or create_slot or folder"— 76 passedGates on the touched files: black, isort, flake8 clean. mypy reports 2 pre-existing errors in
src/kiro_crew/transcribe.py, reproduced identically on an unmodified main checkout — baseline, not from this change.5. Any other suggestions on the work
test_suspend_slots_push_unwinds_and_flushes_on_exceptioncorrectly pins that the owed push fires even when the body raises — but it stubs_broadcast, so a failing flush during unwind is unpinned. When both raise,@contextmanagerpropagates the flush's exception and the body's becomes__context__. That is why api_chat_slot_create 500s (bare StopIteration) when config.agents is empty — main CI red since #6465 #6522 read as a broadcast bug: the 500's top line namedjson.dumpswhile the real cause was chained underneath. A diagnostic that names the offending slot key, field and type before re-raising would be semantics-neutral and cheap.Pattern harvest
Rule candidate: an in-repo sibling already doing the thing correctly is the strongest available authority for a fix's shape — prefer copying a live twin's ordering over reasoning from first principles, and cite the twin by file and line. Here the twin is
session_control.py:1131-1139, and those numbers were re-resolved at branch point rather than carried over from where the review had named them, because main had moved in between: a citation that was true when written and false when used is worse than no citation, since it still looks verified.session_control.pydoing the same thing correctly, with its reason in a comment. An in-repo sibling that already does it right is the strongest authority a fix's shape can have: it is not one reviewer's judgment against another's, it is the repo's own decision applied consistently.json.dumpsis also what every read path goes through (GET /api/chat/slots, the WS snapshot), so had the poison been real, nothing at the broadcast could have fixed it. Retrying or swallowing the broadcast would have addressed the messenger; the divergence lived at the unwind boundary._do_slots_broadcast. That is only worth stating because the same grep does surface real traceback frames fromdashboard/state.py— a probe that cannot produce a positive is indistinguishable from an absent signal.git diff --stat origin/mainreturning empty on the path before the red run, not by assuming the checkout worked.Closes #6532