fix(dashboard): size transfer-bundle tail by message id (#4684) - #5325
Conversation
build_transfer_bundle sized the un-flushed tail of an exported bundle as slot.messages[slot._disk_window_len:]. That counter advances only on the save and load paths, but a durable injector (cron_inject, workflow_inject, crew_chat) appends the same row to the window AND to disk without a save, so the disk read already returns the row while the counter has not moved -- the slice starts one row too early and the bundle carries the injection twice. The read path rejected this exact estimator for this exact job (#4137, measured) and replaced it with _append_unflushed_tail, which matches window rows against the disk read by meta.mid (ordered body comparison when ids are missing or mixed) and merges in only the rows disk does not already hold. Route the sync builder's tail through the same helper; its counter dependency and the now-dead empty-transcript fallback (plus the identical dead twin in _read_and_assemble) go. Reachability, stated plainly: the shipped export endpoint routes through build_transfer_bundle_async, which keeps its boundary slice and is NOT exposed -- a durable injector's slot.append marks the slot dirty, the async builder flushes a dirty slot before snapshotting, and the save folds the append_if_absent copy into the window and advances the boundary, so its counter is honest by the time the tail is cut. The sync builder is the documented entry for tests and off-loop callers; this change removes the desynced estimator from that path (the trap #4137 already measured) rather than fixing a duplication reachable through today's send button. Regression tests mirror #4137's: a durably-injected row appears exactly once on the id arm, the ordered arm, and under a non-zero frozen prefix, and a genuinely un-flushed turn still travels. All red against the previous slice. Closes #4684
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: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of Reading the contract, intent, patch, and the surrounding code ( First-Principles-Verdict: CONCERNS The fix is real and cause-level, but it lands on a builder with zero production callers — retiring that builder was the unweighed, deeper subtraction. What this change shipsIntent: stop an exported session bundle from carrying a cron/workflow-injected turn twice — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 0d76467 |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Consolidates onto the proven #4137 id-merge at the true root cause; honest about reachability; async path deliberately and correctly untouched. Suggestions
[DESIGN-REVIEWED] 0d76467 |
|
Disposition for the First Principles CONCERNS (advisory) on 0d76467: Accepted and deferred. The subtraction is real: build_transfer_bundle has zero production callers (the shipped export path is build_transfer_bundle_async), so retiring it and pointing the 17 test-only call sites at the async builder would delete the second tail estimator instead of maintaining it. Deferring rather than folding it in because (a) it is a scope change beyond #4684 -- that issue files a defect in the estimator, and this PR fixes exactly that; (b) rewiring 17 sync test call sites onto an async builder changes what those tests exercise (the async path's flush/retry loop), which deserves its own review rather than riding a converged fix; (c) the module docstring documents the sync builder as the off-loop/test entry, so removal is an API decision for a maintainer-scoped follow-up. The drift risk in the meantime is bounded: both builders now either share the id-based helper or document why not, and the layering comment pins the import direction. Will file the retire-or-keep decision as a follow-up item rather than expanding this diff. |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe candidate list contains no candidates — the discovery pass found nothing. I verified the change independently:
No grounded (a)/(b)/(c) defect survives, and I found no new one to add. No findings. [OPUS-REVIEWED] 0d76467 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
bolichen97
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 (Semgrep + CodeQL both present, success, 0 findings), security checklist all-NO, AI reviewers green. Category: fix with clear root cause — transfer-bundle tail sized by _disk_window_len re-appended a durably-injected row already on disk (mirrors #4137); now sized by message id via _append_unflushed_tail.
bolichen97
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: clear root cause (#4684) — sync transfer-bundle tail sized by meta.mid identity via _append_unflushed_tail instead of the _disk_window_len boundary a durable injector never advances.
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 (Semgrep+CodeQL success, no alerts), security checklist all-NO, AI reviewers green. Category: sizes the transfer-bundle unflushed tail by message id instead of the _disk_window_len boundary, fixing duplicated injector rows (regression #4137) in the sync bundle builder.
Problem / Motivation
build_transfer_bundle(src/kiro_crew/dashboard/session_transfer.py) sizes the un-flushed tail of an exported session bundle asslot.messages[slot._disk_window_len:]. That counter advances only on the save and load paths, but a durable injector (cron_inject.py,workflow_inject.py,crew_chat.py) appends the same row to the resident window AND persists it to disk viaappend_if_absentwith one sharedmeta.mid, without going through a save. The disk read then already returns the row while the counter has not moved, so the slice starts one row too early and the bundle carries the injection twice. PR #4137 rejected this exact estimator for this exact job on the read path, measured: substituting it broketest_transient_window_row_does_not_duplicate_the_tail.Reachability, stated plainly: the shipped export endpoint (
handlers_instances.api_instances_send_session) routes throughbuild_transfer_bundle_async, which is NOT exposed -- an injector'sslot.appendmarks the slot dirty (state.py:2291), the async builder flushes a dirty slot before snapshotting its tail (session_transfer.py), and the save folds theappend_if_absentcopy into the window (chat_persistence.py:1440-1452) and advances the boundary (:2113), so its counter is honest by the time the tail is cut. The sync builder is the documented entry for tests and off-loop callers; this PR removes the desynced estimator from that path rather than fixing a duplication reachable through today's send button.Why it matters
Any present or future off-loop caller of the sync builder -- the module's own docstring invites them -- silently ships a transcript with a duplicated turn after a cron/workflow injection, and the receiving instance materialises the corrupted copy. It is the same trap the read path already paid to measure and remove (#4137); leaving it in the sibling function is how it gets reintroduced. Two estimator strategies for one job, one of them known-wrong, is also standing drift.
What changed (motivation -> approach -> change)
Symptom: an injected row appears twice in the exported bundle. Root cause: the tail is sized by a window-length counter that durable injectors structurally desync (they write disk + window with no save). Change: route the sync builder's tail through
chat_handlers._append_unflushed_tail-- the id-based merge #4137 established -- which matches window rows against the disk read bymeta.mid(the id an injector stamps on both copies), falls back to the ordered body walk when the disk window region has missing/mixed ids, and merges in only the rows disk does not already hold. The counter dependency disappears from this path, along with the now-deadif not all_messagesfallback (and its identical dead twin in_read_and_assemble). The async sibling deliberately keeps its boundary slice -- its pre-snapshot flush is what makes that counter honest -- and its docstring now says so instead of claiming the two builders are the same. A layering comment at the new import records the direction constraint (session_transfer may import chat_handlers, never the reverse).Tests
All in
test/test_session_transfer.py, each red against the previous slice (mutation-verified by restoring the old estimator in place):test_durably_injected_row_is_not_duplicated_in_the_bundle-- the id-matching arm, the one an injector's rows actually take.test_durably_injected_row_is_not_duplicated_when_older_rows_have_no_id-- the ordered-comparison arm (pre-id-era transcripts).test_durably_injected_row_with_a_frozen_prefix_is_not_duplicated-- non-zero_disk_older_count, pinning the window-region offset arithmetic the sync path newly depends on.test_genuinely_owed_row_still_travels_after_an_injection-- control: a fix that merely stopped appending would pass the three above and fail this.test_bundle_ignores_the_resume_count_and_ships_each_turn_once-- the prior boundary test, rewritten to the invariant that survives the estimator change._disk_older_count; the long-history test now models the realistic frozen prefix (disk_older=8).Gates:
isort/flake8/mypyclean;test_session_transfer.py134/134 andtest_dashboard_chat.py645/645 green; full suite's 82 fails + 2 errors reproduced byte-identical on pristine base (host-env: sandbox confinement, AF_UNIX path length), none touching this area.Manual verification
N/A -- unit coverage sufficient: the changed function is pure data-flow over a fake-able slot/log pair, and the shipped async endpoint is intentionally unchanged.
Closes #4684