fix(dashboard): stop rewind's commit undoing mid-boundary writes - #8974
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of All claims verified against the diff and tests. I have what I need for the design review. Design-Verdict: CONCERNS Sound delta-commit fix, but the intricate discard-drain/SEL machinery is now duplicated verbatim across both endpoints — the exact sibling-divergence class this PR just paid for. Watch
[DESIGN-REVIEWED] eb3ea26 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All claims are now verified. The description's counted claims hold up (one First-Principles-Verdict: CONCERNS The shield quietly turns an abandonable native teardown into run-to-completion while its own comment denies any change, and What this change shipsIntent: keep an edit-boundary commit from erasing concurrent writes and leave an audit record when it destroys context and fails — a FIX (four declared defects).
Watch
[FIRST-PRINCIPLES-REVIEWED] eb3ea26 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI have enough to complete the validation. Let me verify one detail about how the double-cancel scenario is treated as real in this file's discard drain (already confirmed: My analysis:
A second cancellation on rewind's history-save drain ( FINDING — src/kiro_crew/dashboard/chat_rewind.py:719 — the cancellation-drain uses a single [OPUS-REVIEWED] eb3ea26 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
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: |
5d895fb to
2e8a6ac
Compare
Disposition of the GPT 5.6 blocking finding (F1,
|
Status: blocking finding accepted, remedy tracked elsewhereThe GPT 5.6 blocking finding (F1, It predates this change, and it is present identically on the edit-resend path on main: The remedy is tracked in #8988 rather than here, because it requires a new opt-in identity This PR is therefore left as it stands, for a human to decide between holding it and landing it. I |
2e8a6ac to
2e2ec1f
Compare
|
self-added: yes
The subtraction was the maintainer's ruling after both candidates were costed: repair the mechanism (thread identity into the shared save, about twenty lines across three files) versus remove it. Removal won because the disk clobber happens before either added axis in both worlds, so those axes protect live state only and the prescribed fix is a new data-safety mechanism in a shared write path rather than a repair of this one. The two tests that asserted the removed axes were deleted with it, and the import they alone used was dropped. |
|
self-added: yes
|
|
|
7791a71 to
4c5e582
Compare
|
self-added: yes
|
rewind's commit adopted the prepared window wholesale, so a workflow or cron row that landed while its two irreversible boundaries were pending was silently erased -- and a rewrite deliberately skips the cross-process-append scan, so the rewrite could not put it back either. Its commit-side re-check also tested only the history key, so a close-and-recreate under the same name passed and the endpoint reported success for a dispatch reservation that had been cancelled. edit-resend already ships both guards. This ports them: identity-keyed arrival retention for the window and the un-drained buffer, and a single three-axis _commit_target_intact predicate shared by the success and cancellation paths. Both endpoints also now record a SEL event on their post-discard failure paths, which previously destroyed native conversation context and returned 503 with only a logger.warning -- the one outcome that destroyed context without committing anything was the only one absent from the audit trail. Refs #8419
4c5e582 to
eb3ea26
Compare
|
self-added: no
|
|
Verification record for this head, posted as a comment rather than in the Commands run, one file at a time: Plus The three-valued-outcome test was mutation-verified: restoring Main moved to |
Problem / Motivation
rewindandedit-resendare the two edit context-boundary endpoints, and they run the same transaction: freeze a prospective window, destroy the native conversation, rewrite persisted history, then adopt the prepared state on the live slot. Anything that writes to the live slot while those boundaries are pending is racing the commit. Two of those writes were being thrown away, and a third outcome was leaving no record. Measured at main7dd090fb62e858cc1d752e404deae44913d48de1, and re-verified on the rebase onto56f67aa43f00f9484c346a8d1669b39102a63c78.A row that arrives mid-boundary is silently erased.
rewind's commit didslot.messages = prospective_slot.messages-- a wholesale replace. A workflow or cron completion appends straight on the event loop without takingslot._lock(workflow_inject->append_and_surface), so a row can land between the pre-await snapshot and the commit. The replace drops it, and the rewrite cannot put it back because a rewrite deliberately skips the cross-process-append scan (collect_foreign=not rewrite). The same loss happens one field over inslot._pending, which is what an open client's stream reader drains.A row already delivered mid-boundary is queued again.
_pendingis the un-drained buffer an open client's stream reader empties, anddrain()doesslot._pending.clear()on the live list. The commit didslot._pending = prospective_slot._pending + arrived_pending, and that frozen copy still holds every row the drain delivered -- so a client that read mid-boundary is handed the same rows a second time.An answered question card comes back. Answering a card mutates the live dict:
clear_question_pendingpops the id fromslot._question_pending. The commit didslot._question_pending = prospective_slot._question_pending, which replaces that whole dict with a copy frozen before any await -- so the pop is undone. A blocking card is the shape that reaches this, because an append never retires one:state.pyretires on_QUESTION_RETIRING_ROLES = {"user", "nudge"}and onlyif not rec.get("blocking"). The user sees a card asking for an answer they already gave, and the slot reportsneeds_inputagainst a round-trip that has completed.A failure after the native discard left no audit record. Once the native session is torn down it is unrecoverable. SEL carried this endpoint's denials and its successful commits, but not this outcome -- so in the trail, "destroyed the user's conversation context and then failed" was indistinguishable from a denial that touched nothing. True of both endpoints, and true of seven exits rather than the four that are obvious: the four 503 returns plus a cancellation landing on any of the three awaits that run with destruction already done. None of those three is reachable by an
except Exception, becauseCancelledErrorderives from BaseException, and they are the worst of the seven because the client is never told at all -- the cancellation propagates instead of a response, so SEL is the only place the outcome can be attributed from.Why it matters
(1) is silent data loss in a user-visible path: the arrived row is gone from the window, and because it never returns to the window no later flush re-persists it either. (2) is the mirror -- nothing is lost, something is duplicated, and the client sees a row it has already been shown. (3) is worse than either, because it strands consent. A blocking card is a question that gated what happened next. Bringing it back either asks the user twice for one decision, or leaves the session waiting on a question nobody knows is pending -- with the answer channel already gone, no later round-trip can retire it. (4) is the one that cannot be recovered after the fact: the destruction is irreversible and, without a record, unattributable.
What changed (motivation -> approach -> change)
The four defects above lose to one thing: the commit replaces a whole container with one frozen before the boundary. So there is one rule -- a commit edits the live container by the delta the transaction owns; it never assigns a frozen copy over it -- and it is now applied to all three fields, in both endpoints.
Arrival retention.
pre_await_row_ids/pre_await_pending_idsare captured beside the existingpre_await_disk_older_count, in the same synchronous stretch as the snapshot. The window becomesprospective_slot.messages + arrived_rows. Identity rather than a length, because anappendat the window cap trims the front and a positional slice would re-adopt trimmed rows or miss the arrived one. Arrivals go after the prospective window:monotonic_transcript_tsonly ever moves a row forward, so an arrived row must never sort before the edit, and on a coarse clock (Windows ticks in ~15.6 ms steps) both appends can read the same instant -- list order is what separates that tie.Identity by
id()needs the objects kept alive, and that is a correctness requirement rather than a detail. Anid()is an integer that says nothing about lifetime, and at a low-index edit nothing else pins the leading rows -- at index 0 the prospective copy is empty. A cap trim during the awaits would free a leading row, CPython could hand its address to a newly appended arrival, and the commit would read that arrival as "not new" and drop it. So the snapshot retains the lists (pre_await_rows,pre_await_pending) and derives the id sets from them.meta.midis not usable as the identity instead:appendskips it for restored rows (mint_mid=False) and for the wire-only roles, so it is not present on every row._pendingis edited, not replaced. A pre-await row survives only if it is still live, so a row the drain already delivered is dropped instead of requeued; the edit's own row and anything that arrived are kept unconditionally. Order is unchanged -- pre-await rows, then the edit, then arrivals -- which is the same monotonic argument as the window.The answer is made authoritative by construction. The commit stops assigning
_question_pendingat all. It deletes exactly the ids the edit retired, in place, and announces only the ids it actually removed:A census of every
_question_pendingwrite site insrc/kiro_crewreturns eight, and exactly one adds an id:mark_pending. So once an answer has popped a card, nothing in the commit can bring it back -- the commit no longer carries a competing copy of the container, which means there is no reconciliation for a later edit to this function to forget. It also leaves a card that arrived mid-boundary alone.edit-resendused to keep aprospective INTERSECT liveset here, which retired an answered card correctly but erased an arrived one, because the intersection is keyed on the frozen copy. That set is deleted and both endpoints now retire in place.flowchart LR subgraph Before A1["answer pops id from live dict"]:::ctx --> B1["commit assigns frozen copy"]:::removed --> C1["card is back, unanswerable"]:::removed end subgraph After A2["answer pops id from live dict"]:::ctx --> B2["commit pops only the edit's ids"]:::added --> C2["card stays answered"]:::added end classDef added fill:#DCFCE7,stroke:#16A34A,color:#14532D,stroke-width:2px classDef removed fill:#FEE2E2,stroke:#DC2626,color:#7F1D1D,stroke-dasharray:4 3 classDef ctx fill:#E0F2FE,stroke:#0284C7,color:#0C4A6E linkStyle 0,1 stroke:#DC2626,stroke-dasharray:4 3 linkStyle 2,3 stroke:#16A34A,stroke-width:2pxLegend: green = added by this change, red = removed by it, blue = unchanged.
The commit used to hand the slot a dict copied before the answer existed; now it edits the dict the answer wrote to, so the answer wins.
SEL on post-discard failure. A local
_sel_native_destroyed(reason)in each endpoint, called before every exit that leaves destroyed context uncommitted.outcome="error",resourcescarryingslot=<key>,native_cleared=1, and a distinct reason per site. The spelling is not invented:outcome="error"is settled 98 times acrosssrc/kiro_crew/dashboard/.Every exit in the destroyed-context window, and what
native_clearedends up as. The window opens at the first instruction insidediscard_conversationthat changes state and closes at the commit. Both endpoints are identical; the reasons are prefixed per endpoint in the log.native_clearedunknown1skip_if_busyreturns before the popunknown*_prepare_failedunknown*_session_busyaflush()cancelled1aflush()raises, 503*_prepare_failed11*_save_failed1*_save_failed1*_slot_rebound1Rows 3 and 6 are where
unknownwould be the wrong word, and they are the reason the field is three-valued rather than two:discard_conversationevaluatesif skip_if_busy and ... semaphore.locked(): return Falsebeforeowner._sessions.pop(...), so a refusal is positive knowledge that nothing was torn down. Writingunknownthere would discard a fact. Rows 1, 4 and 5 are the opposite: genuinely nothing is known, and the field says so instead of picking whichever of the two facts is cheaper. Rows 5 and 12's ordinary-failure siblings were the last two exits to write nothing at all; both now write, which closes the enumeration.The broad
except Exceptionaround the drain is deliberate and the code says why.provider.shutdown()is provider transport and its failure modes are not enumerable from a caller, and letting an arbitrary error escape aCancelledErrorhandler would replace the client's cancellation with an unrelated exception on a teardown path. It narrows where it matters --CancelledError,KeyboardInterruptandSystemExitstill surface -- and the breadth is harmless now that it no longer manufactures a false state.The drain is a bounded re-shield, not a single
await, because that await is itself a cancellation point: one further cancel would abandon it and lose the record. Eight passes, matching_SAVE_DRAIN_ATTEMPTS, whose comment in this file names the same hazard. The settled task is inspected rather than the await's value.One direction stays open and it is a callee's contract, not a branch:
discard_conversationnever reports whether it passed its own destruction point, which is why rows 1, 4 and 5 can only sayunknownrather than the truth. Folded into Both edit-boundary endpoints validate slot identity only after the destructive rewrite has landed #8988 with a census of all eight call sites.Two other rewrite-save callers are deliberately not closed:
regenerateandforkhave the same injected-row exposure and are untouched by this change. The spec now says so, so the fixed subset -- exactlyrewindandedit-resend-- is discoverable rather than inferred.Disclosed: all three cancellation paths were added in review, and each reviewer was right. The first revision covered only the four 503 returns. Opus flagged the history save. GPT then flagged
aflush(). On the next round GPT flagged the discard itself, and its adjudication is what corrected the reasoning here: I had argued in a disposition that a cancelled discard destroys nothing and so should not be recorded, which is wrong --session_lifecycle.pypops the session and callsclear_sidbefore three further awaits, so destruction is already true while those run. The table above replaces that claim.Subtracted in review: two extra commit-time identity axes, and the measurement is why. An earlier revision added a
_commit_target_intact()predicate torewindcarrying three axes -- the routing key main already checked, plusstate._slots.get(name) is slotandslot.task is task. A review finding on the object-identity axis is correct and its consequence is larger than the axis: every one of those axes is evaluated afterawait asyncio.shield(save_task)has already rewritten the shared history file, so no axis at that point in the sequence can protect the file. A same-name close-and-recreate keeps the same history key, the save's routing guard passes it, and the replacement conversation's transcript is truncated with no archive and no recovery -- the 503 tells the client to retry the rewind, not to restore the other conversation. Adding or dropping a commit-time axis does not move that by a single instruction. Closing it means pre-write validation inside_save_slot_to_history, which is a shared write path serving both endpoints and a second, larger purpose than this change. So the two added axes are dropped and main's own commit-time routing check is restored verbatim at both call sites. Nothing that exists on main is removed -- this declines to add, it does not delete a protection. The corruption is filed as #8988 with the remedy shape.The sibling is fixed rather than declared, because the reviewers were right that a declaration was not enough. An earlier revision fixed
rewindonly and namededit-resend's intersect as out of scope. Design Review and First Principles Review each landed on the same objection from opposite ends: the PR derives a rule and then leaves a live violation of it, in a file the diff already opens, when the fix is the same six lines. Sosurviving_questionsis deleted,edit-resendretires in place, and the two endpoints now spell one job one way instead of two. This makes the diff wider by one function and the concept narrower by one special case.slot.total_messagesis also not taken.edit-resendincrements this lifetime counter for the edited row;rewinddoes not, andprospective_slot.appendbumps only the shallow copy's int. That looks like a real defect, is not reachable from any gap above, and would not be tested by anything here.Tests
Thirteen new tests in
test/test_dashboard_chat_rewind.pyand two intest/test_chat_regenerate_cov80.py, all driving the real endpoint throughTestClientwith the racing event injected inside the awaited boundary viadiscard_conversation's side effect -- the harness both files already use...._keeps_a_row_that_arrived_during_the_boundaries..._keeps_a_pending_row_that_arrived_during_the_boundaries_pending..._keeps_a_blocking_card_answered_during_the_boundaries..._does_not_requeue_a_row_drained_during_the_boundaries..._records_a_sel_event_when_the_native_context_is_destroyedoutcome="error",native_cleared=1, naming the slot and the reason..._cancelled_without_a_landed_rewrite_still_records_the_destruction..._retains_the_pre_await_rows_so_their_ids_cannot_recyclegc.get_referrersbecause rows are plain dicts and cannot be weak-referenced, plus the behavioural half that the arrival still lands..._cancelled_on_the_sid_flush_still_records_the_destructionaflushcancellation records it too, asserting the discard returned True first so the destruction really happened..._cancelled_inside_the_discard_still_records_the_destructiondiscard_cancelled; the handler task is captured on its own stack so the cancellation lands deterministically..._cancelled_discard_that_refused_records_no_destructionskip_if_busyrefusal destroyed nothing and must leave no record, so the drain cannot simply record on every cancellation..._cancelled_discard_that_raises_records_an_unknown_outcomenative_cleared=unknownand zeronative_cleared=1, so it cannot be satisfied by over-claiming in either direction..._second_cancellation_on_the_drain_still_records..._discard_failure_records_an_unknown_outcomenative_cleared=unknownand zeronative_cleared=1, and leaves the original branch intacttest_edit_resend_commit_keeps_a_blocking_card_answered_meanwhiletest_edit_resend_commit_keeps_a_card_that_arrived_meanwhileEach test asserts its own precondition so it cannot pass vacuously: the arrival tests append through
append_and_surface, the real door a workflow completion uses; the drain test asserts the drain delivered something and seeds the buffer through that same door, since the fixture drains; and the card tests assert the clear returnedTrueand use a blocking card, which is the axis-isolating condition -- a non-blocking card would be retired by the edit's ownuserappend and the test would never touch the race.Proved rather than asserted:
prepare-pr'sprove.py --base mainreverts the production hunks in a throwaway worktree while keeping the test hunks, re-runs the changed test files, and reportsPROVEN: an assertion failed with the bug reintroduced. Individual fixes were also mutation-checked by hand -- restoringdestroyed = False, and dropping the retained row list -- each reddening its own test on its own assertion message, which is what separates a test from a decoration.An existing test is the control that the deleted intersect lost no coverage:
test_edit_resend_commit_does_not_resurrect_a_card_retired_meanwhilepins the case the intersect was written for -- an arriveduserrow retiring every non-blocking card, with no second announcement from the commit -- and it passes unchanged against the in-place retire.Gates run individually rather than through a wrapper, on the changed files:
black --target-version py310 --checkclean,isort --check-onlyclean,flake8clean,mypy src/kiro_crew/dashboard/chat_rewind.pyreporting only two pre-existing errors insrc/kiro_crew/transcribe.py:1337reached transitively (identical count on an unmodified sibling, so not from this diff). Suites at-n0, one file at a time:test_dashboard_chat_rewind.py51 passed (38 before),test_chat_regenerate_cov80.py66 (64 before),test_chat_regenerate_refusal_codes.py3, plus every spec that reads the symbols this diff touches --test_slot_needs_input_status.py30,test_ask_question_roundtrip.py63,test_chat_slot_facade_contract.py5,test_steer_requeue.py52.Manual verification
N/A -- unit coverage sufficient. All four defects are concurrency-shaped and reachable only by interleaving an event with an awaited boundary, which the tests drive directly and deterministically; the SEL record is asserted from the recorded call rather than from a log.
Related Issues
Closes #8975
Refs #8419
Refs #8988
Pattern harvest
The defect class is a commit that adopts prepared state by replacing a container rather than editing it. Every field lost this way lost the same way:
messages,_pending, and_question_pendingwere each assigned a copy frozen before an await, so any in-place write that landed during the await was discarded without a trace. Replacement is the easy thing to write and it is silently destructive exactly in proportion to how long the boundary is open. The tell is a plain assignment of one object's field to another object's field inside a commit that follows anawait.Rule candidate: review-prompt
Pattern: inside a post-await commit,
live.field = frozen_copy.fielddiscards every concurrent in-place write tolive.field-- prefer editing the live container by the delta the transaction owns.Checklist
docs/system-specs/modules/session.mdreconciled: its "Edit rewind context boundary" bullets described the pre-fix behaviour