fix(desktop/bots): writes into a member's Group session reach the room log (#93813)
A member's hidden per-group session is an ordinary Hermes session, so the CLI (`hermes -p <bot> chat --resume "Group: <room> · <thread>"`), cron and the agent's own tools append to it too. Those rows reached the transcript but never the room log, so the room silently diverged from what the member actually said. What: new sibling group-external-writes.ts sweeps each member session's unseen tail on the two paths that already read it — the pre-resume snapshot in runGroupChatMemberTurnLeased and the stranded-marker harvest — and appends the rows the room engine did not write itself, authored by that member, in the session key's thread. Cursor keyed by the SESSION key (`thread:<t>::<memberKey>`) in room.externalCursors, persisted through all three room projections (updateGroupChat, durableGroupChatRooms, plugin.tsx hydrate) so a window restart never re-mirrors a row. Why the classifier is header-based: every room-fed prompt opens with the header buildGroupChatTurnPrompt writes (now the exported GROUP_PROMPT_HEADER_PREFIX), agent-injected `[System:` rows continue the open exchange, and an assistant row answers whichever user row preceded it. Why the round watermark walk: mirrored rows land after the round's submit anchor, so `anchorIdx + 1` re-fed the member its own CLI conversation as room news and drove an extra turn. A member's own entries are never news to their author; the walk generalises the existing tail bump for replies. Why the `at` stamps: the gateway mirror merge orders same-millisecond entries by id, so a burst of mirrored rows appended in one tick came back shuffled. Ports the types.ts/group-chat.ts externalCursors persistence hunks of PR #94340; its memberKey cursor and 'legacy' thread predate per-thread member sessions and are replaced by the session-key cursor. Co-authored-by: YusukeOshima-5564 <yusuke_oshima@capsor.co.jp>
This commit is contained in:
@@ -813,6 +813,7 @@ export function mergeRemoteGroupChatSnapshotIntoRooms(
|
||||
watermarks: bounded.watermarks,
|
||||
sessions: existing.sessions && typeof existing.sessions === 'object' ? existing.sessions : {},
|
||||
stranded: existing.stranded && typeof existing.stranded === 'object' ? existing.stranded : {},
|
||||
externalCursors: existing.externalCursors && typeof existing.externalCursors === 'object' ? existing.externalCursors : {},
|
||||
members: [...members.values()],
|
||||
...(projectedRoomId || existing.roomId
|
||||
? {
|
||||
@@ -895,6 +896,7 @@ export function durableGroupChatRooms(all: Record<string, GroupChat> = $groupCha
|
||||
watermarks: room.watermarks || {},
|
||||
sessions: room.sessions || {},
|
||||
stranded: room.stranded || {},
|
||||
externalCursors: room.externalCursors || {},
|
||||
members: Array.isArray(room.members) ? room.members : [],
|
||||
// Immutable room identity: without this, a room merged in via the
|
||||
// remote-sync path (the only caller of this function) loses its
|
||||
@@ -1604,6 +1606,9 @@ export function updateGroupChat(
|
||||
// must too — otherwise a window restart silently releases a bot the
|
||||
// user explicitly stopped.
|
||||
holds: room.holds || {},
|
||||
// #93813: per-member external-write reconcile cursors. Persisted so
|
||||
// external posts aren't re-mirrored after a window restart.
|
||||
externalCursors: room.externalCursors || {},
|
||||
// Source-qualified member descriptors keep the room whole when the
|
||||
// active connection changes and today's local members become remote.
|
||||
members: Array.isArray(room.members) ? room.members : [],
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as groupChat from './group-chat'
|
||||
import type * as groupMembership from './group-membership'
|
||||
import type * as groupRounds from './group-rounds'
|
||||
import { createGroupGateway, drain, runTimersInline, scriptedStorage } from './group-test-utils'
|
||||
import type { GatewayOptions, ScriptedGateway } from './group-test-utils'
|
||||
import type { GroupChat, GroupMember } from './types'
|
||||
|
||||
// #93813: a member's per-group session is a plain Hermes session, so the CLI
|
||||
// (`hermes -p <bot> chat --resume "Group: …"`), cron and the agent's tools
|
||||
// write to it too. Those rows must reach the room log — once — or the room
|
||||
// silently diverges from what the member actually said.
|
||||
|
||||
const { host } = vi.hoisted(() => ({ host: {} as Record<string, unknown> }))
|
||||
|
||||
vi.mock('@hermes/plugin-sdk', async () => {
|
||||
const { pluginSdkMock } = await import('./group-test-utils')
|
||||
|
||||
return pluginSdkMock(host)
|
||||
})
|
||||
|
||||
interface Room {
|
||||
chat: typeof groupChat
|
||||
gateway: ScriptedGateway
|
||||
membership: typeof groupMembership
|
||||
rounds: typeof groupRounds
|
||||
}
|
||||
|
||||
/** Fresh plugin modules over `gateway` — the first call is a cold start, a
|
||||
* second call with the same gateway is a window restart: the gateway keeps
|
||||
* its sessions and the plugin storage, the renderer keeps nothing. */
|
||||
async function loadRoom(gateway: ScriptedGateway): Promise<Room> {
|
||||
vi.resetModules()
|
||||
|
||||
for (const key of Object.keys(host)) {
|
||||
delete host[key]
|
||||
}
|
||||
|
||||
Object.assign(host, gateway.host)
|
||||
|
||||
const [chat, membership, rounds, shared] = await Promise.all([
|
||||
import('./group-chat'),
|
||||
import('./group-membership'),
|
||||
import('./group-rounds'),
|
||||
import('./shared')
|
||||
])
|
||||
|
||||
shared.setPluginCtx(scriptedStorage(gateway.storage))
|
||||
|
||||
return { chat, gateway, membership, rounds }
|
||||
}
|
||||
|
||||
/** What plugin.tsx rebuilds `$groupChats` from after a restart. */
|
||||
function hydrateFromStorage(room: Room) {
|
||||
const durable = (room.gateway.storage.get('group-chats') || {}) as Record<string, GroupChat>
|
||||
const rooms: Record<string, GroupChat> = {}
|
||||
|
||||
for (const [name, stored] of Object.entries(durable)) {
|
||||
rooms[name] = { ...stored, epoch: 0, running: false }
|
||||
}
|
||||
|
||||
room.chat.$groupChats.set(rooms)
|
||||
}
|
||||
|
||||
const MEMBER: GroupMember = { name: 'research', title: '' }
|
||||
const options: GatewayOptions = { turn: ({ n }) => `room reply ${n}` }
|
||||
|
||||
const texts = (room: Room) => (room.chat.$groupChats.get().Room?.log || []).map(entry => entry.text)
|
||||
const settle = (room: Room) => drain(() => Boolean(room.chat.$groupChats.get().Room?.running))
|
||||
|
||||
async function drive(room: Room, text: string, thread?: string) {
|
||||
const id = room.rounds.sendToGroupChat('Room', [MEMBER], text, thread)!
|
||||
await settle(room)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
runTimersInline()
|
||||
// Every clock read ticks a millisecond: the gateway mirror merge orders
|
||||
// same-millisecond entries by id, and inline timers land a whole drive in
|
||||
// one tick, which would shuffle the log the assertions read.
|
||||
let now = 1_000_000
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => (now += 1))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('external writes into a member session', () => {
|
||||
it('reach the room log exactly once across two drives and a window restart', async () => {
|
||||
const gateway = createGroupGateway(options)
|
||||
let room = await loadRoom(gateway)
|
||||
|
||||
const thread = await drive(room, 'hello room')
|
||||
const key = room.membership.groupSessionKey(thread, MEMBER)
|
||||
const session = gateway.sessions.get(String(room.chat.$groupChats.get().Room.sessions?.[key]))!
|
||||
|
||||
// The user resumes the member's session from the CLI between rounds.
|
||||
session.messages.push({ content: 'cli question', role: 'user' }, { content: 'cli answer', role: 'assistant' })
|
||||
|
||||
await drive(room, 'second', thread)
|
||||
|
||||
expect(texts(room)).toEqual(['hello room', 'room reply 1', 'second', 'cli question', 'cli answer', 'room reply 2'])
|
||||
|
||||
const mirrored = room.chat.$groupChats.get().Room.log.filter(entry => entry.text.startsWith('cli '))
|
||||
|
||||
for (const entry of mirrored) {
|
||||
expect(entry.from).toMatchObject({ kind: 'member', name: 'research' })
|
||||
expect(entry.thread).toBe(thread)
|
||||
}
|
||||
|
||||
// The cursor is keyed by the session, sits past the swept rows, and is durable.
|
||||
const durable = (gateway.storage.get('group-chats') as Record<string, GroupChat>).Room
|
||||
|
||||
expect(durable.externalCursors).toEqual({ [key]: 4 })
|
||||
|
||||
// Window restart: same gateway and storage, fresh renderer state.
|
||||
room = await loadRoom(gateway)
|
||||
hydrateFromStorage(room)
|
||||
|
||||
await drive(room, 'third', thread)
|
||||
|
||||
expect(texts(room)).toEqual([
|
||||
'hello room',
|
||||
'room reply 1',
|
||||
'second',
|
||||
'cli question',
|
||||
'cli answer',
|
||||
'room reply 2',
|
||||
'third',
|
||||
'room reply 3'
|
||||
])
|
||||
})
|
||||
|
||||
it('leave room-fed prompts and their replies alone', async () => {
|
||||
const room = await loadRoom(createGroupGateway(options))
|
||||
|
||||
const thread = await drive(room, 'hello room')
|
||||
await drive(room, 'second', thread)
|
||||
|
||||
expect(texts(room)).toEqual(['hello room', 'room reply 1', 'second', 'room reply 2'])
|
||||
expect(room.gateway.calls).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
134
apps/desktop/src/plugins/hermes-bots/group-external-writes.ts
Normal file
134
apps/desktop/src/plugins/hermes-bots/group-external-writes.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Mirror of what a member said and heard OUTSIDE the room engine. (#93813)
|
||||
*
|
||||
* A member's hidden per-group session is an ordinary Hermes session, so other
|
||||
* writers legitimately append to it: the user resuming it from the CLI
|
||||
* (`hermes -p <bot> chat --resume "Group: <room> · <thread>"`), a cron job, the
|
||||
* agent's own tools. Those rows reach the transcript but never the room log,
|
||||
* so the room silently diverges from the member's real conversation. The
|
||||
* sweep below reads each member session's unseen tail and appends the rows
|
||||
* the room did not itself produce, authored by that member.
|
||||
*
|
||||
* Cursor identity is the SESSION key (`thread:<t>::<memberKey>`): one member
|
||||
* owns one session per thread, and a cursor shared across threads would skip
|
||||
* one thread's rows after another thread's sweep advanced it.
|
||||
*/
|
||||
|
||||
import { $groupChats, appendGroupChatEntry, updateGroupChat } from './group-chat'
|
||||
import type { GroupChatRoom } from './group-chat'
|
||||
import { groupMemberKey, groupSessionKey } from './group-membership'
|
||||
import { GROUP_PROMPT_HEADER_PREFIX } from './group-round-prompt'
|
||||
import type { GroupMember } from './types'
|
||||
|
||||
/** A transcript row as `session.resume` reports it; `content` is a plain string
|
||||
* on most providers and a part array on the rest. */
|
||||
export interface GroupTranscriptRow {
|
||||
content?: string | Array<string | { text?: string }>
|
||||
role?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
function transcriptRowText(row: GroupTranscriptRow): string {
|
||||
const text =
|
||||
typeof row.content === 'string'
|
||||
? row.content
|
||||
: Array.isArray(row.content)
|
||||
? row.content.map(part => (typeof part === 'string' ? part : part?.text || '')).join('')
|
||||
: row.text || ''
|
||||
|
||||
return String(text).trim()
|
||||
}
|
||||
|
||||
/** Opens the user-role rows the agent loop injects into its own transcript
|
||||
* (continuation nudges, truncation recovery — `agent/conversation_loop.py`,
|
||||
* `agent/context_compressor.py`). They continue whichever exchange is open. */
|
||||
const AGENT_INJECTED_ROW_PREFIX = '[System:'
|
||||
|
||||
/** The rows in `rows` the room engine did not write itself. A user row that
|
||||
* does not open with the room prompt header is external; an assistant row
|
||||
* answers whichever user row preceded it, so it inherits that row's origin;
|
||||
* agent-injected user rows, tool rows and empty rows are plumbing and never
|
||||
* mirrored. */
|
||||
export function externalGroupTranscriptRows(rows: GroupTranscriptRow[]): string[] {
|
||||
const external: string[] = []
|
||||
let answeringExternal = false
|
||||
|
||||
for (const row of rows) {
|
||||
const text = transcriptRowText(row)
|
||||
|
||||
if (!text || (row.role !== 'user' && row.role !== 'assistant')) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (row.role === 'user') {
|
||||
if (text.startsWith(AGENT_INJECTED_ROW_PREFIX)) {
|
||||
continue
|
||||
}
|
||||
|
||||
answeringExternal = !text.startsWith(GROUP_PROMPT_HEADER_PREFIX)
|
||||
}
|
||||
|
||||
if (answeringExternal) {
|
||||
external.push(text)
|
||||
}
|
||||
}
|
||||
|
||||
return external
|
||||
}
|
||||
|
||||
/** Append the rows written to `member`'s `thread` session since the last sweep
|
||||
* to the room log, then move that session's cursor to the end of `messages`.
|
||||
* Idempotent per row: the cursor persists with the room, so a restarted
|
||||
* window never mirrors a row twice. */
|
||||
export function mirrorExternalGroupWrites(
|
||||
group: string,
|
||||
member: GroupMember,
|
||||
thread: string,
|
||||
messages: GroupTranscriptRow[] | undefined
|
||||
) {
|
||||
const rows = Array.isArray(messages) ? messages : []
|
||||
const key = groupSessionKey(thread, member)
|
||||
const room = ($groupChats.get()[group] || {}) as GroupChatRoom
|
||||
const seen = Math.max(0, Number(room.externalCursors?.[key] || 0))
|
||||
|
||||
if (rows.length <= seen) {
|
||||
return
|
||||
}
|
||||
|
||||
const markKey = `${thread}::${groupMemberKey(member)}`
|
||||
const logLengthBefore = (room.log || []).length
|
||||
const mirrored = externalGroupTranscriptRows(rows.slice(seen)).map(text =>
|
||||
appendGroupChatEntry(
|
||||
group,
|
||||
{
|
||||
kind: 'member',
|
||||
name: member.name,
|
||||
...(member.remoteSource ? { source: member.connectionLabel || member.connectionId } : {})
|
||||
},
|
||||
text,
|
||||
thread
|
||||
)
|
||||
)
|
||||
|
||||
updateGroupChat(group, (r: GroupChatRoom) => {
|
||||
r.externalCursors = { ...(r.externalCursors || {}), [key]: rows.length }
|
||||
|
||||
// The gateway mirror merge orders same-millisecond entries by id, and a
|
||||
// burst appended in one tick would come back shuffled: give the mirrored
|
||||
// rows strictly increasing stamps so they keep their transcript order.
|
||||
for (let i = logLengthBefore + 1; i < r.log.length; i += 1) {
|
||||
if (mirrored.includes(r.log[i])) {
|
||||
r.log[i].at = Math.max(r.log[i].at, (r.log[i - 1].at || 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// The member already lived these rows in its own session; a watermark
|
||||
// sitting at the pre-mirror tail steps over them instead of feeding the
|
||||
// member its own conversation back as room news.
|
||||
if (r.watermarks[markKey] === logLengthBefore) {
|
||||
r.watermarks[markKey] = r.log.length
|
||||
}
|
||||
|
||||
return r
|
||||
})
|
||||
}
|
||||
@@ -230,26 +230,43 @@ export async function runGroupRoundMember(
|
||||
})
|
||||
}
|
||||
|
||||
if (reply !== null && !isGroupPassText(reply)) {
|
||||
const spoke = reply !== null && !isGroupPassText(reply)
|
||||
|
||||
if (reply !== null && spoke) {
|
||||
appendGroupChatEntry(
|
||||
context.group,
|
||||
groupMemberAuthor(member),
|
||||
reply,
|
||||
thread
|
||||
)
|
||||
// A reply cannot acknowledge user entries that arrived during inference.
|
||||
updateGroupChat(context.group, (r: GroupChatRoom) => {
|
||||
if (r.watermarks[markKey] === r.log.length - 1) {
|
||||
r.watermarks[markKey] = r.log.length
|
||||
}
|
||||
|
||||
return r
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
// A member's own entries — its reply, and the rows group-external-writes.ts
|
||||
// mirrored out of its own session — are never news to their author, so the
|
||||
// watermark steps over them. A user entry that arrived during inference
|
||||
// stops the walk: a reply cannot acknowledge what it never saw.
|
||||
updateGroupChat(context.group, (r: GroupChatRoom) => {
|
||||
let mark = r.watermarks[markKey] || 0
|
||||
|
||||
while (mark < r.log.length && authoredByMember(r.log[mark], member)) {
|
||||
mark += 1
|
||||
}
|
||||
|
||||
if (mark !== (r.watermarks[markKey] || 0)) {
|
||||
r.watermarks[markKey] = mark
|
||||
}
|
||||
|
||||
return r
|
||||
})
|
||||
|
||||
return spoke
|
||||
}
|
||||
|
||||
function authoredByMember(entry: GroupMessage, member: GroupMember): boolean {
|
||||
const from = entry?.from
|
||||
const source = member.remoteSource ? member.connectionLabel || member.connectionId : undefined
|
||||
|
||||
return from?.kind === 'member' && from.name === member.name && String(from.source || '') === String(source || '')
|
||||
}
|
||||
|
||||
export async function runGroupContinuationMembers(
|
||||
|
||||
@@ -141,6 +141,10 @@ interface GroupChatTurnPromptInput {
|
||||
viewer: GroupMember
|
||||
}
|
||||
|
||||
/** Opens every room-fed turn prompt; group-external-writes.ts tells the room's
|
||||
* own prompts apart from outside writes by it. */
|
||||
export const GROUP_PROMPT_HEADER_PREFIX = '[Group chat: "'
|
||||
|
||||
/** The full per-turn payload for one member: participation rules + the room
|
||||
* delta. Rules travel in the turn payload (not SOUL) so every existing bot
|
||||
* can join a group chat without a profile migration. */
|
||||
@@ -157,7 +161,7 @@ export function buildGroupChatTurnPrompt({ groupName, members, viewer, deltaLine
|
||||
.join(', ')
|
||||
|
||||
return [
|
||||
`[Group chat: "${groupName}"] You are @${botMentionTag(viewer)}, one participant in a group chat with ${peerNames || 'no one else yet'} and the user.`,
|
||||
`${GROUP_PROMPT_HEADER_PREFIX}${groupName}"] You are @${botMentionTag(viewer)}, one participant in a group chat with ${peerNames || 'no one else yet'} and the user.`,
|
||||
'',
|
||||
'New messages in the room since your last turn (oldest first):',
|
||||
...deltaLines.map(line => ` ${line}`),
|
||||
|
||||
@@ -54,6 +54,9 @@ const ROUTED_MEMBER: GroupMember = { connectionId: 'mini', name: 'helper', remot
|
||||
const IMG: Attachment = { data: 'data:image/png;base64,iVBORw0KGgo=', kind: 'image', name: 'shot.png' }
|
||||
|
||||
const log = (room: Room, group: string) => room.chat.$groupChats.get()[group]?.log || []
|
||||
// The room engine opens every turn prompt with this header (group-round-prompt.ts);
|
||||
// a user row without it is an outside write and gets mirrored into the room log.
|
||||
const roomPrompt = (group: string) => `[Group chat: "${group}"] You are @member, one participant in a group chat.`
|
||||
|
||||
beforeEach(() => {
|
||||
runTimersInline()
|
||||
@@ -1137,7 +1140,7 @@ describe('in-flight marker', () => {
|
||||
})
|
||||
room.gateway.sessions.set('sid-mini-helper', {
|
||||
messages: [
|
||||
{ content: 'the turn prompt', role: 'user' },
|
||||
{ content: roomPrompt('Fleet'), role: 'user' },
|
||||
{ content: 'Finished on the mini after the Desktop went away.', role: 'assistant' }
|
||||
],
|
||||
profile: 'helper',
|
||||
@@ -1203,7 +1206,7 @@ describe('stranded harvest', () => {
|
||||
})
|
||||
// The member's session finished after we stopped waiting.
|
||||
seedSession(room, 'sid-research', 'research', 'Group: Late', [
|
||||
['user', 'the turn prompt'],
|
||||
['user', roomPrompt('Late')],
|
||||
['assistant', 'Here is the full research result, delivered late.']
|
||||
])
|
||||
|
||||
@@ -1228,7 +1231,7 @@ describe('stranded harvest', () => {
|
||||
return current
|
||||
})
|
||||
seedSession(room, 'sid-research', 'research', 'Group: Rescue', [
|
||||
['user', 'the turn prompt'],
|
||||
['user', roomPrompt('Rescue')],
|
||||
['assistant', 'Here is the full research result, delivered late.'],
|
||||
[
|
||||
'user',
|
||||
@@ -1287,8 +1290,8 @@ describe('stranded harvest', () => {
|
||||
return current
|
||||
})
|
||||
seedSession(room, 'sid-builder', 'builder', 'Group: Quiet2', [
|
||||
['user', 'p1'],
|
||||
['user', 'prompt'],
|
||||
['user', roomPrompt('Quiet2')],
|
||||
['user', roomPrompt('Quiet2')],
|
||||
['assistant', '(pass)']
|
||||
])
|
||||
|
||||
@@ -1314,7 +1317,7 @@ describe('stranded harvest', () => {
|
||||
|
||||
return current
|
||||
})
|
||||
seedSession(room, 'sid-builder', 'builder', 'Group: Dead', [['user', 'p1']])
|
||||
seedSession(room, 'sid-builder', 'builder', 'Group: Dead', [['user', roomPrompt('Dead')]])
|
||||
const requestProfile = host.requestProfile as (...args: unknown[]) => Promise<Record<string, unknown>>
|
||||
|
||||
host.requestProfile = async (...args: unknown[]) => ({
|
||||
|
||||
@@ -12,6 +12,7 @@ import { noteBotAttention } from './data'
|
||||
import { groupFailureReason, recordGroupActivity } from './group-activity'
|
||||
import { $groupChats, $groupClarify, appendGroupChatEntry, updateGroupChat } from './group-chat'
|
||||
import type { GroupChatRoom } from './group-chat'
|
||||
import { mirrorExternalGroupWrites } from './group-external-writes'
|
||||
import {
|
||||
followGroupChat,
|
||||
groupMemberAuthor,
|
||||
@@ -1042,6 +1043,7 @@ async function prepareGroupTurnBaseline(
|
||||
// turn the gateway still retains from before it.
|
||||
let before = 0
|
||||
let leftover: null | string = null
|
||||
let snapshot: GroupSessionSnapshot | null = null
|
||||
// Every runtime id this turn has seen for the member's session. Terminal
|
||||
// frames are keyed by runtime id, and a resume can hand back a fresh one.
|
||||
const runtimeIds = new Set<string>([runtime])
|
||||
@@ -1052,6 +1054,7 @@ async function prepareGroupTurnBaseline(
|
||||
profile: member.name
|
||||
})) as GroupSessionSnapshot
|
||||
|
||||
snapshot = pre
|
||||
before = Array.isArray(pre?.messages) ? pre.messages.length : pre?.message_count || 0
|
||||
leftover = retainedGroupTurnError(pre) === null ? null : JSON.stringify(pre.inflight)
|
||||
|
||||
@@ -1062,7 +1065,7 @@ async function prepareGroupTurnBaseline(
|
||||
/* lazy session — zero messages */
|
||||
}
|
||||
|
||||
return { before, leftover, runtimeIds }
|
||||
return { before, leftover, runtimeIds, snapshot }
|
||||
}
|
||||
|
||||
async function runGroupChatMemberTurnLeased(
|
||||
@@ -1092,7 +1095,16 @@ async function runGroupChatMemberTurnLeased(
|
||||
thread
|
||||
})
|
||||
|
||||
const { before, leftover, runtimeIds } = await prepareGroupTurnBaseline(member, runtime, stored)
|
||||
const { before, leftover, runtimeIds, snapshot } = await prepareGroupTurnBaseline(member, runtime, stored)
|
||||
|
||||
if (!binding.isLive()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// #93813: rows other writers put in this session since the last look
|
||||
// (CLI resume, cron, tools) join the room log before this turn's own
|
||||
// prompt lands after them.
|
||||
mirrorExternalGroupWrites(group, member, thread, snapshot?.messages)
|
||||
|
||||
const { failed, fileRefs } = await stageGroupTurnAttachments(member, runtime, images)
|
||||
|
||||
@@ -1277,6 +1289,10 @@ export async function harvestStrandedGroupReply(group: string, member: GroupMemb
|
||||
return r
|
||||
})
|
||||
}
|
||||
|
||||
// #93813: whatever else reached the session while the turn was stranded
|
||||
// follows the late reply into the room.
|
||||
mirrorExternalGroupWrites(group, member, strandedThread, messages)
|
||||
} finally {
|
||||
binding.dispose()
|
||||
}
|
||||
|
||||
@@ -269,6 +269,7 @@ export default {
|
||||
// guard as the other maps — a held bot stays held across
|
||||
// window restarts until explicitly released.
|
||||
holds: room.holds && typeof room.holds === 'object' ? room.holds : {},
|
||||
externalCursors: room.externalCursors && typeof room.externalCursors === 'object' ? room.externalCursors : {},
|
||||
members: Array.isArray(room.members) ? room.members : [],
|
||||
roomId: typeof room.roomId === 'string' && room.roomId ? room.roomId : null,
|
||||
image: typeof room.image === 'string' && room.image ? room.image : null,
|
||||
|
||||
@@ -203,6 +203,11 @@ export interface GroupChat {
|
||||
/** A member turn this Desktop is not (or no longer) polling: the message-count baseline to
|
||||
* harvest its late reply from. `turn` names the poll that owns it while that poll runs. */
|
||||
stranded?: Record<string, number | { before: number; thread?: string; turn?: string }>
|
||||
/** #93813: how far each member's external-write reconcile sweep has read
|
||||
* into that member's per-group session transcript (absolute row index of
|
||||
* the last mirrored row + 1). Persisted so external posts aren't rescanned
|
||||
* (or re-mirrored) after a window restart. */
|
||||
externalCursors?: Record<string, number>
|
||||
syncRevision?: number
|
||||
/** Left behind when a room is disbanded, so sync can't resurrect it. */
|
||||
tombstone?: boolean
|
||||
|
||||
@@ -154,6 +154,7 @@ Use the **Move up** and **Move down** arrows beside a room to choose its positio
|
||||
- **Rooms can span machines.** **New Group Chat** is available as soon as two Bots are selectable across all your registered connections — one Bot on this device plus one on another gateway is enough. The picker seats Bots from any registered connection; each member's turns run on its own machine, in its own room session there. Cross-machine members carry a device badge (`dixie · Mac Mini`) in the room and in other members' transcripts, and the disambiguated `@name-device` handle works in room mentions — so same-named agents on two machines never blur together. A member's turn that outlives this Desktop — you quit or it crashed mid-turn — finishes on the member's own machine: the room posts that reply the next time it is driven, and does not re-drive the member while it is still working.
|
||||
- **Rooms show the same identity as the roster.** Transcript rows, the “X is thinking…” line and Activity rows resolve each member's title and avatar by its owning connection and profile — re-titling a Bot or changing its picture updates every room it sits in, remote members keep their own titles, and two same-named Bots on different connections never borrow each other's avatar. When two members would still read identically, Activity appends the connection label (`Reviewer · Mac Mini`).
|
||||
- **Plugins can watch members work.** The durable room log records `turn.started` and `turn.settled`; what a member does in between (tools, approvals, streamed text) is projected to plugins through the [`on_room_member_activity`](./features/hooks.md#on_room_member_activity) hook with room, member and turn coordinates, so community clients can build tool cards and live member status on top of Group Chat without reading Hermes internals.
|
||||
- **What a member says outside the room still reaches it.** Each member's room session is an ordinary Hermes session (titled `Group: <room> · <thread>`), so you can resume it from the CLI, a routine can post into it, or the Bot's own tools can write to it. The next time the room drives that member, whatever those writers added since the room last looked — your questions and the member's answers — is posted into the room log under that member's name, once, in the thread the session belongs to; a window restart does not repeat it. The room's own turn prompts and their replies are never duplicated this way.
|
||||
|
||||
## Bot-to-bot messaging
|
||||
|
||||
|
||||
Reference in New Issue
Block a user