Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1213-room-presence.changed.md
Original file line number Diff line number Diff line change
@@ -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)).
10 changes: 9 additions & 1 deletion core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)}` : '',
Expand Down
67 changes: 66 additions & 1 deletion core/src/host/room-context.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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')
}
84 changes: 83 additions & 1 deletion core/test/room-context.test.js
Original file line number Diff line number Diff line change
@@ -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}`
Expand Down Expand Up @@ -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 }), '')
})
51 changes: 51 additions & 0 deletions core/test/rounds.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
5 changes: 5 additions & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <title> (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`.
Expand Down
Loading
Loading