Skip to content

remote-chat: a room already stored as one row per member is never merged — duplicates keep their own history and lines (T3) #2804

Closed

Description

@HugoXK

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment on this issue.

Written-by: FlyBot Lead
Claimed-by: FlyBot Lead
Claim-Id: 92849141a93448c3bbc08e96eeddf4a1
Branch: dev/3-remote-room-merge
Base: f42ad01 — (reproduction base at issue creation; the PR branch is cut from live main at PR time)
At: 2026-11-10T10:35:26Z

Category: defect — an install that already carries one remote-chat row per member of a published room is never merged. #2797 (PR #2801, 61e44034) stopped a fresh publish from opening one row per member, but it does nothing to the rows an older build already wrote: every one of them still counts as "the room is still published", keeps its own history, and each member's line keeps landing in its own row. This is not a re-open of #2797: the fresh-publish half is done and stays exactly as it is.

Risk tier: T3. core/src/host/remote-chat.js is in risk-rules.json's T3 path list, so this needs a Verdict: PASS naming the PR tip, plus the Security advisory.

What happens (live main f42ad016649c, 2026-11-10T10:13:55Z)

  • sync() (core/src/host/remote-chat.js:292-305) opens each room once per publish, but its gone check for an existing chat is const gone = isHomeLevelRemote(...) || (!handles.has(normRemoteHandle(bot.remote.handle)) && !(room && rooms.has(room))) (:304-305). Stored duplicate rows all carry the same chatRoomKey(bot) (same remote.home, same name), so room && rooms.has(room) is true for every one of them: none is gone, and sync() has no merge or removal step at all.
  • open() (:254) and deliver() (:334) resolve a chat with chatOfHandle first, so the "real" row is whichever handle was opened first, and every remaining duplicate keeps taking its own member's lines.
  • core/src/shared/remote-label.js:34-50 shows why a gone marker is not a fix: a gone row is still shown in the sidebar (marked "no longer shared"), so the merge has to move the lines and remove the duplicate rows, not flag them.

Reproduction (measured on real installs, twice)

Old tree 5b8d980f, real install() + sync('social', [8 members naming the same room]):

PREFIX-ROWS: 8            (8 rows; every remote.gone empty)

New tree f42ad016, seeding that saved state into state.bots and running one sync('social', the same 8 members):

ROWS-AFTER-SYNC: 8
GONE-ROWS: 0
SHOWN-ROWS: 8
LANDED-IN: d2             deliver(member2) lands in member2's own duplicate row

Both trees are green on remote-chat.test.js + remote-room.test.js (141/141): #2797's four cases cover a fresh publish only, so this half is untested.

Red-first reading at f42ad016 (core/test/remote-room-merge.test.js, 6 cases: the 3 acceptance readings plus 3 controls that must stay green), run twice with the same shape:

# tests 6 / pass 3 / fail 3
8 !== 1                                   (after one sync: one row, not eight)
the survivor's history lost or reordered a duplicate's line: ["line from member1"]
'd2' !== 'd1'                             (deliver(member2) returns member2's row, not the survivor)

With the sketch below applied to that same tree: 6/6 green, and remote-chat + remote-room + remote-approval-wait 142/142.

Feasibility (read, not assumed)

  1. The line lives in a DSH Session, not in state: chatOf(bot.id) === bot.sessionId (store.js:1145) and the reader is sessionController.page → records[].event (chat-index.js:87-103, parts.js:1341-1346). state.exchanges is delivery bookkeeping, not chat history.
  2. There is no "merge one Session's history into another" operation, and the only write path is agent.followup/inject + ctx.sessions.flush — that is replay, which rewrites authored text (an assistant reply would come back as a user line), so it is rejected.
  3. But "several Sessions shown as one chat" already exists: bot.segments (earlier parts) + bot.sessionId (current), composed by partsOf (parts.js:299-308); earlierPage reads them and chat-index indexes them (:313, :401). So the merge hands each duplicate's Session to the survivor as an earlier part — nothing is copied, replayed or rewritten.
  4. Deleting the duplicate rows without hanging their Sessions on the survivor is deleting the user's history (earlierPage needs chatOwners/pastOwners, built from bots + segments at load; retiredSessions refuses calls but serves no history). That is not a permissible fix here.

Expected

  • One published room is one row after the first sync(), including an install that already holds duplicates: the rows are merged into one survivor. The oldest row keeps the id, name and hidden flag; the newest row's Session becomes the survivor's current Session, and every other row's Session becomes an earlier part of the survivor in creation order — including any earlier parts a duplicate row already carried — so the chat reads oldest → newest and stays searchable.
  • No message is copied, replayed or rewritten, and no delivery hook (agent.followup/inject) is called by the merge: an assistant reply inside a duplicate stays a reply. The honest consequence, which this issue accepts: the merged history shows as earlier parts with the existing part divider (the same shape a part rotation produces), not as one seamless Session.
  • From then on any member's line lands in that one row, and open()/deliver() resolve the survivor.
  • Not a gone flag: gone rows stay in the sidebar by design (#956), so a merge that only marks them has not fixed the user-visible row count.
  • A fresh install (no duplicates) behaves byte-identically: one row, as #2797 already guarantees. Two different names at one home, and one name at two homes, stay two rows.
  • The user's own hidden flag is never touched; a line already in a conversation is never deleted; a publish with no duplicates writes nothing.

Change scope (S–M)

  • core/src/host/remote-chat.js — the merge in sync(), and open()/deliver() resolving the survivor first.
  • core/test/remote-room-merge.test.js (new) — the acceptance cases below, with the three controls.
  • patches/ds-bot/files/src/host/remote-chat.js and patches/ds-bot/0002-flybot-api-host.patch — generate + check.
  • docs/subsystems/core-host.md (the remote-chat paragraph) and one changelog.d/2804-remote-room-merge.fixed.md fragment.

Acceptance (red first)

Bar (Chief, 2026-11-10): after the merge, every line a user could read in those rows is still readable there, verbatim. If the merge cannot do that, the approach is not acceptable — stop and report the options instead of opening a PR; a data-loss decision is not ours and not this issue's.

  1. An install seeded with 8 duplicate rows of one room → one sync('social', the 8 members) → remoteChats.list() has one row for that room and state.bots no longer holds the seven duplicates. Measured on f42ad016: 8 rows, 0 gone — red 8 !== 1.

  2. The survivor's history, read the way partsOf composes it (earlier parts oldest first, then the current Session), holds every line the eight rows held, in order, with no line dropped and none duplicated; a duplicate row that already carried its own segments keeps those parts too, in order.

  3. deliver(member2) returns the survivor's id and appends there (measured red today: returns d2, the duplicate's id).

  4. No agent.followup/inject is called by the merge and no event text changes: the test reads the same Session events the host would read, before and after, and asserts they are unchanged (an assistant reply stays an assistant reply).

  5. A fresh install: #2797's four cases stay byte-identical green and one room is one row.

  6. Two names at one home, and one name at two homes, remain two rows.

  7. The user's hidden flag and the remote.gone / hiddenByGone rules are unchanged; a sync with no duplicates writes nothing.

  8. remote-chat + remote-room stay 141/141 green (remote-approval-wait too, 142/142 with the new file), and the new file runs red first at the PR base.

  9. The Session that becomes the chat's own was opened for a different row's handle, and applyModel returns without re-selecting when bot.appliedModel === ref (models.js:225-231), so the merge puts the survivor's own Session on its own handle route itself — a first line typed into the merged row is sent, never Not sent.

  10. An open question card in a duplicate row is answered on the chat's current Session (channel.js:568-593 resolves an earlier part to it) and a rotation moves the card while leaving its callId in the old part (parts.js:1009-1013), so the merge moves it the same way. If more than one row of that room carries an open card, the room is not merged this pass (no change, retried on the next sync/open/deliver) rather than dropping a card the user has to answer.

Dedupe (2026-11-10)

  1. GitHub: remote chat duplicate rows existing installation → closed #2010/#1764/#1045/#1085/#1070/#129 (nothing on this face); remote chat rows duplicated after upgrade → 0; open PRs touching remote-chat → 0. #2797 and #2801 are closed.
  2. Local ledger: no open issue or PR on this face (P-684/P-686 are read_group_chat; P-687/P-688 are the Feedback page).

Slot: the PR opens only when a slot frees (the team's in-flight count is frozen until then); the branch, the red-first test and the sketch are held locally until that moment. This issue does not take a review slot.

Written-by: FlyBot Lead
Bot: FlyBot Dev-3
Found by: FlyBot Product (round-54 audit)

Activity

  1. HugoXK commented on Nov 11, 2026

    @HugoXK
    ContributorAuthor

    Heartbeat

    Written-by: FlyBot Lead
    Still-working: 92849141a93448c3bbc08e96eeddf4a1
    At: 2026-11-11T00:21:27Z

  2. HugoXK commented on Nov 11, 2026

    @HugoXK
    ContributorAuthor

    Heartbeat

    Written-by: FlyBot Lead
    Still-working: 92849141a93448c3bbc08e96eeddf4a1
    At: 2026-11-11T00:44:12Z

  3. HugoXK commented on Nov 11, 2026

    @HugoXK
    ContributorAuthor

    Heartbeat

    Written-by: FlyBot Lead
    Still-working: 92849141a93448c3bbc08e96eeddf4a1
    At: 2026-11-11T02:46:18Z

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions