diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index a2c790924..ea81e9949 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -219,6 +219,9 @@ function faceStatusFrom(spec, current) { const status = spec?.status if (status === 'open' || status === 'closed' || status === 'left') return status if (spec?.startedAt) return 'open' + // A later snapshot that says "candidate" and omits startedAt must not put an + // open, closed, or left room back on the waiting lobby (#1072). + if (current === 'open' || current === 'closed' || current === 'left') return current if (status) return status return current || 'candidate' } diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index fec23814e..01b0ce90a 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -343,6 +343,33 @@ test('face-to-face rejoin revives a left participant instead of duplicating', as assert.equal(room.face.status, 'open') }) +test('#1072: an open face room is not demoted to the waiting lobby when a snapshot omits startedAt', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const spec = { + roomId: 'frm_1072', + localId: 'face-1072', + status: 'open', + startedAt: '2026-10-25T01:00:00.000Z', + localBots: [{ id: 'spec-a', handle: '@writer.alice.home', name: 'Writer A' }], + } + const both = [ + { handle: '@writer.alice.home', displayName: 'Writer', ownerName: 'Alice', published: false }, + { handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', published: false }, + ] + await rt.upsertFaceRoom({ ...spec, members: both }) + const room = rt.state.rooms['face-1072'] + assert.equal(room.face.status, 'open') + assert.equal(room.id, 'face-1072') + await rt.upsertFaceRoom({ roomId: 'frm_1072', status: 'candidate', members: [both[0]] }) + assert.equal(room.face.status, 'open') + assert.equal(room.id, 'face-1072') + const peer = room.participants.filter(p => p.handle === '@coder.bob.home') + assert.equal(peer.length, 1) + assert.ok(peer[0].leftSeq !== undefined) + assert.equal(room.participants.filter(p => p.handle === '@writer.alice.home' && p.leftSeq === undefined).length, 1) +}) + test('#304: a face room keeps the number it was joined by in its title, through polls and leave', async (t) => { const { rt } = host(t, TEAM) await rt.load() diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 5e269c92c..10f50661f 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -226,7 +226,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - `syncFaceParticipants` / `faceRemoteRecord` keep optional `color` / `avatar` from the Bot Social member payload on the remote participant. The client seeds a distinct generated look from the handle when those fields are absent (see [core-client](core-client.md)). Face-room REST still does not require a person-grant. - `rt.uiLanguage()` defaults to `English` until a browser reports a locale; the memory tools use it to name the language the owner writes in on a fresh home (#173). - Face titles (#172): `core/src/shared/face-title.js` lists every participant owner in stable handle order and `isAutoFaceTitle` marks auto titles, so a roster change refreshes an auto-named room but never a renamed one; both homes derive the same title. A hosted face room that falls off the Bot Social list keeps its local transcript (poll cleanup no longer calls `dropFace`), so Past rooms **Open** can reopen it (#171). -- A member that left and joins again is the same participant record with `leftSeq` cleared and a new `joinedSeq` — not a second live row. `upsertFaceRoom` treats `startedAt` (or an explicit `open`) as an open room so the Friends' Bots card does not stay on the matching countdown after **Enter chat**. +- A member that left and joins again is the same participant record with `leftSeq` cleared and a new `joinedSeq` — not a second live row. `upsertFaceRoom` treats `startedAt` (or an explicit `open`) as an open room so the Friends' Bots card does not stay on the matching countdown after **Enter chat**. A later snapshot whose status is not `open`, `closed`, or `left`, and that omits `startedAt`, does not demote a room that is already open, closed, or left back to `candidate` (#1072). ### FlyBot display copy diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 0e83d3497..25515b65c 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -189,7 +189,7 @@ Creating a group opens its Session with zero turns, and the host draws no sessio ### Inbound shared groups -- Bot Social has no FlyBot-group record (`list_conversations` is 1:1; inbox is `message.received` after a send). The plugin therefore carries a `FLYBOT-ROOM-SHARE` envelope on `send_as_member` text. `pollOnce` calls `flybot.rooms.applyInbound` before 1:1 chat delivery, so the Bot's owner sees the group as soon as they are added. `via: 'room'` / `via: 'room-share'` skip the social outbound hold (the add-to-group click is consent), and a send the host sees come back `pending_approval` clears Bot Social's own blanket hold by itself (`autoSendRoomEnvelope`, `holdIsBlanket()` on `hold_reason` of #865, the allow-list of #737): the invite and the room's lines leave with no card, while a hold with a finding of its own keeps its card. That card reads one sentence about the envelope (`Invite to the group ""`, `Group message to : `), never the JSON or a local `session-…` id (#847). The invited owner's Bot replies still go through `pending_approval`. A roster fetch that no longer lists a remote, or `social/revoke-grant`, leaves those rooms / hides inbound projections. +- Bot Social has no FlyBot-group record (`list_conversations` is 1:1; inbox is `message.received` after a send). The plugin therefore carries a `FLYBOT-ROOM-SHARE` envelope on `send_as_member` text. `pollOnce` calls `flybot.rooms.applyInbound` before 1:1 chat delivery, so the Bot's owner sees the group as soon as they are added. `via: 'room'` / `via: 'room-share'` skip the social outbound hold (the add-to-group click is consent), and a send the host sees come back `pending_approval` clears Bot Social's own blanket hold by itself (`autoSendRoomEnvelope`, `holdIsBlanket()` on `hold_reason` of #865, the allow-list of #737): the invite and the room's lines leave with no card, while a hold with a finding of its own keeps its card. That card reads one sentence about the envelope (`Invite to the group ""`, `Group message to : `), never the JSON or a local `session-…` id (#847). The invited owner's Bot replies still go through `pending_approval`. A roster fetch that no longer lists a remote, or `social/revoke-grant`, leaves those rooms / hides inbound projections. Revoking a grant also leaves shared face rooms that still seat that grantee (`leaveSharedFaceRooms`, before the grant delete): the only server call is leaving this home's own seats, so the other home sees a normal departure and does not stay on the waiting lobby. A leave that fails is warned and the revoke still completes. An open room (one already started) whose next snapshot is `candidate` or drops every other home is closed with `peersGone` instead of returning to **Waiting for others**; a room that was only ever a candidate lobby stays one until its window runs out. Peer seats are soft-left (`leftSeq` / `leftAt`), not deleted (#1072). ### A local room's link for a stranger (#883, #884) diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index a2c790924..ea81e9949 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -219,6 +219,9 @@ function faceStatusFrom(spec, current) { const status = spec?.status if (status === 'open' || status === 'closed' || status === 'left') return status if (spec?.startedAt) return 'open' + // A later snapshot that says "candidate" and omits startedAt must not put an + // open, closed, or left room back on the waiting lobby (#1072). + if (current === 'open' || current === 'closed' || current === 'left') return current if (status) return status return current || 'candidate' } diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index fec23814e..01b0ce90a 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -343,6 +343,33 @@ test('face-to-face rejoin revives a left participant instead of duplicating', as assert.equal(room.face.status, 'open') }) +test('#1072: an open face room is not demoted to the waiting lobby when a snapshot omits startedAt', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const spec = { + roomId: 'frm_1072', + localId: 'face-1072', + status: 'open', + startedAt: '2026-10-25T01:00:00.000Z', + localBots: [{ id: 'spec-a', handle: '@writer.alice.home', name: 'Writer A' }], + } + const both = [ + { handle: '@writer.alice.home', displayName: 'Writer', ownerName: 'Alice', published: false }, + { handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', published: false }, + ] + await rt.upsertFaceRoom({ ...spec, members: both }) + const room = rt.state.rooms['face-1072'] + assert.equal(room.face.status, 'open') + assert.equal(room.id, 'face-1072') + await rt.upsertFaceRoom({ roomId: 'frm_1072', status: 'candidate', members: [both[0]] }) + assert.equal(room.face.status, 'open') + assert.equal(room.id, 'face-1072') + const peer = room.participants.filter(p => p.handle === '@coder.bob.home') + assert.equal(peer.length, 1) + assert.ok(peer[0].leftSeq !== undefined) + assert.equal(room.participants.filter(p => p.handle === '@writer.alice.home' && p.leftSeq === undefined).length, 1) +}) + test('#304: a face room keeps the number it was joined by in its title, through polls and leave', async (t) => { const { rt } = host(t, TEAM) await rt.load() diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index ad03ace06..3315c9947 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -2667,6 +2667,24 @@ export function createSocial(service, config = {}, { client: given, api: givenAp void remoteBots({ fresh: true }).catch(() => {}) return result })) + // Face rooms that still seat this grantee. The only server call is leaving our own seats; + // the other home then sees a normal departure. A room already left or closed is skipped, + // and so is one this home is no longer hosting. + async function leaveSharedFaceRooms(bot, grantee) { + if (!grantee || typeof flybot.rooms?.list !== 'function') return + for (const room of roomsUsing(bot.id, grantee)) { + if (room.origin !== 'face-to-face') continue + const meta = Object.entries(settings.faceRooms ?? {}).find(([, item]) => item?.localRoomId === room.id) + if (!meta) continue + const status = flybot.rooms.ofFace?.(meta[0])?.face?.status + if (status === 'left' || status === 'closed') continue + try { + await leaveFaceRoom({ roomId: room.id, faceRoomId: meta[0] }) + } catch (error) { + warn('shared face room not left on revoke', error) + } + } + } // One grant's delete; the caller fences it and refreshes the roster once afterwards. async function revokeOne(bot, { grantId, relationshipId, grantee }) { const current = flybot.grants?.of?.(bot.id) ?? [] @@ -2676,6 +2694,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp || (grantee && norm(grant.grantee) === norm(grantee))) if (!target && !grantId && !relationshipId) throw new Error('which grant?') const member = memberOf(bot.id) + // The face room seats that person with no person-grant on the room, so deleting the + // grant leaves the other home polling a lobby. Leave first, while the room is still + // healthy, and a failed leave must not block the revoke (#1072). + await leaveSharedFaceRooms(bot, target?.grantee || (typeof grantee === 'string' ? grantee : '')) await api.revokeGrant({ grantId: grantId || target?.id, relationshipId: relationshipId || target?.relationshipId, @@ -3497,15 +3519,19 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (held === 'closed' && status === 'open') return { status: held, peersGone: true } if (held === 'left' || held === 'closed') return { status: held } const expiresAt = snapshot?.expiresAt || hosted?.face?.expiresAt + const locals = new Set(bringable().map(bot => bot.handle).filter(Boolean)) + const previousRemotes = (hosted?.participants ?? []).filter(p => p.leftSeq === undefined && p.remote === true) + const currentRemotes = (snapshot?.members ?? []).filter(member => member.handle && !locals.has(norm(member.handle))) + // Every other home left an open room: it has ended here, including when the next snapshot + // falls back to the waiting lobby (candidate, startedAt omitted). A room that was only ever + // a candidate lobby stays one until its window runs out (#1072, #304). Checked before the + // expired-candidate return so a started room still leaves on Bot Social. + const wasOpen = held === 'open' || Boolean(hosted?.face?.startedAt) + if ((status === 'open' || wasOpen) && previousRemotes.length > 0 && currentRemotes.length === 0) return { status: 'closed', peersGone: true } if (status === 'candidate' && expiresAt) { const at = Date.parse(expiresAt) if (Number.isFinite(at) && at <= now()) return { status: 'closed' } } - const locals = new Set(bringable().map(bot => bot.handle).filter(Boolean)) - const previousRemotes = (hosted?.participants ?? []).filter(p => p.leftSeq === undefined && p.remote === true) - const currentRemotes = (snapshot?.members ?? []).filter(member => member.handle && !locals.has(norm(member.handle))) - // Every other home left: the room has ended here, but this home did not leave it (#304). - if (status === 'open' && previousRemotes.length > 0 && currentRemotes.length === 0) return { status: 'closed', peersGone: true } // The joining window ran out and nobody else ever came (#458): the room ends, it is no open chat with yourself. const started = Date.parse(snapshot?.startedAt || '') const deadline = Date.parse(expiresAt || '') diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index b9332c05f..c05680388 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -2483,6 +2483,70 @@ test('#304: a failed automatic leave is tried again on the next poll', async (t) assert.equal(leaves.length, 2, 'not after it succeeded') }) +test('#1072: revoking a grant leaves the shared face room before the grant is deleted', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = withFaceRooms(mockServer()) + server.tools.revoke_bot_grant = () => ({ ok: true }) + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + const chief = ctx.flybot.bot.list().find(bot => bot.name === 'Chief') + server.face.peer('123456') + const joined = (await call('flybot/social/face-join', { start: true, code: '123456', botId: chief.id })).value + await call('flybot/social/face-start', { faceRoomId: 'frm_123456' }) + await ctx.flybot.grants.set(chief.id, [{ id: 'g-face', grantee: '@coder.bob.home', name: 'Bob' }]) + const before = server.calls.length + const revoked = await call('flybot/social/revoke-grant', { botId: chief.id, grantId: 'g-face', grantee: '@coder.bob.home' }) + assert.equal(revoked.ok, true) + const slice = server.calls.slice(before) + const leaveAt = slice.findIndex(c => c.name === 'REST POST /frm_123456/leave') + const revokeAt = slice.findIndex(c => c.name === 'revoke_bot_grant') + assert.ok(leaveAt >= 0, 'the shared face room is left on Bot Social') + assert.ok(revokeAt > leaveAt, 'the leave is recorded before the grant is deleted') + const hosted = ctx.flybot.rooms.ofFace('frm_123456') + assert.equal(hosted.id, joined.roomId, 'the same hosted room') + assert.equal(hosted.face.status, 'left') + const peer = hosted.participants.filter(p => p.handle === '@coder.bob.home') + assert.equal(peer.length, 1, 'the peer seat stays on the record') + assert.equal((ctx.flybot.grants.of(chief.id) ?? []).some(grant => grant.id === 'g-face'), false) +}) + +test('#1072: an open face room whose next snapshot is a waiting lobby closes instead of sticking', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = withFaceRooms(mockServer()) + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + server.face.peer('123456') + const joined = (await call('flybot/social/face-join', { start: true, code: '123456' })).value + await call('flybot/social/face-start', { faceRoomId: 'frm_123456' }) + const room = server.face.rooms.get('frm_123456') + room.status = 'candidate' + delete room.started_at + room.expires_at = new Date(Date.now() + 10 * 60_000).toISOString() + room.members = room.members.filter(member => member.handle !== '@coder.bob.home') + const listed = (await call('flybot/social/face-rooms', { poll: true })).value + const past = listed.rooms.find(item => item.faceRoomId === 'frm_123456') + const hosted = ctx.flybot.rooms.ofFace('frm_123456') + assert.equal(hosted.id, joined.roomId) + assert.equal(hosted.face.status, 'closed') + assert.equal(past.status, 'closed') + const peer = hosted.participants.filter(p => p.handle === '@coder.bob.home') + assert.equal(peer.length, 1) + assert.ok(peer[0].leftSeq !== undefined) + assert.equal(hosted.participants.filter(p => p.handle === '@chief.danyang.home').length, 1) +}) + test('face and inbox polls speed up while a live face room is open', async (t) => { const { ctx, call, stop } = await team() t.after(stop)