From 6b069bef69d0155237eeb07864c36df0d2a57063 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Mon, 26 Oct 2026 12:56:54 +0800 Subject: [PATCH] feat(host): a group turn shows who is working from that room's board (#1213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member that claims a card and stays quiet reads as idle to everyone in the room, and the board only says what is open, never who holds it. A turn now renders one line per member holding an unfinished card in that room — number, column, progress, age and last move, from the same `rt.state.tasks` snapshot it already reads, so nothing is written and nothing is broadcast — then one `idle (no open card)` line per member holding none, and a single nobody line when no card is open. At most eight lines and 400 characters. Rebased onto d04712bb with #1219 and #1230 in it; the two patch sections for groups.js and rounds.test.js are regenerated against that tree. --- changelog.d/1213-room-presence.changed.md | 1 + core/src/host/groups.js | 10 ++- core/src/host/room-context.js | 67 ++++++++++++++- core/test/room-context.test.js | 84 ++++++++++++++++++- core/test/rounds.test.js | 51 +++++++++++ docs/subsystems/core-host.md | 5 ++ patches/ds-bot/0005-social-seams.patch | 63 ++++++++++++-- patches/ds-bot/0012-room-membership.patch | 46 +++++----- patches/ds-bot/files/src/host/room-context.js | 67 ++++++++++++++- .../ds-bot/files/test/room-context.test.js | 84 ++++++++++++++++++- 10 files changed, 447 insertions(+), 31 deletions(-) create mode 100644 changelog.d/1213-room-presence.changed.md diff --git a/changelog.d/1213-room-presence.changed.md b/changelog.d/1213-room-presence.changed.md new file mode 100644 index 000000000..7179a5fe6 --- /dev/null +++ b/changelog.d/1213-room-presence.changed.md @@ -0,0 +1 @@ +A group turn now shows who is working: one line per member that holds an open card on that room's board, with the card number, column, progress and how long it has been open, and an explicit `idle (no open card)` line for a member that holds none — read from the board at the turn, with nothing new written and no one having to post a status ([#1213](https://github.com/ThinkFlowLab/FlyBot/issues/1213)). diff --git a/core/src/host/groups.js b/core/src/host/groups.js index df1457c55..89cd61077 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -30,7 +30,7 @@ import { FACE_LEAD_NOTE, FACE_LEAD_ROUTE, faceLeadIsRemote, faceMemberNote, face import { faceMentions } from './face-mentions.js' import { cleanName, stripSessionChips, unwrapUntrusted } from './delivery.js' import { createRoomParticipants } from './room-participant.js' -import { ROOM_CONTEXT_BUDGET, boardCoversCue, fitRoomLines, formatRoomBoard, prepareRoomLine } from './room-context.js' +import { ROOM_CONTEXT_BUDGET, boardCoversCue, fitRoomLines, formatRoomBoard, formatRoomPresence, prepareRoomLine } from './room-context.js' const ROOM_LOG_MAX = 60 const ROOM_FIRST_LOOK = 12 @@ -367,12 +367,20 @@ export function install(rt) { const before = index const after = round.slots.length - index - 1 const working = workingIn(room).filter(item => item.bot.id !== member.id) + // Who is on what, before the board itself (#1213): the board says what is open, this + // says which member holds it, so a working and an idle member read differently without + // anyone posting. Read from the same state, at the moment this turn is built. + const presence = isFaceRoom(room) ? '' : formatRoomPresence(rt.state.tasks, { + roomId: room.id, + members: room.members.map(id => ({ id, name: rt.botOf(id)?.name })), + }) const board = isFaceRoom(room) ? '' : formatRoomBoard(rt.state.tasks, { roomId: room.id, nameOf: task => rt.botOf(task.ownerBotId)?.name, }) return [ noticeChange(room, member), + presence, board, fitted.omitted > 0 ? `${fitted.omitted} older lines are not in this turn. Progress is on the task board; call read_group_chat when you need an exact earlier line.` : '', earlier.length ? `Earlier in the group, before this round (context only):\n${show(earlier)}` : '', diff --git a/core/src/host/room-context.js b/core/src/host/room-context.js index 74b3b2f85..5a76144a8 100644 --- a/core/src/host/room-context.js +++ b/core/src/host/room-context.js @@ -2,7 +2,7 @@ // whole. The turn gets a newer-first slice under a character budget, and a long // status essay is collapsed so it cannot fill that slice. Progress for the open // job is a compact board snapshot in the same turn, not those essays. -import { compareForWork, progressOf } from '../shared/task-board.js' +import { columnOf, compareForWork, progressOf } from '../shared/task-board.js' // Characters, not a tokenizer. The essays this was measured on are mostly CJK, // about one token per character, so a character ceiling is the conservative one: @@ -165,3 +165,68 @@ export function formatRoomBoard(tasks, { roomId, nameOf, limit = 8 } = {}) { if (hidden > 0) lines.push(`- ${hidden} more open card(s) not shown`) return lines.join('\n') } + +// Who is on what, read from the board at the moment the turn is built (#1213). Members +// followed the rule — claim the card, update it, say nothing — and the room became +// unreadable: silence meant "idle" only because nothing rendered the board's holders. +// One line per member who holds an open card in THIS room, one line per member who does +// not, so "nobody is working" is readable too. Read-only: no write, no message, no +// heartbeat. Columns, progress and age only — never CI, the queue or a verdict, which +// #1203 keeps off the chat. +export const ROOM_PRESENCE_BUDGET = 400 +const PRESENCE_MEMBERS = 8 +const OPEN_STATES = new Set(['open', 'claimed', 'working', 'blocked', 'result-ready']) + +const columnWord = column => (column === 'in-progress' ? 'in progress' : column === 'not-started' ? 'not started' : column) +const minutesBetween = (from, now) => Math.max(0, Math.round((now - from) / 60000)) + +// `- Dev2 — #1081 title (in progress 15%, open 27 min)`: the card's own fields, flattened +// to one line. A 0% card says `just claimed` instead of a percentage, and a tag is +// counted from `createdAt` — the fields the record already has, no new one. +function presenceLine(task, name, now, extra = 0) { + const ref = typeof task.target?.number === 'number' ? `#${task.target.number} ` : '' + const title = oneLine(task.title || '(untitled)', 60) + const waiting = task.waitingFor === 'input' + const head = waiting ? 'waiting on an answer' : columnWord(columnOf(task)) + const pct = progressOf(task) + const detail = waiting ? '' : pct > 0 ? ` ${pct}%` : ', just claimed' + const created = Number.isFinite(task.createdAt) ? task.createdAt : now + const open = `open ${minutesBetween(created, now)} min` + const moved = Number.isFinite(task.updatedAt) && task.updatedAt - created >= 60000 + ? `, moved ${minutesBetween(task.updatedAt, now)} min ago` + : '' + const more = extra > 0 ? ` +${extra} more` : '' + return `- ${name} — ${ref}${title} (${head}${detail}, ${open}${moved})${more}` +} + +export function formatRoomPresence(tasks, { roomId, members, now = Date.now(), nameOf } = {}) { + if (!roomId) return '' + const list = (members ?? []).filter(member => member && member.id !== undefined) + if (list.length === 0) return '' + const nameOfMember = member => oneLine(typeof nameOf === 'function' ? nameOf(member) : (member.name ?? member.id), 40) || String(member.id) + const cardsOf = new Map() + for (const task of Object.values(tasks ?? {})) { + if (!task || task.origin?.roomId !== roomId || !OPEN_STATES.has(task.status)) continue + if (task.ownerBotId === undefined) continue + const cards = cardsOf.get(task.ownerBotId) ?? [] + cards.push(task) + cardsOf.set(task.ownerBotId, cards) + } + const holders = list.filter(member => cardsOf.has(member.id)) + if (holders.length === 0) return 'Who is working: nobody (no open card).' + const built = [] + for (const member of holders) { + const cards = cardsOf.get(member.id).sort(compareForWork) + built.push(presenceLine(cards[0], nameOfMember(member), now, cards.length - 1)) + } + for (const member of list.filter(item => !cardsOf.has(item.id))) built.push(`- ${nameOfMember(member)} — idle (no open card)`) + const header = 'Who is working (from the task board; nobody has to post here):' + const render = count => [header, ...built.slice(0, count), built.length > count ? `- +${built.length - count} more member(s)` : ''].filter(Boolean) + let count = Math.min(PRESENCE_MEMBERS, built.length) + let lines = render(count) + while (count > 1 && lines.join('\n').length > ROOM_PRESENCE_BUDGET) { + count -= 1 + lines = render(count) + } + return lines.join('\n') +} diff --git a/core/test/room-context.test.js b/core/test/room-context.test.js index 60c5cc199..a06c92a2b 100644 --- a/core/test/room-context.test.js +++ b/core/test/room-context.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { ROOM_CONTEXT_BUDGET, boardCoversCue, compactStatus, fitRoomLines, formatRoomBoard, prepareRoomLine, statusShaped } from '../src/host/room-context.js' +import { ROOM_CONTEXT_BUDGET, ROOM_PRESENCE_BUDGET, boardCoversCue, compactStatus, fitRoomLines, formatRoomBoard, formatRoomPresence, prepareRoomLine, statusShaped } from '../src/host/room-context.js' const render = line => `${line.who}: ${line.text}` const essay = (tail) => `CI on tip abcdef12 is green. tests-ok passed. enqueue when mergeable. merge_group is next. PASS at abcdef12. @Chief ${'detail '.repeat(80)}${tail}` @@ -103,3 +103,85 @@ test('an open card covers a status @ and not a question or a new ask', () => { assert.equal(boardCoversCue(tasks, { roomId: 'room', botId: 'other', text: '@Other Writer implements' }), false) assert.equal(boardCoversCue({ only: { ...tasks.waiting } }, { ...cue, text: '@Writer done' }), false) }) + +// #1213: the room shows who is on what, read from the board, so a member that claimed +// and stayed quiet is not read as idle. These are the fixtures the issue asks for: two +// holders and one idle member, and a holder whose card is removed. +const presenceAt = Date.parse('2026-10-26T02:00:00Z') +const presenceMembers = [{ id: 'dev2', name: 'Dev2' }, { id: 'dev3', name: 'Dev3' }, { id: 'chief', name: 'Chief' }] +const presenceTasks = () => ({ + race: { id: 'race', status: 'working', progress: 15, title: 'Connectors test race', ownerBotId: 'dev2', origin: { roomId: 'room' }, target: { number: 1081 }, createdAt: presenceAt - 27 * 60000, updatedAt: presenceAt - 27 * 60000 }, + recursion: { id: 'recursion', status: 'claimed', progress: 0, title: 'Face room recursion', ownerBotId: 'dev3', origin: { roomId: 'room' }, target: { number: 979 }, createdAt: presenceAt - 3 * 60000, updatedAt: presenceAt - 3 * 60000 }, + done: { id: 'done', status: 'accepted', title: 'FINISHED_MARKER', ownerBotId: 'dev3', origin: { roomId: 'room' }, updatedAt: presenceAt }, + elsewhere: { id: 'elsewhere', status: 'working', progress: 50, title: 'OTHER_ROOM', ownerBotId: 'dev2', origin: { roomId: 'elsewhere' }, updatedAt: presenceAt }, +}) + +test('presence: two holders and an idle member each get one line, with column, progress and age', () => { + const text = formatRoomPresence(presenceTasks(), { roomId: 'room', members: presenceMembers, now: presenceAt }) + assert.match(text, /^- Dev2 — #1081 Connectors test race \(in progress 15%, open 27 min\)$/m) + assert.match(text, /^- Dev3 — #979 Face room recursion \(not started, just claimed, open 3 min\)$/m) + assert.match(text, /^- Chief — idle \(no open card\)$/m) + assert.doesNotMatch(text, /FINISHED_MARKER|OTHER_ROOM/) + assert.ok(text.startsWith('Who is working')) +}) + +test('presence: a card removed turns its holder into idle (the strip follows the board)', () => { + const tasks = presenceTasks() + delete tasks.recursion + const text = formatRoomPresence(tasks, { roomId: 'room', members: presenceMembers, now: presenceAt }) + assert.doesNotMatch(text, /Dev3 — #979/) + assert.match(text, /^- Dev3 — idle \(no open card\)$/m) + assert.match(text, /^- Dev2 — #1081/m) +}) + +test('presence: a waiting card says so, and a moved card adds when it moved', () => { + const tasks = { + ask: { id: 'ask', status: 'blocked', waitingFor: 'input', progress: 40, title: 'Need a name', ownerBotId: 'dev2', origin: { roomId: 'room' }, createdAt: presenceAt - 12 * 60000, updatedAt: presenceAt - 2 * 60000 }, + } + const text = formatRoomPresence(tasks, { roomId: 'room', members: [{ id: 'dev2', name: 'Dev2' }], now: presenceAt }) + assert.match(text, /- Dev2 — Need a name \(waiting on an answer, open 12 min, moved 2 min ago\)/) +}) + +test('presence: a holder with three cards names the first and counts the rest', () => { + const tasks = { + one: { id: 'one', status: 'working', progress: 10, title: 'First card', ownerBotId: 'dev2', origin: { roomId: 'room' }, createdAt: presenceAt, updatedAt: presenceAt }, + two: { id: 'two', status: 'open', progress: 0, title: 'Second card', ownerBotId: 'dev2', origin: { roomId: 'room' }, createdAt: presenceAt, updatedAt: presenceAt }, + three: { id: 'three', status: 'open', progress: 0, title: 'Third card', ownerBotId: 'dev2', origin: { roomId: 'room' }, createdAt: presenceAt, updatedAt: presenceAt }, + } + const text = formatRoomPresence(tasks, { roomId: 'room', members: [{ id: 'dev2', name: 'Dev2' }], now: presenceAt }) + assert.match(text, /^- Dev2 — First card \(in progress 10%, open 0 min\) \+2 more$/m) +}) + +test('presence: nobody holding a card is one readable line, never an empty block', () => { + assert.equal(formatRoomPresence({}, { roomId: 'room', members: presenceMembers, now: presenceAt }), 'Who is working: nobody (no open card).') + assert.equal(formatRoomPresence(presenceTasks(), { roomId: 'no-such-room', members: presenceMembers, now: presenceAt }), 'Who is working: nobody (no open card).') +}) + +test('presence: at most eight members, the rest counted, and the whole block stays under the budget', () => { + const tasks = {} + const members = [] + for (let at = 0; at < 12; at += 1) { + const id = `bot${at}` + members.push({ id, name: `Bot${at}` }) + tasks[id] = { id, status: 'working', progress: 50, title: `Card ${at}`, ownerBotId: id, origin: { roomId: 'room' }, createdAt: presenceAt, updatedAt: presenceAt } + } + const text = formatRoomPresence(tasks, { roomId: 'room', members, now: presenceAt }) + const memberLines = text.split('\n').filter(line => line.startsWith('- ') && line.includes(' — ')) + assert.ok(memberLines.length <= 8, `at most eight member lines, got ${memberLines.length}`) + assert.match(text, /^- \+\d+ more member\(s\)$/m) + assert.ok(text.length <= ROOM_PRESENCE_BUDGET, `block is ${text.length} chars`) +}) + +test('presence: a long title is flattened and cut, and no note prose rides along', () => { + const tasks = { + long: { id: 'long', status: 'working', progress: 5, title: `${'x'.repeat(300)}`, note: 'NOTE_PROSE '.repeat(40), ownerBotId: 'dev2', origin: { roomId: 'room' }, createdAt: presenceAt, updatedAt: presenceAt }, + } + const text = formatRoomPresence(tasks, { roomId: 'room', members: [{ id: 'dev2', name: 'Dev2' }], now: presenceAt }) + assert.doesNotMatch(text, /NOTE_PROSE|/) + assert.ok(text.length <= ROOM_PRESENCE_BUDGET) +}) + +test('presence: no members or no room renders nothing at all', () => { + assert.equal(formatRoomPresence(presenceTasks(), { roomId: 'room', members: [], now: presenceAt }), '') + assert.equal(formatRoomPresence(presenceTasks(), { members: presenceMembers, now: presenceAt }), '') +}) diff --git a/core/test/rounds.test.js b/core/test/rounds.test.js index 75de093a3..92dc04bd1 100644 --- a/core/test/rounds.test.js +++ b/core/test/rounds.test.js @@ -2035,3 +2035,54 @@ test('a Bot @ whose open card already shows the line passes without a turn (#120 await personRound assert.equal(person.sessionId, live.sessions[writer.id], 'a person @ still gets an answer') }) + + +// #1213: the turn reads the board twice — who is working (one line per member, idle ones +// included) and then the board itself. The owner asked "they are both working, why does the +// group show nothing"; a claimed card has to be readable before any PR exists. +test('a group turn shows who is working before the board it renders (#1213)', async (t) => { + const { call, emit, userMessage, relay, answer, stop, sent, rt } = start() + t.after(stop) + const chief = (await call('state', {})).value.mainBotId + const writer = (await call('create-bot', { name: 'Writer' })).value + const idle = (await call('create-bot', { name: 'Idle' })).value + const room = (await call('create-room', { name: 'R1213', members: [chief, writer.id, idle.id] })).value + await call('update-room', { id: room.id, mode: 'everyone' }) + const now = Date.now() + rt.state.tasks = { + card: { + id: 'card', status: 'working', progress: 15, title: 'Connectors test race', kind: 'task', + ownerBotId: writer.id, origin: { roomId: room.id }, target: { number: 1081 }, + createdAt: now - 27 * 60000, updatedAt: now - 27 * 60000, + }, + } + const playRound = async (label, check, { board = true } = {}) => { + sent.length = 0 + await emit(room.id, userMessage(label)) + const running = relay(room.id, `r1213-${label}`) + const seen = [] + for (let at = 0; at < 3; at += 1) { + const turn = await turnFor(sent, ...seen) + assert.ok(turn, `${label}: member ${at + 1} gets a turn`) + seen.push(turn) + const text = turn.message.content[0].text + assert.ok(text.includes('Who is working'), `${label}: the prompt carries the presence block`) + if (board) assert.ok(text.indexOf('Who is working') < text.indexOf('Task board (assign, claim'), `${label}: presence reads before the board`) + else assert.ok(!text.includes('Task board (assign, claim'), `${label}: no open card, so no board block`) + assert.doesNotMatch(text, /tests-ok|merge queue|Verdict/, `${label}: the anti-narration rule still holds`) + check(text) + await answer(turn.sessionId, turn.message.source.exchangeId, `${label} ${at + 1}.`, at + 1) + } + await running + } + await playRound('who', (text) => { + assert.match(text, /Who is working \(from the task board; nobody has to post here\):/) + assert.match(text, /- Writer — #1081 Connectors test race \(in progress 15%, open 2[67] min\)/) + assert.match(text, /- Idle — idle \(no open card\)/) + }) + // The board is the record: removing the card turns "Writer is working" into "nobody". + delete rt.state.tasks.card + await playRound('now', (text) => { + assert.match(text, /Who is working: nobody \(no open card\)\./) + }, { board: false }) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 610804992..a76d80354 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -106,6 +106,11 @@ A group turn does not hand the model the raw ring buffer. `src/host/room-context The open task board for that room is a compact snapshot in the same turn (`formatRoomBoard`): the newest overall task, then its subtasks and any room card that is not under a different overall task, up to eight cards. Done cards stay off. Titles and notes are one line, capped, with `<` and `>` removed. A face-to-face room has no snapshot and keeps `@Name` as how its lead gives a part. The standing section does not carry the snapshot or the transcript, so it still changes only when the group settings change. In a group that has a board, `ROOM_REPLY_BOARD` and the task-lead note say assigning, claiming, and progress are `track_tasks` writes (overall task, subtask, assignee, column, progress), `@Name` only wakes, and a status the board already shows is `[PASS]`. A task-drive wake in the group says to set that card's column and progress, and appends the same snapshot after its cue. Covered by `core/test/room-context.test.js`, `core/test/rounds.test.js`, and `core/test/task-drive.test.js`. +### Who is working (#1213) + +The board says what is open; the turn also says which member holds it, because a member that claims a card and stays quiet used to be indistinguishable from an idle one. `room-context.js` `formatRoomPresence(tasks, { roomId, members, now })` reads the same `rt.state.tasks` at the moment the turn is built — no write, no message, no heartbeat — and `groups.js` `roomPrompt` renders it just before `formatRoomBoard`, for a room with a board only (a face-to-face room gets neither). One line per member that holds an unfinished card in that room: `- Dev2 — #1081 (in progress 15%, open 27 min)`, the number from the card's `target`, the column word from `columnOf`, progress from `progressOf`, age from `createdAt` (`open N min`), and `, moved N min ago` when `updatedAt` is at least a minute newer; a card waiting on an input reads `waiting on an answer`, a 0% card `just claimed`, and a second card on the same owner is `+N more`. Every member that holds no card gets `- <Name> — idle (no open card)`, so "nobody is working" is readable too; when no member holds a card the block is the single line `Who is working: nobody (no open card).` At most eight member lines and `ROOM_PRESENCE_BUDGET` (400) characters, then `- +N more member(s)`; titles are flattened and cut, and a card's note prose never rides along. Only card state is rendered — never CI, the queue or a verdict, which #1203 keeps out of the chat. Covered by `core/test/room-context.test.js` and `core/test/rounds.test.js`. + + ### Group loop guards (#557) `post_to_group` has no cap on the rounds Bots start in one group between the user's lines. The old `ROOM_BOT_ROUNDS` (3) gate was turned off by owner decision 2026-10-21 07:34 Asia/Shanghai: it refused a recurring scheduled task's fourth post with a tool result only, so owner-set runs stopped overnight with nothing shown in the group. A saved room may still carry `botRounds`; nothing reads it, and `channel.js` keeps it out of the roster. The guards that stay: `MAX_HOPS` (6) ends a Bot-to-Bot chain (`message_bot`, and `post_to_group` from a relayed turn); `MAX_UNREAD_REQUESTS` (10) per Bot chat; `ROOM_TURNS_EACH` / `ROOM_ADMIN_TURNS_EACH` (2 / 4) per member per round, except the room's admin when a member names it with `@`: that call is never dropped by the cap (#804), because it is a report to the coordinator rather than a chain, and the round's own turn limit still bounds the round; `ROOM_LATE_ROUNDS` (10) late replies answered before the user speaks again; and a Bot cannot `post_to_group` into the room it is speaking in. Face-to-face rooms take the same path. Covered by `core/test/rounds.test.js`. diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index ec669b33e..ceba0af2b 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -1185,7 +1185,7 @@ diff --git a/src/host/delivery.js b/src/host/delivery.js diff --git a/test/rounds.test.js b/test/rounds.test.js --- a/test/rounds.test.js +++ b/test/rounds.test.js -@@ -114,6 +114,8 @@ const waitFor = async (check) => { +@@ -114,6 +114,8 @@ return undefined } const turnFor = (sent, ...earlier) => waitFor(() => sent.find(entry => !earlier.includes(entry) && entry.message.source.role === 'room')) @@ -1194,7 +1194,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a new group Session waits for capabilities before its first member prompt', async (t) => { const { call, emit, userMessage, relay, answer, stop, sent, rt } = start() -@@ -180,6 +180,69 @@ test('a message the composer queues mid-round joins the round and leaves the Ses +@@ -180,6 +180,69 @@ assert.deepEqual(inboxOf(room.id).nextTurn, []) }) @@ -1264,7 +1264,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a plain message steered mid-round is answered by everyone, admin first, inside the round', async (t) => { const { call, emit, userMessage, compose, relay, inboxOf, answer, stop, sent } = start() t.after(stop) -@@ -253,32 +253,6 @@ test('in admin mode a plain mid-round message goes to the admin', async (t) => { +@@ -253,32 +253,6 @@ assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer', 'User', 'Chief']) }) @@ -1297,7 +1297,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a message with an image or a chat reference waits for its own round, and so does what follows it', async (t) => { const { call, emit, userMessage, compose, relay, inboxOf, answer, stop, sent } = start() t.after(stop) -@@ -300,19 +300,26 @@ test('a message with an image or a chat reference waits for its own round, and s +@@ -300,19 +300,26 @@ assert.equal(inboxOf(room.id).nextStep.length, 1) }) @@ -1331,7 +1331,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js }) test('a stopped round leaves a waiting message for the next round', async (t) => { -@@ -331,6 +331,377 @@ test('a stopped round leaves a waiting message for the next round', async (t) => +@@ -331,6 +331,377 @@ assert.equal(inboxOf(room.id).nextTurn.length, 1) }) @@ -1709,7 +1709,7 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js test('a message sent between rounds still opens a full round', async (t) => { const { call, emit, userMessage, relay, answer, stop, sent } = start() t.after(stop) -@@ -347,3 +347,1274 @@ test('a message sent between rounds still opens a full round', async (t) => { +@@ -347,3 +347,1325 @@ const result = JSON.parse(await running) assert.deepEqual(result.replies.map(reply => reply.name ?? 'User'), ['Chief', 'Writer']) }) @@ -2984,3 +2984,54 @@ diff --git a/test/rounds.test.js b/test/rounds.test.js + await personRound + assert.equal(person.sessionId, live.sessions[writer.id], 'a person @ still gets an answer') +}) ++ ++ ++// #1213: the turn reads the board twice — who is working (one line per member, idle ones ++// included) and then the board itself. The owner asked "they are both working, why does the ++// group show nothing"; a claimed card has to be readable before any PR exists. ++test('a group turn shows who is working before the board it renders (#1213)', async (t) => { ++ const { call, emit, userMessage, relay, answer, stop, sent, rt } = start() ++ t.after(stop) ++ const chief = (await call('state', {})).value.mainBotId ++ const writer = (await call('create-bot', { name: 'Writer' })).value ++ const idle = (await call('create-bot', { name: 'Idle' })).value ++ const room = (await call('create-room', { name: 'R1213', members: [chief, writer.id, idle.id] })).value ++ await call('update-room', { id: room.id, mode: 'everyone' }) ++ const now = Date.now() ++ rt.state.tasks = { ++ card: { ++ id: 'card', status: 'working', progress: 15, title: 'Connectors test race', kind: 'task', ++ ownerBotId: writer.id, origin: { roomId: room.id }, target: { number: 1081 }, ++ createdAt: now - 27 * 60000, updatedAt: now - 27 * 60000, ++ }, ++ } ++ const playRound = async (label, check, { board = true } = {}) => { ++ sent.length = 0 ++ await emit(room.id, userMessage(label)) ++ const running = relay(room.id, `r1213-${label}`) ++ const seen = [] ++ for (let at = 0; at < 3; at += 1) { ++ const turn = await turnFor(sent, ...seen) ++ assert.ok(turn, `${label}: member ${at + 1} gets a turn`) ++ seen.push(turn) ++ const text = turn.message.content[0].text ++ assert.ok(text.includes('Who is working'), `${label}: the prompt carries the presence block`) ++ if (board) assert.ok(text.indexOf('Who is working') < text.indexOf('Task board (assign, claim'), `${label}: presence reads before the board`) ++ else assert.ok(!text.includes('Task board (assign, claim'), `${label}: no open card, so no board block`) ++ assert.doesNotMatch(text, /tests-ok|merge queue|Verdict/, `${label}: the anti-narration rule still holds`) ++ check(text) ++ await answer(turn.sessionId, turn.message.source.exchangeId, `${label} ${at + 1}.`, at + 1) ++ } ++ await running ++ } ++ await playRound('who', (text) => { ++ assert.match(text, /Who is working \(from the task board; nobody has to post here\):/) ++ assert.match(text, /- Writer — #1081 Connectors test race \(in progress 15%, open 2[67] min\)/) ++ assert.match(text, /- Idle — idle \(no open card\)/) ++ }) ++ // The board is the record: removing the card turns "Writer is working" into "nobody". ++ delete rt.state.tasks.card ++ await playRound('now', (text) => { ++ assert.match(text, /Who is working: nobody \(no open card\)\./) ++ }, { board: false }) ++}) diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index f9314a506..88820d006 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -794,7 +794,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js +import { faceMentions } from './face-mentions.js' +import { cleanName, stripSessionChips, unwrapUntrusted } from './delivery.js' +import { createRoomParticipants } from './room-participant.js' -+import { ROOM_CONTEXT_BUDGET, boardCoversCue, fitRoomLines, formatRoomBoard, prepareRoomLine } from './room-context.js' ++import { ROOM_CONTEXT_BUDGET, boardCoversCue, fitRoomLines, formatRoomBoard, formatRoomPresence, prepareRoomLine } from './room-context.js' const ROOM_LOG_MAX = 60 const ROOM_FIRST_LOOK = 12 @@ -885,8 +885,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js return `${line.who}: ${line.text}` } const transcript = lines => lines.map(lineText).join('\n') -@@ -85,7 +85,17 @@ - ? `Conversations the user referenced in this round:\n\n${references.map(reference => reference.text).join('\n\n')}` +@@ -86,7 +86,17 @@ : '') const ROOM_REPLY = 'Reply with your message for the group, no name prefix: in a discussion, one or two short messages. When you are asked to do something, do it now with your tools. The group does not wait for you, and your final reply is posted here when you finish, so give the result, never a promise to do it later. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.' @@ -903,7 +902,8 @@ diff --git a/src/host/groups.js b/src/host/groups.js +const ROOM_CALLS_BOARD = 'Writing "@Name" wakes that member to answer, or to do the card the board already assigned to it. It does not assign work or record progress. When it finishes that card, you answer its result. To only mention a member, write its name without "@".' const slotLabel = (slot) => { -@@ -100,165 +100,237 @@ + if (slot.status === 'speaking') return `${slot.name} (you, now)` +@@ -100,162 +100,242 @@ return slot.name } @@ -945,12 +945,6 @@ diff --git a/src/host/groups.js b/src/host/groups.js // A member's group Session, created on its first turn there. A new one starts with // a fresh look at the log and a brief of the member's own chat; the brief is the // first message, so it stays fixed and the prefix stays cacheable. -- async function groupSessionFor(room, member) { -- room.sessions ??= {} -- const known = room.sessions[member.id] -- if (known !== undefined) { -- await rt.ensureCapabilities(known) -- return { id: known, brief: undefined } + // The brief is the member's owner's private chat with the Bot. In a room where + // another owner sits it stays out unless that owner shared it with this room (#53). + const briefAllowed = (room, member) => { @@ -962,7 +956,19 @@ diff --git a/src/host/groups.js b/src/host/groups.js + // A granted remote Bot speaks for another home; its handle stands for that owner. + else if (p.remote) owners.add(p.handle) + else owners.add(rt.botOf(p.id)?.ownerId) - } ++ } ++ owners.delete(undefined) ++ if (owners.size <= 1) return true ++ return participants.some(p => p.kind === 'bot' && p.id === member.id && p.briefShared === true) ++ } ++ + async function groupSessionFor(room, member) { +- room.sessions ??= {} +- const known = room.sessions[member.id] +- if (known !== undefined) { +- await rt.ensureCapabilities(known) +- return { id: known, brief: undefined } +- } - const id = await rt.createSession(`${member.name} · ${room.name}`, { dir: rt.workDirOf({ botId: member.id, roomId: room.id }) }) - room.sessions[member.id] = id - rt.groupOwners.set(id, { botId: member.id, roomId: room.id }) @@ -971,12 +977,6 @@ diff --git a/src/host/groups.js b/src/host/groups.js - if (ref !== undefined) { - await rt.selectSessionModel(id, splitRef(ref)) - .catch(error => rt.warn('ds-bot: model for %s in %s not applied: %s', member.name, room.name, error)) -+ owners.delete(undefined) -+ if (owners.size <= 1) return true -+ return participants.some(p => p.kind === 'bot' && p.id === member.id && p.briefShared === true) -+ } -+ -+ async function groupSessionFor(room, member) { + const session = await participant.open({ room, member }) + return { id: session.id, brief: session.created && briefAllowed(room, member) ? await rt.ownChatBrief(member) : undefined } + } @@ -1124,6 +1124,13 @@ diff --git a/src/host/groups.js b/src/host/groups.js - const notice = room.notice ? `Group notice (shared instructions for every member):\n${room.notice}` : '' - if (round.kickoff) return [members, notice, leads ? adminNote(room) : '', roomKickoff(rt.uiLanguage?.())].filter(Boolean).join('\n\n') const working = workingIn(room).filter(item => item.bot.id !== member.id) ++ // Who is on what, before the board itself (#1213): the board says what is open, this ++ // says which member holds it, so a working and an idle member read differently without ++ // anyone posting. Read from the same state, at the moment this turn is built. ++ const presence = isFaceRoom(room) ? '' : formatRoomPresence(rt.state.tasks, { ++ roomId: room.id, ++ members: room.members.map(id => ({ id, name: rt.botOf(id)?.name })), ++ }) + const board = isFaceRoom(room) ? '' : formatRoomBoard(rt.state.tasks, { + roomId: room.id, + nameOf: task => rt.botOf(task.ownerBotId)?.name, @@ -1134,6 +1141,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js - leads ? adminNote(room) : '', - earlier.length ? `Earlier in the group, before this round (context only):\n${transcript(earlier)}` : '', + noticeChange(room, member), ++ presence, + board, + fitted.omitted > 0 ? `${fitted.omitted} older lines are not in this turn. Progress is on the task board; call read_group_chat when you need an exact earlier line.` : '', + earlier.length ? `Earlier in the group, before this round (context only):\n${show(earlier)}` : '', @@ -1244,9 +1252,6 @@ diff --git a/src/host/groups.js b/src/host/groups.js if (by === undefined) delete called.by else called.by = by round.slots.splice(next, 0, called) - next += 1 - } - } @@ -285,34 +285,138 @@ // Session's inbox, so it does not open another round when this one ends. Returns // the number of turns added, which the round's limit allows for. @@ -1911,6 +1916,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js const latest = [...options.messages].reverse() .find(message => message.role === 'assistant' || message.role === 'tool' || opens(message)) if (latest?.role !== 'user') { + diff --git a/test/at-menu.test.js b/test/at-menu.test.js --- a/test/at-menu.test.js +++ b/test/at-menu.test.js diff --git a/patches/ds-bot/files/src/host/room-context.js b/patches/ds-bot/files/src/host/room-context.js index 74b3b2f85..5a76144a8 100644 --- a/patches/ds-bot/files/src/host/room-context.js +++ b/patches/ds-bot/files/src/host/room-context.js @@ -2,7 +2,7 @@ // whole. The turn gets a newer-first slice under a character budget, and a long // status essay is collapsed so it cannot fill that slice. Progress for the open // job is a compact board snapshot in the same turn, not those essays. -import { compareForWork, progressOf } from '../shared/task-board.js' +import { columnOf, compareForWork, progressOf } from '../shared/task-board.js' // Characters, not a tokenizer. The essays this was measured on are mostly CJK, // about one token per character, so a character ceiling is the conservative one: @@ -165,3 +165,68 @@ export function formatRoomBoard(tasks, { roomId, nameOf, limit = 8 } = {}) { if (hidden > 0) lines.push(`- ${hidden} more open card(s) not shown`) return lines.join('\n') } + +// Who is on what, read from the board at the moment the turn is built (#1213). Members +// followed the rule — claim the card, update it, say nothing — and the room became +// unreadable: silence meant "idle" only because nothing rendered the board's holders. +// One line per member who holds an open card in THIS room, one line per member who does +// not, so "nobody is working" is readable too. Read-only: no write, no message, no +// heartbeat. Columns, progress and age only — never CI, the queue or a verdict, which +// #1203 keeps off the chat. +export const ROOM_PRESENCE_BUDGET = 400 +const PRESENCE_MEMBERS = 8 +const OPEN_STATES = new Set(['open', 'claimed', 'working', 'blocked', 'result-ready']) + +const columnWord = column => (column === 'in-progress' ? 'in progress' : column === 'not-started' ? 'not started' : column) +const minutesBetween = (from, now) => Math.max(0, Math.round((now - from) / 60000)) + +// `- Dev2 — #1081 title (in progress 15%, open 27 min)`: the card's own fields, flattened +// to one line. A 0% card says `just claimed` instead of a percentage, and a tag is +// counted from `createdAt` — the fields the record already has, no new one. +function presenceLine(task, name, now, extra = 0) { + const ref = typeof task.target?.number === 'number' ? `#${task.target.number} ` : '' + const title = oneLine(task.title || '(untitled)', 60) + const waiting = task.waitingFor === 'input' + const head = waiting ? 'waiting on an answer' : columnWord(columnOf(task)) + const pct = progressOf(task) + const detail = waiting ? '' : pct > 0 ? ` ${pct}%` : ', just claimed' + const created = Number.isFinite(task.createdAt) ? task.createdAt : now + const open = `open ${minutesBetween(created, now)} min` + const moved = Number.isFinite(task.updatedAt) && task.updatedAt - created >= 60000 + ? `, moved ${minutesBetween(task.updatedAt, now)} min ago` + : '' + const more = extra > 0 ? ` +${extra} more` : '' + return `- ${name} — ${ref}${title} (${head}${detail}, ${open}${moved})${more}` +} + +export function formatRoomPresence(tasks, { roomId, members, now = Date.now(), nameOf } = {}) { + if (!roomId) return '' + const list = (members ?? []).filter(member => member && member.id !== undefined) + if (list.length === 0) return '' + const nameOfMember = member => oneLine(typeof nameOf === 'function' ? nameOf(member) : (member.name ?? member.id), 40) || String(member.id) + const cardsOf = new Map() + for (const task of Object.values(tasks ?? {})) { + if (!task || task.origin?.roomId !== roomId || !OPEN_STATES.has(task.status)) continue + if (task.ownerBotId === undefined) continue + const cards = cardsOf.get(task.ownerBotId) ?? [] + cards.push(task) + cardsOf.set(task.ownerBotId, cards) + } + const holders = list.filter(member => cardsOf.has(member.id)) + if (holders.length === 0) return 'Who is working: nobody (no open card).' + const built = [] + for (const member of holders) { + const cards = cardsOf.get(member.id).sort(compareForWork) + built.push(presenceLine(cards[0], nameOfMember(member), now, cards.length - 1)) + } + for (const member of list.filter(item => !cardsOf.has(item.id))) built.push(`- ${nameOfMember(member)} — idle (no open card)`) + const header = 'Who is working (from the task board; nobody has to post here):' + const render = count => [header, ...built.slice(0, count), built.length > count ? `- +${built.length - count} more member(s)` : ''].filter(Boolean) + let count = Math.min(PRESENCE_MEMBERS, built.length) + let lines = render(count) + while (count > 1 && lines.join('\n').length > ROOM_PRESENCE_BUDGET) { + count -= 1 + lines = render(count) + } + return lines.join('\n') +} diff --git a/patches/ds-bot/files/test/room-context.test.js b/patches/ds-bot/files/test/room-context.test.js index 60c5cc199..a06c92a2b 100644 --- a/patches/ds-bot/files/test/room-context.test.js +++ b/patches/ds-bot/files/test/room-context.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { ROOM_CONTEXT_BUDGET, boardCoversCue, compactStatus, fitRoomLines, formatRoomBoard, prepareRoomLine, statusShaped } from '../src/host/room-context.js' +import { ROOM_CONTEXT_BUDGET, ROOM_PRESENCE_BUDGET, boardCoversCue, compactStatus, fitRoomLines, formatRoomBoard, formatRoomPresence, prepareRoomLine, statusShaped } from '../src/host/room-context.js' const render = line => `${line.who}: ${line.text}` const essay = (tail) => `CI on tip abcdef12 is green. tests-ok passed. enqueue when mergeable. merge_group is next. PASS at abcdef12. @Chief ${'detail '.repeat(80)}${tail}` @@ -103,3 +103,85 @@ test('an open card covers a status @ and not a question or a new ask', () => { assert.equal(boardCoversCue(tasks, { roomId: 'room', botId: 'other', text: '@Other Writer implements' }), false) assert.equal(boardCoversCue({ only: { ...tasks.waiting } }, { ...cue, text: '@Writer done' }), false) }) + +// #1213: the room shows who is on what, read from the board, so a member that claimed +// and stayed quiet is not read as idle. These are the fixtures the issue asks for: two +// holders and one idle member, and a holder whose card is removed. +const presenceAt = Date.parse('2026-10-26T02:00:00Z') +const presenceMembers = [{ id: 'dev2', name: 'Dev2' }, { id: 'dev3', name: 'Dev3' }, { id: 'chief', name: 'Chief' }] +const presenceTasks = () => ({ + race: { id: 'race', status: 'working', progress: 15, title: 'Connectors test race', ownerBotId: 'dev2', origin: { roomId: 'room' }, target: { number: 1081 }, createdAt: presenceAt - 27 * 60000, updatedAt: presenceAt - 27 * 60000 }, + recursion: { id: 'recursion', status: 'claimed', progress: 0, title: 'Face room recursion', ownerBotId: 'dev3', origin: { roomId: 'room' }, target: { number: 979 }, createdAt: presenceAt - 3 * 60000, updatedAt: presenceAt - 3 * 60000 }, + done: { id: 'done', status: 'accepted', title: 'FINISHED_MARKER', ownerBotId: 'dev3', origin: { roomId: 'room' }, updatedAt: presenceAt }, + elsewhere: { id: 'elsewhere', status: 'working', progress: 50, title: 'OTHER_ROOM', ownerBotId: 'dev2', origin: { roomId: 'elsewhere' }, updatedAt: presenceAt }, +}) + +test('presence: two holders and an idle member each get one line, with column, progress and age', () => { + const text = formatRoomPresence(presenceTasks(), { roomId: 'room', members: presenceMembers, now: presenceAt }) + assert.match(text, /^- Dev2 — #1081 Connectors test race \(in progress 15%, open 27 min\)$/m) + assert.match(text, /^- Dev3 — #979 Face room recursion \(not started, just claimed, open 3 min\)$/m) + assert.match(text, /^- Chief — idle \(no open card\)$/m) + assert.doesNotMatch(text, /FINISHED_MARKER|OTHER_ROOM/) + assert.ok(text.startsWith('Who is working')) +}) + +test('presence: a card removed turns its holder into idle (the strip follows the board)', () => { + const tasks = presenceTasks() + delete tasks.recursion + const text = formatRoomPresence(tasks, { roomId: 'room', members: presenceMembers, now: presenceAt }) + assert.doesNotMatch(text, /Dev3 — #979/) + assert.match(text, /^- Dev3 — idle \(no open card\)$/m) + assert.match(text, /^- Dev2 — #1081/m) +}) + +test('presence: a waiting card says so, and a moved card adds when it moved', () => { + const tasks = { + ask: { id: 'ask', status: 'blocked', waitingFor: 'input', progress: 40, title: 'Need a name', ownerBotId: 'dev2', origin: { roomId: 'room' }, createdAt: presenceAt - 12 * 60000, updatedAt: presenceAt - 2 * 60000 }, + } + const text = formatRoomPresence(tasks, { roomId: 'room', members: [{ id: 'dev2', name: 'Dev2' }], now: presenceAt }) + assert.match(text, /- Dev2 — Need a name \(waiting on an answer, open 12 min, moved 2 min ago\)/) +}) + +test('presence: a holder with three cards names the first and counts the rest', () => { + const tasks = { + one: { id: 'one', status: 'working', progress: 10, title: 'First card', ownerBotId: 'dev2', origin: { roomId: 'room' }, createdAt: presenceAt, updatedAt: presenceAt }, + two: { id: 'two', status: 'open', progress: 0, title: 'Second card', ownerBotId: 'dev2', origin: { roomId: 'room' }, createdAt: presenceAt, updatedAt: presenceAt }, + three: { id: 'three', status: 'open', progress: 0, title: 'Third card', ownerBotId: 'dev2', origin: { roomId: 'room' }, createdAt: presenceAt, updatedAt: presenceAt }, + } + const text = formatRoomPresence(tasks, { roomId: 'room', members: [{ id: 'dev2', name: 'Dev2' }], now: presenceAt }) + assert.match(text, /^- Dev2 — First card \(in progress 10%, open 0 min\) \+2 more$/m) +}) + +test('presence: nobody holding a card is one readable line, never an empty block', () => { + assert.equal(formatRoomPresence({}, { roomId: 'room', members: presenceMembers, now: presenceAt }), 'Who is working: nobody (no open card).') + assert.equal(formatRoomPresence(presenceTasks(), { roomId: 'no-such-room', members: presenceMembers, now: presenceAt }), 'Who is working: nobody (no open card).') +}) + +test('presence: at most eight members, the rest counted, and the whole block stays under the budget', () => { + const tasks = {} + const members = [] + for (let at = 0; at < 12; at += 1) { + const id = `bot${at}` + members.push({ id, name: `Bot${at}` }) + tasks[id] = { id, status: 'working', progress: 50, title: `Card ${at}`, ownerBotId: id, origin: { roomId: 'room' }, createdAt: presenceAt, updatedAt: presenceAt } + } + const text = formatRoomPresence(tasks, { roomId: 'room', members, now: presenceAt }) + const memberLines = text.split('\n').filter(line => line.startsWith('- ') && line.includes(' — ')) + assert.ok(memberLines.length <= 8, `at most eight member lines, got ${memberLines.length}`) + assert.match(text, /^- \+\d+ more member\(s\)$/m) + assert.ok(text.length <= ROOM_PRESENCE_BUDGET, `block is ${text.length} chars`) +}) + +test('presence: a long title is flattened and cut, and no note prose rides along', () => { + const tasks = { + long: { id: 'long', status: 'working', progress: 5, title: `${'x'.repeat(300)}<b>`, note: 'NOTE_PROSE '.repeat(40), ownerBotId: 'dev2', origin: { roomId: 'room' }, createdAt: presenceAt, updatedAt: presenceAt }, + } + const text = formatRoomPresence(tasks, { roomId: 'room', members: [{ id: 'dev2', name: 'Dev2' }], now: presenceAt }) + assert.doesNotMatch(text, /NOTE_PROSE|<b>/) + assert.ok(text.length <= ROOM_PRESENCE_BUDGET) +}) + +test('presence: no members or no room renders nothing at all', () => { + assert.equal(formatRoomPresence(presenceTasks(), { roomId: 'room', members: [], now: presenceAt }), '') + assert.equal(formatRoomPresence(presenceTasks(), { members: presenceMembers, now: presenceAt }), '') +})