Repository navigation
Description
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 isconst gone = isHomeLevelRemote(...) || (!handles.has(normRemoteHandle(bot.remote.handle)) && !(room && rooms.has(room)))(:304-305). Stored duplicate rows all carry the samechatRoomKey(bot)(sameremote.home, samename), soroom && rooms.has(room)is true for every one of them: none is gone, andsync()has no merge or removal step at all.open()(:254) anddeliver()(:334) resolve a chat withchatOfHandlefirst, 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-50shows why agonemarker 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)
- The line lives in a DSH Session, not in
state:chatOf(bot.id) === bot.sessionId(store.js:1145) and the reader issessionController.page→records[].event(chat-index.js:87-103,parts.js:1341-1346).state.exchangesis delivery bookkeeping, not chat history. - 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. - But "several Sessions shown as one chat" already exists:
bot.segments(earlier parts) +bot.sessionId(current), composed bypartsOf(parts.js:299-308);earlierPagereads them andchat-indexindexes them (:313,:401). So the merge hands each duplicate's Session to the survivor as an earlier part — nothing is copied, replayed or rewritten. - Deleting the duplicate rows without hanging their Sessions on the survivor is deleting the user's history (
earlierPageneedschatOwners/pastOwners, built from bots + segments at load;retiredSessionsrefuses 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 andhiddenflag; 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
goneflag: 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
#2797already guarantees. Two different names at one home, and one name at two homes, stay two rows. - The user's own
hiddenflag 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 insync(), andopen()/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.jsandpatches/ds-bot/0002-flybot-api-host.patch—generate+check.docs/subsystems/core-host.md(the remote-chat paragraph) and onechangelog.d/2804-remote-room-merge.fixed.mdfragment.
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.
-
An install seeded with 8 duplicate rows of one room → one
sync('social', the 8 members)→remoteChats.list()has one row for that room andstate.botsno longer holds the seven duplicates. Measured onf42ad016: 8 rows, 0 gone — red8 !== 1. -
The survivor's history, read the way
partsOfcomposes 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 ownsegmentskeeps those parts too, in order. -
deliver(member2)returns the survivor's id and appends there (measured red today: returnsd2, the duplicate's id). -
No
agent.followup/injectis 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). -
A fresh install:
#2797's four cases stay byte-identical green and one room is one row. -
Two names at one home, and one name at two homes, remain two rows.
-
The user's
hiddenflag and theremote.gone/hiddenByGonerules are unchanged; a sync with no duplicates writes nothing. -
remote-chat+remote-roomstay141/141green (remote-approval-waittoo,142/142with the new file), and the new file runs red first at the PR base. -
The Session that becomes the chat's own was opened for a different row's handle, and
applyModelreturns without re-selecting whenbot.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, neverNot sent. -
An open question card in a duplicate row is answered on the chat's current Session (
channel.js:568-593resolves an earlier part to it) and a rotation moves the card while leaving itscallIdin 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)
- 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 touchingremote-chat→ 0.#2797and#2801are closed. - Local ledger: no open issue or PR on this face (
P-684/P-686areread_group_chat;P-687/P-688are 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
HugoXK commentedon Nov 11, 2026
Heartbeat
Written-by: FlyBot Lead
Still-working: 92849141a93448c3bbc08e96eeddf4a1
At: 2026-11-11T00:21:27Z
HugoXK commentedon Nov 11, 2026
Heartbeat
Written-by: FlyBot Lead
Still-working: 92849141a93448c3bbc08e96eeddf4a1
At: 2026-11-11T00:44:12Z
fix(remote-chat): drop the merged rows before the merge's one await (T…
HugoXK commentedon Nov 11, 2026
Heartbeat
Written-by: FlyBot Lead
Still-working: 92849141a93448c3bbc08e96eeddf4a1
At: 2026-11-11T02:46:18Z