Skip to content

Commit 1701a67

Browse files
authored
Show who is working in a group turn, read from that room’s board (#1213) (#1226)
Written-by: Dev3 Fixes #1213 Owner: Dev3 Reviewer: Reviewer Tip SHA: 6b069be The **code half** of #1213, option (a): a group turn now shows who is working, read from that room's board at the moment the turn is built. The wording half (one line at claim, in the group notice and `AGENTS.md`) already merged as #1217 and is not here. `core/src/host/room-context.js` gains `formatRoomPresence(tasks, { roomId, members, now })`, and `groups.js` `roomPrompt` renders it immediately before the board, for a room with a board only — a face-to-face room gets neither block. It reads `rt.state.tasks`: no new write, no endpoint, no heartbeat, and it never renders CI, queue state or a verdict, so #1203's anti-narration rule still holds. **Rebased onto `d04712bb`, one commit, same ten files.** The base moved from `f5031756` to `d04712bb`, which carries #1219's delivery work and #1230's read-only fix; both touch files this change also touches, which is why the pull request was `dirty`. The two patch sections that belong to this change — `groups.js` in `0012-room-membership.patch` and `rounds.test.js` in `0005-social-seams.patch` — were regenerated against the new tree rather than reusing old hunks, and the one-byte newline fix is already in it. Nothing else changed: the diff is the same ten files, and `1a2d30a`'s PASS is void by the move, so the new tip is reviewed once from scratch. **Why idle members are shown (ruling of 2026-10-26):** the two rules act on different layers, they do not collide. "Nobody without an in-progress card says anything" governs what a *member* posts in the room; this block is text the turn builder renders, spent by no member and costing no member's turn, and it renders no CI state, no queue state and no verdict, so #1203 holds untouched. Acceptance 4 then needs the idle line on its own merit: the incident behind this issue was the owner reading silence as "these two are idle", and a strip listing only holders would leave a room where nobody holds a card looking exactly as empty as before. So a member with no open card gets one `idle (no open card)` line, and when no member holds a card the block is the single line `Who is working: nobody (no open card).` The cost is bounded by construction: at most 8 member lines, `ROOM_PRESENCE_BUDGET` (400) characters, `+N more member(s)`, and only in a room that has a board. ## Reproduce 2026-10-26, group "FlyBot 交付组": two members held live cards for most of an hour (#1081 Dev2 at 15%, #979 Dev3 at 10%, notes naming their first step) and the room showed nothing from either. The owner read the silence as "these two are idle" and asked why they were not working — the board was correct, the turn simply never rendered it. On `main` a turn shows the `Task board` snapshot only; nothing names who holds which card, and nothing says when everyone is idle. ## What changed - `core/src/host/room-context.js`: new `formatRoomPresence` (pure; reuses `oneLine`, `compareForWork`, `progressOf`, `columnOf`). One line per holder: `- 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`, plus `, moved N min ago` when `updatedAt` is at least a minute newer; a card waiting on input reads `waiting on an answer`, a 0% card `just claimed`, and a second card on the same owner `+N more`. Every member with no card gets `- <Name> — idle (no open card)`; with no holders at all the block is `Who is working: nobody (no open card).` At most 8 member lines and `ROOM_PRESENCE_BUDGET` (400) characters, then `- +N more member(s)`; titles flattened and cut, no note prose. - `core/src/host/groups.js`: `roomPrompt` renders `presence` between the notice change and the board, guarded by `isFaceRoom` like the board. - `core/test/room-context.test.js`: 8 fixtures — two holders + one idle; a removed card turns its holder idle; waiting + moved; a holder with three cards (`+2 more`); no holder; 12 members (8 lines + `+N more`, block ≤ 400); a long title with note prose; no members / no room. - `core/test/rounds.test.js`: one round-level test — three members, one card: every member's turn carries `Who is working …` before `Task board …`, the holder line and the idle line; deleting the card turns the same room into `nobody (no open card)` and drops the board block; no `tests-ok` / `merge queue` / `Verdict` text appears. - `docs/subsystems/core-host.md`: new subsection `### Who is working (#1213)` — the contract (fields, caps, idle rule, read-only, face-room exclusion). - `changelog.d/1213-room-presence.changed.md`: one user-visible line (this one *is* user-visible). - Patches: `patches/ds-bot/0012-room-membership.patch` (groups.js), `patches/ds-bot/0005-social-seams.patch` (rounds.test.js) and the two whole-file mirrors under `patches/ds-bot/files/`. **This box has no `git`, so `generate` refuses to run**; the two patch sections were regenerated from `diff -u ds-bot/<path> core/<path>` with the format rule applied (no `index` lines, each hunk's new-side start set to its old-side start), and `check` verifies the result applies and equals `core/`. `ds-bot/` is untouched and no `core/client.js` change was needed. ## What I ran - `node --test core/test/room-context.test.js` → **16 pass, 0 fail** (8 existing + 8 new). - `node --test core/test/rounds.test.js` → **52 pass, 0 fail** (51 existing + the new one). - `node scripts/ds-bot-patches.mjs check`, all three lines verbatim: ``` skipped pin (no git): the tree hash cannot be computed here ok 10 patches apply cleanly in order ok core/ equals ds-bot/ + patches/ds-bot/ ``` That middle line is the load-bearing one: the two patch sections were hand-cut here, so "applies cleanly in order" plus "core/ equals ds-bot/ + patches/ds-bot/" is the whole evidence that they match the source. - The diff is exactly the ten files listed above and nothing else: **`ds-bot/` carries zero changes** (the pin line still reads `Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5)`), and **`core/client.js` does not appear** — nothing under `core/src/client/**` changed, so no bundle rebuild and no `0008-client-bundle` entry was needed. Its absence is correct, not a missed step. - `node .github/scripts/changelog.mjs check` → `changelog: 1 fragment(s) in changelog.d/ are well formed`. - The branch is based on live main `f5031756`; `compare` shows only the ten files above. ## Red without the source change - Delete `presence,` from `roomPrompt` (the wiring only, `formatRoomPresence` untouched) → the round test goes red: `not ok 1 - a group turn shows who is working before the board it renders (#1213)`, thrown at the `Who is working` assertion. - `package` on the previous tip (`89fa83d1`) was red on one byte, not on the change. CI log, verbatim: `ok ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5)` then `ds-bot-patches: 0005-social-seams.patch does not apply cleanly:` / `error: corrupt patch at /home/runner/work/FlyBot/FlyBot/patches/ds-bot/0005-social-seams.patch:3035`. Cause: the regenerated `0005-social-seams.patch` was written without its final newline, which this box's git-free `check` tolerates and real git rejects. `1a2d30a` restores that single trailing byte (one file, +1 −1). `tests-ok` on that tip was red only because it aggregates `PACKAGE` (`test: success, plugins: success, package: failure`). - Delete the idle-line loop in `formatRoomPresence` → `14 pass / 2 fail`: `two holders and an idle member each get one line …` and `a card removed turns its holder into idle …`. ## Not run - No `git` on this box, so `npm run verify` and `generate` cannot run here. `generate` refuses verbatim: ``` ds-bot-patches: generate needs git: it asks git for the diff of ds-bot/ against core/. Without git, edit core/ and patches/ds-bot/ together and run check. ``` The same missing git prints in `check` as `skipped pin (no git): the tree hash cannot be computed here`. CI runs both with git, and that is the only place the pin and the checkout are exercised. - `oxlint` / `npm run lint` and `arch-docs`: the partial `core/node_modules` on this box has no binaries. The subsystem doc is updated, so `arch-docs` should see its owned path; CI decides. - No smoke test (this changes `src/host/` rendering, not `index.js`): `dev/bench.sh smoke` needs the DSH checkout; CI/no. ## Risk - The block is per-turn text, so its cost is characters, not writes: 400 chars worst case, and it is rendered only where a board exists. A room with many members will show holders first and idle members only while they fit — by design, but it means "everyone else is idle" is summarized as `+N more member(s)` rather than spelled out. - A member whose card belongs to a different room is not shown in this one (cards are per room, #1209's neighbour rule); two rooms looking at the same Bot can disagree, correctly. - `progressOf` clamps and rounds, so a 0% card reads `just claimed` and a 100% non-done card reads `100%`; the column word still says whether it is really done. - Reading `rt.state.tasks` at turn-build time means a card updated a second earlier is already visible, which is the point, but it also means the strip is as fresh as the turn — nothing caches it. ## Checkpoint <!-- flybot:checkpoint --> ```json { "pr": 1226, "claim": "Dev3", "issue": 1213, "summary": "A group turn now renders who is working from that room's board: one line per member holding an unfinished card with number, column, progress, age and last move, one idle line per member holding none, and a single nobody line when no card is open. It reads the board when the turn is built, writes nothing, sends no message and starts no heartbeat, and a face-to-face room renders neither block. Caps are eight member lines and 400 characters, plus +N more. The subsystem doc and a changelog fragment move with it, and the hand-cut patch sections are verified by check.", "files": ["core/src/host/room-context.js", "core/src/host/groups.js", "core/test/room-context.test.js", "core/test/rounds.test.js", "patches/ds-bot/0012-room-membership.patch", "patches/ds-bot/0005-social-seams.patch", "patches/ds-bot/files/src/host/room-context.js", "patches/ds-bot/files/test/room-context.test.js", "docs/subsystems/core-host.md", "changelog.d/1213-room-presence.changed.md"], "checks": { "ran": "node --test core/test/room-context.test.js && node --test core/test/rounds.test.js && node scripts/ds-bot-patches.mjs check", "result": "green", "at": "6b069bef69d0155237eeb07864c36df0d2a57063" }, "next": "none — ready for review" } ``` <!-- /flybot:checkpoint -->
1 parent 7be5658 commit 1701a67

10 files changed

Lines changed: 447 additions & 31 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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)).

‎core/src/host/groups.js‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { FACE_LEAD_NOTE, FACE_LEAD_ROUTE, faceLeadIsRemote, faceMemberNote, face
3030
import { faceMentions } from './face-mentions.js'
3131
import { cleanName, stripSessionChips, unwrapUntrusted } from './delivery.js'
3232
import { createRoomParticipants } from './room-participant.js'
33-
import { ROOM_CONTEXT_BUDGET, boardCoversCue, fitRoomLines, formatRoomBoard, prepareRoomLine } from './room-context.js'
33+
import { ROOM_CONTEXT_BUDGET, boardCoversCue, fitRoomLines, formatRoomBoard, formatRoomPresence, prepareRoomLine } from './room-context.js'
3434

3535
const ROOM_LOG_MAX = 60
3636
const ROOM_FIRST_LOOK = 12
@@ -367,12 +367,20 @@ export function install(rt) {
367367
const before = index
368368
const after = round.slots.length - index - 1
369369
const working = workingIn(room).filter(item => item.bot.id !== member.id)
370+
// Who is on what, before the board itself (#1213): the board says what is open, this
371+
// says which member holds it, so a working and an idle member read differently without
372+
// anyone posting. Read from the same state, at the moment this turn is built.
373+
const presence = isFaceRoom(room) ? '' : formatRoomPresence(rt.state.tasks, {
374+
roomId: room.id,
375+
members: room.members.map(id => ({ id, name: rt.botOf(id)?.name })),
376+
})
370377
const board = isFaceRoom(room) ? '' : formatRoomBoard(rt.state.tasks, {
371378
roomId: room.id,
372379
nameOf: task => rt.botOf(task.ownerBotId)?.name,
373380
})
374381
return [
375382
noticeChange(room, member),
383+
presence,
376384
board,
377385
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.` : '',
378386
earlier.length ? `Earlier in the group, before this round (context only):\n${show(earlier)}` : '',

‎core/src/host/room-context.js‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// whole. The turn gets a newer-first slice under a character budget, and a long
33
// status essay is collapsed so it cannot fill that slice. Progress for the open
44
// job is a compact board snapshot in the same turn, not those essays.
5-
import { compareForWork, progressOf } from '../shared/task-board.js'
5+
import { columnOf, compareForWork, progressOf } from '../shared/task-board.js'
66

77
// Characters, not a tokenizer. The essays this was measured on are mostly CJK,
88
// 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 } = {}) {
165165
if (hidden > 0) lines.push(`- ${hidden} more open card(s) not shown`)
166166
return lines.join('\n')
167167
}
168+
169+
// Who is on what, read from the board at the moment the turn is built (#1213). Members
170+
// followed the rule — claim the card, update it, say nothing — and the room became
171+
// unreadable: silence meant "idle" only because nothing rendered the board's holders.
172+
// One line per member who holds an open card in THIS room, one line per member who does
173+
// not, so "nobody is working" is readable too. Read-only: no write, no message, no
174+
// heartbeat. Columns, progress and age only — never CI, the queue or a verdict, which
175+
// #1203 keeps off the chat.
176+
export const ROOM_PRESENCE_BUDGET = 400
177+
const PRESENCE_MEMBERS = 8
178+
const OPEN_STATES = new Set(['open', 'claimed', 'working', 'blocked', 'result-ready'])
179+
180+
const columnWord = column => (column === 'in-progress' ? 'in progress' : column === 'not-started' ? 'not started' : column)
181+
const minutesBetween = (from, now) => Math.max(0, Math.round((now - from) / 60000))
182+
183+
// `- Dev2 — #1081 title (in progress 15%, open 27 min)`: the card's own fields, flattened
184+
// to one line. A 0% card says `just claimed` instead of a percentage, and a tag is
185+
// counted from `createdAt` — the fields the record already has, no new one.
186+
function presenceLine(task, name, now, extra = 0) {
187+
const ref = typeof task.target?.number === 'number' ? `#${task.target.number} ` : ''
188+
const title = oneLine(task.title || '(untitled)', 60)
189+
const waiting = task.waitingFor === 'input'
190+
const head = waiting ? 'waiting on an answer' : columnWord(columnOf(task))
191+
const pct = progressOf(task)
192+
const detail = waiting ? '' : pct > 0 ? ` ${pct}%` : ', just claimed'
193+
const created = Number.isFinite(task.createdAt) ? task.createdAt : now
194+
const open = `open ${minutesBetween(created, now)} min`
195+
const moved = Number.isFinite(task.updatedAt) && task.updatedAt - created >= 60000
196+
? `, moved ${minutesBetween(task.updatedAt, now)} min ago`
197+
: ''
198+
const more = extra > 0 ? ` +${extra} more` : ''
199+
return `- ${name} — ${ref}${title} (${head}${detail}, ${open}${moved})${more}`
200+
}
201+
202+
export function formatRoomPresence(tasks, { roomId, members, now = Date.now(), nameOf } = {}) {
203+
if (!roomId) return ''
204+
const list = (members ?? []).filter(member => member && member.id !== undefined)
205+
if (list.length === 0) return ''
206+
const nameOfMember = member => oneLine(typeof nameOf === 'function' ? nameOf(member) : (member.name ?? member.id), 40) || String(member.id)
207+
const cardsOf = new Map()
208+
for (const task of Object.values(tasks ?? {})) {
209+
if (!task || task.origin?.roomId !== roomId || !OPEN_STATES.has(task.status)) continue
210+
if (task.ownerBotId === undefined) continue
211+
const cards = cardsOf.get(task.ownerBotId) ?? []
212+
cards.push(task)
213+
cardsOf.set(task.ownerBotId, cards)
214+
}
215+
const holders = list.filter(member => cardsOf.has(member.id))
216+
if (holders.length === 0) return 'Who is working: nobody (no open card).'
217+
const built = []
218+
for (const member of holders) {
219+
const cards = cardsOf.get(member.id).sort(compareForWork)
220+
built.push(presenceLine(cards[0], nameOfMember(member), now, cards.length - 1))
221+
}
222+
for (const member of list.filter(item => !cardsOf.has(item.id))) built.push(`- ${nameOfMember(member)} — idle (no open card)`)
223+
const header = 'Who is working (from the task board; nobody has to post here):'
224+
const render = count => [header, ...built.slice(0, count), built.length > count ? `- +${built.length - count} more member(s)` : ''].filter(Boolean)
225+
let count = Math.min(PRESENCE_MEMBERS, built.length)
226+
let lines = render(count)
227+
while (count > 1 && lines.join('\n').length > ROOM_PRESENCE_BUDGET) {
228+
count -= 1
229+
lines = render(count)
230+
}
231+
return lines.join('\n')
232+
}

‎core/test/room-context.test.js‎

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from 'node:assert/strict'
22
import test from 'node:test'
3-
import { ROOM_CONTEXT_BUDGET, boardCoversCue, compactStatus, fitRoomLines, formatRoomBoard, prepareRoomLine, statusShaped } from '../src/host/room-context.js'
3+
import { ROOM_CONTEXT_BUDGET, ROOM_PRESENCE_BUDGET, boardCoversCue, compactStatus, fitRoomLines, formatRoomBoard, formatRoomPresence, prepareRoomLine, statusShaped } from '../src/host/room-context.js'
44

55
const render = line => `${line.who}: ${line.text}`
66
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', () => {
103103
assert.equal(boardCoversCue(tasks, { roomId: 'room', botId: 'other', text: '@Other Writer implements' }), false)
104104
assert.equal(boardCoversCue({ only: { ...tasks.waiting } }, { ...cue, text: '@Writer done' }), false)
105105
})
106+
107+
// #1213: the room shows who is on what, read from the board, so a member that claimed
108+
// and stayed quiet is not read as idle. These are the fixtures the issue asks for: two
109+
// holders and one idle member, and a holder whose card is removed.
110+
const presenceAt = Date.parse('2026-10-26T02:00:00Z')
111+
const presenceMembers = [{ id: 'dev2', name: 'Dev2' }, { id: 'dev3', name: 'Dev3' }, { id: 'chief', name: 'Chief' }]
112+
const presenceTasks = () => ({
113+
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 },
114+
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 },
115+
done: { id: 'done', status: 'accepted', title: 'FINISHED_MARKER', ownerBotId: 'dev3', origin: { roomId: 'room' }, updatedAt: presenceAt },
116+
elsewhere: { id: 'elsewhere', status: 'working', progress: 50, title: 'OTHER_ROOM', ownerBotId: 'dev2', origin: { roomId: 'elsewhere' }, updatedAt: presenceAt },
117+
})
118+
119+
test('presence: two holders and an idle member each get one line, with column, progress and age', () => {
120+
const text = formatRoomPresence(presenceTasks(), { roomId: 'room', members: presenceMembers, now: presenceAt })
121+
assert.match(text, /^- Dev2 — #1081 Connectors test race \(in progress 15%, open 27 min\)$/m)
122+
assert.match(text, /^- Dev3 — #979 Face room recursion \(not started, just claimed, open 3 min\)$/m)
123+
assert.match(text, /^- Chief — idle \(no open card\)$/m)
124+
assert.doesNotMatch(text, /FINISHED_MARKER|OTHER_ROOM/)
125+
assert.ok(text.startsWith('Who is working'))
126+
})
127+
128+
test('presence: a card removed turns its holder into idle (the strip follows the board)', () => {
129+
const tasks = presenceTasks()
130+
delete tasks.recursion
131+
const text = formatRoomPresence(tasks, { roomId: 'room', members: presenceMembers, now: presenceAt })
132+
assert.doesNotMatch(text, /Dev3 — #979/)
133+
assert.match(text, /^- Dev3 — idle \(no open card\)$/m)
134+
assert.match(text, /^- Dev2 — #1081/m)
135+
})
136+
137+
test('presence: a waiting card says so, and a moved card adds when it moved', () => {
138+
const tasks = {
139+
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 },
140+
}
141+
const text = formatRoomPresence(tasks, { roomId: 'room', members: [{ id: 'dev2', name: 'Dev2' }], now: presenceAt })
142+
assert.match(text, /- Dev2 — Need a name \(waiting on an answer, open 12 min, moved 2 min ago\)/)
143+
})
144+
145+
test('presence: a holder with three cards names the first and counts the rest', () => {
146+
const tasks = {
147+
one: { id: 'one', status: 'working', progress: 10, title: 'First card', ownerBotId: 'dev2', origin: { roomId: 'room' }, createdAt: presenceAt, updatedAt: presenceAt },
148+
two: { id: 'two', status: 'open', progress: 0, title: 'Second card', ownerBotId: 'dev2', origin: { roomId: 'room' }, createdAt: presenceAt, updatedAt: presenceAt },
149+
three: { id: 'three', status: 'open', progress: 0, title: 'Third card', ownerBotId: 'dev2', origin: { roomId: 'room' }, createdAt: presenceAt, updatedAt: presenceAt },
150+
}
151+
const text = formatRoomPresence(tasks, { roomId: 'room', members: [{ id: 'dev2', name: 'Dev2' }], now: presenceAt })
152+
assert.match(text, /^- Dev2 — First card \(in progress 10%, open 0 min\) \+2 more$/m)
153+
})
154+
155+
test('presence: nobody holding a card is one readable line, never an empty block', () => {
156+
assert.equal(formatRoomPresence({}, { roomId: 'room', members: presenceMembers, now: presenceAt }), 'Who is working: nobody (no open card).')
157+
assert.equal(formatRoomPresence(presenceTasks(), { roomId: 'no-such-room', members: presenceMembers, now: presenceAt }), 'Who is working: nobody (no open card).')
158+
})
159+
160+
test('presence: at most eight members, the rest counted, and the whole block stays under the budget', () => {
161+
const tasks = {}
162+
const members = []
163+
for (let at = 0; at < 12; at += 1) {
164+
const id = `bot${at}`
165+
members.push({ id, name: `Bot${at}` })
166+
tasks[id] = { id, status: 'working', progress: 50, title: `Card ${at}`, ownerBotId: id, origin: { roomId: 'room' }, createdAt: presenceAt, updatedAt: presenceAt }
167+
}
168+
const text = formatRoomPresence(tasks, { roomId: 'room', members, now: presenceAt })
169+
const memberLines = text.split('\n').filter(line => line.startsWith('- ') && line.includes(' — '))
170+
assert.ok(memberLines.length <= 8, `at most eight member lines, got ${memberLines.length}`)
171+
assert.match(text, /^- \+\d+ more member\(s\)$/m)
172+
assert.ok(text.length <= ROOM_PRESENCE_BUDGET, `block is ${text.length} chars`)
173+
})
174+
175+
test('presence: a long title is flattened and cut, and no note prose rides along', () => {
176+
const tasks = {
177+
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 },
178+
}
179+
const text = formatRoomPresence(tasks, { roomId: 'room', members: [{ id: 'dev2', name: 'Dev2' }], now: presenceAt })
180+
assert.doesNotMatch(text, /NOTE_PROSE|<b>/)
181+
assert.ok(text.length <= ROOM_PRESENCE_BUDGET)
182+
})
183+
184+
test('presence: no members or no room renders nothing at all', () => {
185+
assert.equal(formatRoomPresence(presenceTasks(), { roomId: 'room', members: [], now: presenceAt }), '')
186+
assert.equal(formatRoomPresence(presenceTasks(), { members: presenceMembers, now: presenceAt }), '')
187+
})

‎core/test/rounds.test.js‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2035,3 +2035,54 @@ test('a Bot @ whose open card already shows the line passes without a turn (#120
20352035
await personRound
20362036
assert.equal(person.sessionId, live.sessions[writer.id], 'a person @ still gets an answer')
20372037
})
2038+
2039+
2040+
// #1213: the turn reads the board twice — who is working (one line per member, idle ones
2041+
// included) and then the board itself. The owner asked "they are both working, why does the
2042+
// group show nothing"; a claimed card has to be readable before any PR exists.
2043+
test('a group turn shows who is working before the board it renders (#1213)', async (t) => {
2044+
const { call, emit, userMessage, relay, answer, stop, sent, rt } = start()
2045+
t.after(stop)
2046+
const chief = (await call('state', {})).value.mainBotId
2047+
const writer = (await call('create-bot', { name: 'Writer' })).value
2048+
const idle = (await call('create-bot', { name: 'Idle' })).value
2049+
const room = (await call('create-room', { name: 'R1213', members: [chief, writer.id, idle.id] })).value
2050+
await call('update-room', { id: room.id, mode: 'everyone' })
2051+
const now = Date.now()
2052+
rt.state.tasks = {
2053+
card: {
2054+
id: 'card', status: 'working', progress: 15, title: 'Connectors test race', kind: 'task',
2055+
ownerBotId: writer.id, origin: { roomId: room.id }, target: { number: 1081 },
2056+
createdAt: now - 27 * 60000, updatedAt: now - 27 * 60000,
2057+
},
2058+
}
2059+
const playRound = async (label, check, { board = true } = {}) => {
2060+
sent.length = 0
2061+
await emit(room.id, userMessage(label))
2062+
const running = relay(room.id, `r1213-${label}`)
2063+
const seen = []
2064+
for (let at = 0; at < 3; at += 1) {
2065+
const turn = await turnFor(sent, ...seen)
2066+
assert.ok(turn, `${label}: member ${at + 1} gets a turn`)
2067+
seen.push(turn)
2068+
const text = turn.message.content[0].text
2069+
assert.ok(text.includes('Who is working'), `${label}: the prompt carries the presence block`)
2070+
if (board) assert.ok(text.indexOf('Who is working') < text.indexOf('Task board (assign, claim'), `${label}: presence reads before the board`)
2071+
else assert.ok(!text.includes('Task board (assign, claim'), `${label}: no open card, so no board block`)
2072+
assert.doesNotMatch(text, /tests-ok|merge queue|Verdict/, `${label}: the anti-narration rule still holds`)
2073+
check(text)
2074+
await answer(turn.sessionId, turn.message.source.exchangeId, `${label} ${at + 1}.`, at + 1)
2075+
}
2076+
await running
2077+
}
2078+
await playRound('who', (text) => {
2079+
assert.match(text, /Who is working \(from the task board; nobody has to post here\):/)
2080+
assert.match(text, /- Writer — #1081 Connectors test race \(in progress 15%, open 2[67] min\)/)
2081+
assert.match(text, /- Idle — idle \(no open card\)/)
2082+
})
2083+
// The board is the record: removing the card turns "Writer is working" into "nobody".
2084+
delete rt.state.tasks.card
2085+
await playRound('now', (text) => {
2086+
assert.match(text, /Who is working: nobody \(no open card\)\./)
2087+
}, { board: false })
2088+
})

‎docs/subsystems/core-host.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ A group turn does not hand the model the raw ring buffer. `src/host/room-context
106106

107107
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`.
108108

109+
### Who is working (#1213)
110+
111+
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`.
112+
113+
109114
### Group loop guards (#557)
110115

111116
`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`.

0 commit comments

Comments
 (0)