fix(channels): persist a durable row id for channel-born transcript rows - #7646
Conversation
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) — ✅ PASSPremise-level review of The evidence checks out. I ran the counts the contract requires: First-Principles-Verdict: PASS A reported display defect, fixed at the writer that owns the row's first existence, with the one duplicate spelling of the id format deleted rather than added to. What this change shipsIntent: stop the dashboard rendering one channel reply several times — a FIX.
No item's zero option is free (the defect is user-reported), no existing mechanism does the job (the dual-writer [FIRST-PRINCIPLES-REVIEWED] a51e898 |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause fix at the correct layer — identity minted once by the writer, single format owner, the one dual-writer exception threaded correctly — with residual gaps (Slack, legacy rows) explicitly scoped and disclosed. [DESIGN-REVIEWED] a51e898 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
|
Disposition for the three findings on head GPT BLOCKING -- Verified against Fixed with the dual-writer shape Design Review named, not by dropping the id: Four new tests pin it, including the mirrored-branch coverage Design Review asked for and whose absence it correctly flagged: First Principles -- WhatsApp is an undeclared unfixed sibling -- FIXED, the count was wrong. Confirmed: First Principles -- replace the per-call-site edits with one default in I implemented this subtraction in full before rejecting it, because the reasoning is good: it needs no call-site edits, and it would cover Slack, WhatsApp and eval for free. Two things came back:
So the subtraction is a real improvement that belongs in its own change, where breaking Verification on |
d08ede5 to
dc927d9
Compare
…ing channel replies twice A channel dispatcher writes its transcript rows with no `meta.mid`, so every surface that materializes the row mints a fresh one and the same logical reply carries a different identity on every pass. The dashboard's merge keys on `meta.mid` alone -- `isRedeliveredMessage`, `olderHeadAbovePage`'s cut and `rowIdentities`/`tailNotInPage` all decline rather than guess when the id is absent or changed -- so an unstable id degrades all three at once and one reply can render more than once. Mint the id at write time, where the row first exists. `mint_row_mid` in history.py is now the single definition of the format, used by both `_ChatSlot.append` and the nine dispatchers that share the two-append `_persist_turn` shape (weixin, telegram, discord, feishu, imessage, teams, webex, wecom, whatsapp). Discord's mirrored branch is the exception: it DOES have a live slot, so `_mirror_turn_to_live_slot` now returns the ids the slot minted and `_persist_turn` threads them into `append_if_absent` -- the dual-writer shape `cron_inject` uses. Minting a second id there would defeat that call's same-mid skip and persist the turn twice on disk. Slack is out of scope: its persist is a different topology. Closes #5981
|
Disposition for First Principles on Subtraction -- drop Verified the sole-caller claim rather than taking it on trust:
I also deleted Slack -- accepted-and-deferred, restated with the count you grepped. Verification on Also fixed the Still outstanding: GPT reported "review incomplete" for |
dc927d9 to
a51e898
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Reviewed via parallel subagent audit: diff matches description, CI fully green, no blocking findings, no unresolved threads.
…ing door
A family of call sites appended a row to the chat window and then hand-built a
second broadcast_ws("chat_message", ...) frame for it. slot.append already
delivers one live frame carrying the row's minted meta.mid (via _on_message ->
_broadcast_chat_message) whenever no HTTP stream reader is active, so the
manual frame shipped the SAME row a second time -- and carried no meta.mid, the
one field the client's redelivery guard (isRedeliveredMessage) keys on. The
guard declines mid-less frames rather than guessing, so each extra copy
rendered as a new bubble: one duplicate per emitted segment of a multi-step
channel turn fits the "same reply rendered 5 times" report in #5981.
New helper append_and_surface (state.py) is the one door: append (identity
minted, single delivery), plus a manual frame ONLY when slot._has_reader
suppresses append's own callback -- and that frame carries the row's ts + meta
(mid included), so a client seeing a row through two doors recognises "this
row again" instead of duplicating. Converted sites:
- chat_orchestrator.py: cancelled-latch stop message, stage-complete message,
stage summary, plan-cancelled message (assistant double-emits); the Go/Go All
label (a user row append skips by default -- now broadcast_user=True, one
mid-carrying delivery instead of a sole mid-less manual frame)
- chat_handlers.py: orchestration-stopped message
- chat_runner.py: conversation-cleared confirmation (slot_clear reordered
BEFORE the append: the wipe must precede the confirmation on every path or
it erases the row it announces)
- chat_utils.py: the compaction-notice chokepoint (kind=compaction preserved
on frame and meta)
- workflow_inject.py: workflow-result injection (window_mid still read off the
append for the durable copy)
- handlers/files.py: the file-card site that pioneered the conditional
pattern -- its reader frame now carries ts + meta.mid too
- slack/handler.py: the Slack->dashboard user mirror (broadcast_user=True)
- crew_chat.py: keeps its deliberate broadcast=False + manual frame, but the
frame now ships the APPENDED row's meta (mid included) instead of the
pre-append dict that never had the id
The persistence half of #5981 (channel rows saved without their mid, re-minted
on every rebuild) was fixed separately in #7646; with both halves in place the
remaining frontend sweep in #5982 catches any residual same-mid duplicate.
Tests: test_midless_broadcast_dedup.py pins the helper contract (single
delivery without a reader; identity-carrying frame with one; user-row
broadcast_user semantics; extra fields) and the compaction site end-to-end
(red-before proven: the old site double-delivers, assert 2 == 1). Existing
tests that pinned the old always-broadcast behaviour re-pinned to the new
contract; test doubles updated to mirror the real append contract (return the
row, mint a mid, model _on_message/_has_reader).
Credit: root-cause investigation of the mid-less double-broadcast producer by
isotope14's PR #5982 review cycle.
Fixes #5981
Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
…ing door
A family of call sites appended a row to the chat window and then hand-built a
second broadcast_ws("chat_message", ...) frame for it. slot.append already
delivers one live frame carrying the row's minted meta.mid (via _on_message ->
_broadcast_chat_message) whenever no HTTP stream reader is active, so the
manual frame shipped the SAME row a second time -- and carried no meta.mid, the
one field the client's redelivery guard (isRedeliveredMessage) keys on. The
guard declines mid-less frames rather than guessing, so each extra copy
rendered as a new bubble: one duplicate per emitted segment of a multi-step
channel turn fits the "same reply rendered 5 times" report in #5981.
New helper append_and_surface (state.py) is the one door: append (identity
minted, single delivery), plus a manual frame ONLY when slot._has_reader
suppresses append's own callback -- and that frame carries the row's ts + meta
(mid included), so a client seeing a row through two doors recognises "this
row again" instead of duplicating. Converted sites:
- chat_orchestrator.py: cancelled-latch stop message, stage-complete message,
stage summary, plan-cancelled message (assistant double-emits); the Go/Go All
label (a user row append skips by default -- now broadcast_user=True, one
mid-carrying delivery instead of a sole mid-less manual frame)
- chat_handlers.py: orchestration-stopped message
- chat_runner.py: conversation-cleared confirmation (slot_clear reordered
BEFORE the append: the wipe must precede the confirmation on every path or
it erases the row it announces)
- chat_utils.py: the compaction-notice chokepoint (kind=compaction preserved
on frame and meta)
- workflow_inject.py: workflow-result injection (window_mid still read off the
append for the durable copy)
- handlers/files.py: the file-card site that pioneered the conditional
pattern -- its reader frame now carries ts + meta.mid too
- slack/handler.py: the Slack->dashboard user mirror (broadcast_user=True)
- crew_chat.py: keeps its deliberate broadcast=False + manual frame, but the
frame now ships the APPENDED row's meta (mid included) instead of the
pre-append dict that never had the id
The persistence half of #5981 (channel rows saved without their mid, re-minted
on every rebuild) was fixed separately in #7646; with both halves in place the
remaining frontend sweep in #5982 catches any residual same-mid duplicate.
Tests: test_midless_broadcast_dedup.py pins the helper contract (single
delivery without a reader; identity-carrying frame with one; user-row
broadcast_user semantics; extra fields) and the compaction site end-to-end
(red-before proven: the old site double-delivers, assert 2 == 1). Existing
tests that pinned the old always-broadcast behaviour re-pinned to the new
contract; test doubles updated to mirror the real append contract (return the
row, mint a mid, model _on_message/_has_reader).
Credit: root-cause investigation of the mid-less double-broadcast producer by
isotope14's PR #5982 review cycle.
Fixes #5981
Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
…ing door (#7981) A family of call sites appended a row to the chat window and then hand-built a second broadcast_ws("chat_message", ...) frame for it. slot.append already delivers one live frame carrying the row's minted meta.mid (via _on_message -> _broadcast_chat_message) whenever no HTTP stream reader is active, so the manual frame shipped the SAME row a second time -- and carried no meta.mid, the one field the client's redelivery guard (isRedeliveredMessage) keys on. The guard declines mid-less frames rather than guessing, so each extra copy rendered as a new bubble: one duplicate per emitted segment of a multi-step channel turn fits the "same reply rendered 5 times" report in #5981. New helper append_and_surface (state.py) is the one door: append (identity minted, single delivery), plus a manual frame ONLY when slot._has_reader suppresses append's own callback -- and that frame carries the row's ts + meta (mid included), so a client seeing a row through two doors recognises "this row again" instead of duplicating. Converted sites: - chat_orchestrator.py: cancelled-latch stop message, stage-complete message, stage summary, plan-cancelled message (assistant double-emits); the Go/Go All label (a user row append skips by default -- now broadcast_user=True, one mid-carrying delivery instead of a sole mid-less manual frame) - chat_handlers.py: orchestration-stopped message - chat_runner.py: conversation-cleared confirmation (slot_clear reordered BEFORE the append: the wipe must precede the confirmation on every path or it erases the row it announces) - chat_utils.py: the compaction-notice chokepoint (kind=compaction preserved on frame and meta) - workflow_inject.py: workflow-result injection (window_mid still read off the append for the durable copy) - handlers/files.py: the file-card site that pioneered the conditional pattern -- its reader frame now carries ts + meta.mid too - slack/handler.py: the Slack->dashboard user mirror (broadcast_user=True) - crew_chat.py: keeps its deliberate broadcast=False + manual frame, but the frame now ships the APPENDED row's meta (mid included) instead of the pre-append dict that never had the id The persistence half of #5981 (channel rows saved without their mid, re-minted on every rebuild) was fixed separately in #7646; with both halves in place the remaining frontend sweep in #5982 catches any residual same-mid duplicate. Tests: test_midless_broadcast_dedup.py pins the helper contract (single delivery without a reader; identity-carrying frame with one; user-row broadcast_user semantics; extra fields) and the compaction site end-to-end (red-before proven: the old site double-delivers, assert 2 == 1). Existing tests that pinned the old always-broadcast behaviour re-pinned to the new contract; test doubles updated to mirror the real append contract (return the row, mint a mid, model _on_message/_has_reader). Credit: root-cause investigation of the mid-less double-broadcast producer by isotope14's PR #5982 review cycle. Fixes #5981 Co-authored-by: Nick Bowers <nrb@amazon.com> Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
What is the problem?
Viewing a channel-born session (Weixin/iLink) in the dashboard renders the assistant's reply several times - reported as 5x on one turn - while the on-disk transcript holds exactly one copy of the message.
A channel dispatcher persists its rows through
ConversationLog.appendwithout amid, so the JSONL row gets nometaat all._ChatSlot.appendmints ameta.midfor any row that lacks one, andchannel_slots._rebuild_window/refresh_channel_windowboth materialize the disk tail through that append - so the same logical row gets a fresh identity on every materialization, and the identity the dashboard was handed a moment ago is not the identity it is handed next time.That matters because the dashboard's merge keys on
meta.midand nothing else, and each consumer is deliberately written to decline rather than guess when the id is absent or has changed:isRedeliveredMessage(chatSlice.ts:90) returnsfalsefor a missing mid, so a redelivered channel row is never recognised as one.olderHeadAbovePage(chatSlice.ts:1374) locates the retained scrollback head bypage[0].meta.mid; a changed mid makesfindIndexreturn-1.rowIdentities(chatSlice.ts:1265) derives identity frommeta.mid/meta.sendIdonly, so a row with neither yields an empty identity array - andtailNotInPage(:1280) therefore treats it as "not present in the page" and always retains it.With
cutIdx < 0permanent for a channel session, the warm-cache merge runs in its degraded branch, wherepriorEndsBeforePageunions[...prior, ...tailNotInPage(warmed, prior)]- appending a copy the pane already holds, once per pass.Why this issue matters to the user
The dashboard's view of a Weixin/Telegram/Discord conversation is visibly wrong: a reply is repeated several times, so the history reads as though the agent answered over and over. Because the duplication is display-side, nothing in the transcript looks broken and there is no obvious recovery - and since the identity is re-minted on every window rebuild, re-opening the tab can produce a different wrong answer rather than a fixed one. The same root cause also degrades scrollback retention in the other direction: a declined cut can drop history the pane had already paged in.
How our fix solves it
The identity has to come from the writer, because only the writer sees the row once.
A channel turn runs on the dispatcher's own session, so unlike the dashboard dual-writers (
cron_inject,workflow_inject,crew_chat, which read the id back offslot.appendand pass it toappend_if_absent) there is normally no slot to mint from. The dispatcher is the first and only place the row exists, so it now mints its own id and persists it.history.pygainsmint_row_mid(), the single definition of themeta.midformat._ChatSlot.appendnow calls it instead of spellingf"m-{uuid4().hex[:16]}"inline, so the format cannot drift between the two mint sites.ConversationLog.appendalready acceptedmidand already wrote it asmeta.mid; no storage change was needed, and its id-less contract (TestAppendMid) is untouched._persist_turnshape now passmid=mint_row_mid()on each row: weixin, telegram, discord, feishu, imessage, teams, webex, wecom, whatsapp.mirroredbranch is the one exception, because it DOES have a live slot._mirror_turn_to_live_slotnow returns the(user_mid, assistant_mid)the slot minted and_persist_turnthreads them intoappend_if_absent, so both copies of one row share ONE identity. Minting a second id there would defeat that call's same-mid skip -append_if_absentonly skips a body-equal row carrying the same mid - and persist the turn twice on disk. An earlier revision of this PR had exactly that bug; it is fixed and pinned by four tests.Chaining it back to the symptom: the row now reaches disk with
meta.mid;_rebuild_window/refresh_channel_windowpass thatmetastraight intoslot.append, which preserves a caller-supplied mid rather than minting; so every materialization resolves to the same identity,isRedeliveredMessagecan recognise a redelivery,olderHeadAbovePagecan find its cut, and the union branch that was appending a duplicate is no longer reached.Two deliberate exclusions. Slack stays out: its persist is a different topology (three write branches, off-loop
to_threadstamping, an options-turn path) and folding it in would mean reworking that path rather than adding a kwarg. And the other half of the producer analysis on #5981 - removing manualbroadcast_ws("chat_message", ...)frames that follow aslot.append- is not here: I traced each candidate site (chat_orchestrator.py238/809/847/1022/1068,chat_handlers.py617/778,workflow_inject.py:173,crew_chat.py:1164,slack/handler.py:2538,slack/gateway.py5902/6821,handlers/files.py:326) and none is reachable from a channel-born turn - a weixin turn never enters the dashboard orchestrator, and the weixin package contains nobroadcast_ws/slot.appendcall site at all.What tests we did
New
test/test_channel_row_identity.py- 36 tests, 33 red on pristine base (origin/main1ee69f225), verified in a detached base worktree:test_persist_turn_stamps_a_durable_mid_on_both_rows- parametrized over all 9 channels, drives the real unbound_persist_turnagainst a realConversationLogand assertsmeta.midon the raw JSONL (read off the file, not through a reader that could re-derive an id).test_persist_turn_ids_are_unique_across_turns- 4 rows over 2 turns, 4 distinct ids. A reused id makes a client drop a real row, the opposite failure to a duplicate.test_persist_turn_still_writes_an_empty_reply- the empty-reply guard is unchanged.TestDiscordMirroredBranchStaysIdempotent- 4 tests on the exception path, including the real race (slot save lands the row, then the mirror retries it) which was red under the previous head.TestMintRowMid- format and distinctness over 512 mints. Imported inside each test on purpose, so the rest of the module still collects on a tree without the helper and the behavioural tests fail on their assertions rather than the file erroring out on an ImportError.TestIdentitySurvivesMaterialization- 3 characterization tests that pass on base:channel_slotsalready preserves a persisted mid and already re-mints an absent one. They are what make the fix load-bearing rather than incidental, and they pin the re-mint behaviour that must stay for legacy rows.Structured after
test_channel_persist_agent_metadata.py(#2890), which locks the same "every channel omitted a kwarg on its persist writes" shape.Eight existing
conv_logtest doubles were widened to acceptmid, matching the real signature (weixin, whatsapp, discord_sessions, webex, wecom, feishu, teams dispatch tests). Enumerated in one pass rather than discovered per CI round.2893 passed across every affected channel suite plus
channel_slots,history(TestAppendMidintact),cron_history,chat_row_identityand the discord suite. flake8, isort, black clean on all 19 changed files. Inherited, reproduces on pristine base:test_weixin_qr.pyfails 2 withModuleNotFoundError: No module named 'qrcode'(missing optional dep in my environment, unrelated to this diff).Pattern harvest
Rule candidate: semgrep (or a review checklist item)
Pattern: a dual-writer that mints a fresh identity for its durable copy instead of reusing the one its in-memory copy already carries
The generalizable defect is not "channels forgot a kwarg" - it is that a row's identity must be minted once per row, not once per write. Two shapes are worth catching mechanically:
ConversationLog.append_if_absent(..., mid=<freshly minted>). That call's skip is defined as "body-equal AND same mid", so a fresh id makes it structurally unable to skip and converts an idempotent write into an unconditional one. Anymid=argument there that is not derived from a priorslot.appendreturn (row_mid(...)) or a stored value is a bug. This is exactly what GPT caught on headd08ede5fd, and it is the second time this class has appeared:chat_runner.py:9287's comment already warns about the sibling case (append plus a manual re-broadcast).meta.mid. The correct shape is documented three times in-tree (cron_inject.py:136-167,workflow_inject.py:167-198,crew_chat.py:1163-1184) -window_mid = row_mid(slot.append(...))thenappend_if_absent(..., mid=window_mid)- which is a strong signal it should be a lint rule or a small shared helper rather than a pattern each writer re-derives.Not generalizable: the specific list of nine dispatchers is not a reusable rule. That breadth is a consequence of the same
_persist_turnshape having been copied per channel, which #2890 already hit for theagentkwarg - the durable lesson there is that a per-channel copy of one persist routine will keep needing the same fix N times, and is a refactor candidate in its own right.Any other suggestions on the work?
deduplicateByMidsweep, currently held by a First Principles BLOCK whose confirmed half is that the sweep cannot reach the producer. The two are complementary: with ids now stable, a future duplicate of a channel row becomes a same-mid duplicate, which is exactly what that sweep collapses. Nothing here conflicts with it, and the diffs share no file.ConversationLog.appendis a real improvement, deliberately not taken here. It would need no call-site edits and would cover Slack, WhatsApp and eval at once. I implemented it in full before reverting: it breakstest_history.py::TestAppendMid, which explicitly pins that an id-less append must not grow ametafield. Forking a documented identity contract belongs in its own change where that can be argued on its merits, not folded into a bug fix. Recorded as a follow-up.TestAppendMidcontract) and worth an explicit decision rather than a silent migration._persist_turnstill has the gap and deserves its own change, sized for its three-branch write path.Closes #5981