Skip to content

bug(host): deleting a Bot leaves it a live room participant, and the next membership write puts its id back into room.members #2607

Closed

Description

@Birfy

Written-by: Chief-audit

Dangling reference (audit 2026-11-07, main b75b07d2848a702bc48795744cd173beb425db42): deleting a Bot leaves it a live participant of every materialized group, and the next membership change writes its id back into room.members.

  • core/src/host/roster.js:653 — room.members = room.members.filter(member => member !== id) (in deleteBot)
  • core/src/host/room-membership.js:66 — return (room?.participants ?? []).filter(p => p.leftSeq === undefined) (currentParticipants: the Bot is still current)
  • core/src/host/room-membership.js:411 — room.members = currentParticipants(room).filter(p => p.kind === 'bot' && !p.remote).map(p => p.id) (syncMembers: the deleted id comes back)

What is deleted, and what keeps the id. deleteBot removes state.bots[id], then for every room that lists the Bot it filters the legacy room.members array and drops its group Session. A room whose participants were materialized (room.participants — every room that ever had a member added/removed or a settings save) keeps a { kind: 'bot', id } entry with no leftSeq, because nothing in deleteBot touches participants. currentParticipants therefore still answers that the Bot is seated, and syncMembers — called by the next add/remove/participants write, applyParticipants — rebuilds room.members from it, undoing the deletion in the array the rest of the host reads.

Leaving sequence and the exact JSON. State: bots chief, writer, coder; room room with members: ["chief","writer"], admin: "chief", and participants [{kind:"human",id:"owner"},{kind:"bot",id:"chief"},{kind:"bot",id:"writer"}]. Reproduced with createRuntime + fakeContext and real calls (rt.deleteBot('writer'), rt.addRoomParticipant('room', {kind:'bot', id:'coder'})).

  • Before deleteBot: members: ["chief","writer"], participants human:owner live, bot:chief live, bot:writer live.
  • After deleteBot('writer'): members: ["chief"] — but participants unchanged, bot:writer still live (no leftSeq), and rt.botOf('writer') is undefined. rt.participantsOf(rt.state.rooms.room) (line 441, the read behind the room drawer and the share envelopes) answers ["human:owner","bot:chief","bot:writer"].
  • After one ordinary membership write (addRoomParticipant('room', {kind:'bot', id:'coder'})): members: ["chief","writer","coder"] — the deleted id is back — and members.map(id => rt.botOf(id)?.name) is ["Chief", null, "Coder"].

Observed silent drop. autoRoomName (roster.js:271-273, used on every membership change and again on every boot by store.js:355) maps room.members through rt.botOf(id)?.name and ends with .filter(Boolean), so the dead id is silently dropped from the group's name — the name reads clean while the array it came from still names nothing. roomRoster (room-membership.js:1078-1085) goes the other way for a shared room: it keeps bot?.name || p.displayName || p.id, so the deleted Bot is published to the other home's guests as a seated member under its old displayName, and reshareRoster sends that roster on the next membership change. Nothing is ever refused and nothing logs.

Expected. Deleting a Bot ends its seats the way leaving does: its participant entry gets a leftSeq/leftAt (or is removed) in the same save, syncMembers runs, and room.members is not written by a separate filter that the participants can undo. A later read must not answer a Bot that state.bots no longer holds, and a guest's roster must not carry it.

Smallest fix. In deleteBot's room loop, mark the Bot's participant entry left before saving — const p = currentParticipants(room).find(x => x.kind === 'bot' && x.id === id); if (p) { p.leftSeq = room.seq ?? 0; p.leftAt = Date.now() } — then call the module's syncMembers(room) instead of room.members = room.members.filter(...). removeRoomParticipant cannot be reused as-is because it enforces the two-Bot floor and can refuse a deletion that must not fail. Rooms without a participants array keep the current members filter.

Not covered by an open issue. Searched open issues for participants / delete-bot / room.participants: none names this; #1819 is about remote seats after a face-to-face room ends, a different path.

Ran: node --import ./test/harness-resolve.mjs --test test/audit-dangling.test.js in a fresh checkout of b75b07d, one file, with the harness packages at 0.2.1-rc.2; the test printed the before/after JSON above and asserts both the stale participant and the re-added members entry.

Independent re-check (Chief, 2026-11-07T12:24Z, main b75b07d2848a702bc48795744cd173beb425db42)

Re-ran the reproduction myself, one file, on a fresh checkout of that sha:

$ cd core && TZ=UTC DSH_HARNESS=<harness 0.2.1-rc.2> node --import ./test/harness-resolve.mjs --test test/audit-dangling.test.js
ok 1 - audit: deleteBot leaves a live participant entry ...
# AFTER members: ["chief"]
# AFTER participants: [{"kind":"human",...},{"kind":"bot","id":"chief",...},{"kind":"bot","id":"writer",...}]   (writer still live, no leftSeq)
# participantsOf: ["human:owner","bot:chief","bot:writer"]
# AFTER add coder members: ["chief","writer","coder"]
# room.members names: ["Chief",null,"Coder"]
1..1  # pass 1  # fail 0

The Verify node of the audit run dropped this candidate on the theory that applyRoster → syncMembers would clear the seat on the next boot. That path re-derives the seat from the home roster but does not clear a participant that already exists without leftSeq, and the state is saved by the following membership write, so the stale id survives a reload; the run above is on a runtime loaded from state.json, which is the same shape a boot produces.

Activity

  1. added
    bugSomething isn't working
    on Nov 7, 2026
  2. Birfy commented on Nov 7, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Chief

    Reproduced myself at b75b07d2 (see the re-check section above), so this is agent-ready. Scope for the fix:

    Files. core/src/host/roster.js (deleteBot 618-670, the room loop at :645-660) and, for the shared helper, core/src/host/room-membership.js (currentParticipants :66, syncMembers :411). Do not touch applyRoster (:1248) — it only owns fromRoster seats on a shared mirror and correctly leaves local seats alone.

    Behaviour. Deleting a Bot must end its seat in every room in the same save: its participant entry gets leftSeq/leftAt (or is removed when the room has no participants array), then room.members is derived through syncMembers(room) instead of a separate room.members.filter(...) the participants can undo. After the delete, no read may answer a Bot that state.bots no longer holds: participantsOf(), roomRoster() on a shared room, and room.members itself.

    Cannot reuse removeRoomParticipant. It enforces the two-Bot floor and can refuse; a deletion must not fail because a room would drop below the floor.

    Red first. The existing repro is core/test/audit-dangling.test.js in the audit checkout; turn it into a test under core/test/ that fails on the current code (deleted id back in room.members after one membership write, and the stale participant still current) and passes after the fix. Also assert the persist step: save, reload from the same state.json, and the deleted id is still absent.

    Acceptance.

    • cd core && TZ=UTC node --import ./test/harness-resolve.mjs --test test/<new file>.test.js — red before, green after.
    • Revert the source change, the same file goes red again.
    • npm run lint in core/ (arch-docs: core/src/host/** is owned — either update the matching subsystem doc or carry Docs-not-needed: in the commit and the PR body).
    • One PR, Fixes #2607, Owner: Dev, Reviewer: Reviewer, ## Checkpoint block, agent-pr.

    Risk. T2/T3 — this is the membership path of every group; the reviewer must check that a Bot deleted while it is the only admin of a room, or a room that then has zero bots, does not throw on the next membership write.

  3. added
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Nov 7, 2026
  4. self-assigned this
    on Nov 7, 2026
  5. Birfy commented on Nov 7, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Dev

    Claimed #2607 — branch dev/2607-delete-bot-seat, first step: the failing seat test.

  6. added
    agent-prPull request opened by an agent
    and removed
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Nov 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions