From 759156039912c92d6800e2414bfe7b6ff88777d9 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:36:47 +0800 Subject: [PATCH] fix(groups): an admin's work dispatch is a board assign, not a round (#1254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An admin's `@` was the room's control plane: every `@Dev #1234 rebase and push` opened a group round for that member, so dispatch stayed chat and the board only recorded what chat had already said. work-dispatch.js answers only the shape (one named member, one issue/PR id, an imperative within 40 characters, no question mark); groups.js adds the room's rules and gives that opener no speakers at all, so the member Session is never opened in the room, while every other line keeps the turn it had. tasks.js writes the assign: the open card the member already holds for that issue is updated, otherwise one is opened owned by it and still `open` — the host assigns and the member claims on its own turn. A card the drive does not read (`result-ready`, `blocked`) is restarted through an edge the state machine already has, a card blocked on an input is reused instead of duplicated, and a stored card with no `actors` list starts one before either path appends to it — `record()` pushes without a guard, so the restart would otherwise throw after the note moved and before the save and never wake the member. A card write that answers undefined is no silent drop either: the opener falls back to the ordinary speakers and a mid-round dispatch falls back to `round.calls`. --- changelog.d/1254-work-dispatch.fixed.md | 1 + core/src/host/groups.js | 52 ++- core/src/host/tasks.js | 61 ++++ core/src/host/work-dispatch.js | 50 +++ core/test/work-dispatch.test.js | 328 ++++++++++++++++++ docs/subsystems/core-host.md | 4 + patches/ds-bot/0012-room-membership.patch | 83 +++-- patches/ds-bot/files/src/host/tasks.js | 61 ++++ .../ds-bot/files/src/host/work-dispatch.js | 50 +++ .../ds-bot/files/test/work-dispatch.test.js | 328 ++++++++++++++++++ 10 files changed, 992 insertions(+), 26 deletions(-) create mode 100644 changelog.d/1254-work-dispatch.fixed.md create mode 100644 core/src/host/work-dispatch.js create mode 100644 core/test/work-dispatch.test.js create mode 100644 patches/ds-bot/files/src/host/work-dispatch.js create mode 100644 patches/ds-bot/files/test/work-dispatch.test.js diff --git a/changelog.d/1254-work-dispatch.fixed.md b/changelog.d/1254-work-dispatch.fixed.md new file mode 100644 index 000000000..ee85c1990 --- /dev/null +++ b/changelog.d/1254-work-dispatch.fixed.md @@ -0,0 +1 @@ +In a group with a task board, the admin's `@Name #1234 …` work dispatch is a board assignment now, not a round: the host writes (or updates) that member's card and wakes it in its own chat, and the room opens no group part for the line. A question, a remark, an `@all`, a line from a member who does not divide the board, and a face-to-face room keep their group turn. A card that is parked (`result-ready` or `blocked`) is restarted to `working` so the wake actually reaches the member, a card already blocked on an input is reused instead of duplicated, and a dispatch whose card write fails falls back to the member's group turn instead of dropping the line. ([#1254](https://github.com/ThinkFlowLab/FlyBot/issues/1254)) diff --git a/core/src/host/groups.js b/core/src/host/groups.js index 0ab63a497..c2a3680e4 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -31,6 +31,7 @@ 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, formatRoomPresence, prepareRoomLine } from './room-context.js' +import { workDispatch } from './work-dispatch.js' // #1253: how long an identical claim line keeps a card quiet after it was posted. A claim is a // beacon, not a heartbeat; a re-claim inside this window says nothing new to the room. @@ -811,11 +812,18 @@ export function install(rt) { const references = posts.flatMap(post => post.references ?? []) // The members the opening line names: a Bot's post calls them to work for that Bot, a person's // line addresses them. The report keeps that apart from a plain pass (#913). - const named = mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean), opener.references ?? []).found + const said = mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean), opener.references ?? []) + const named = said.found + // A task lead's @ dispatch is a card, not a turn (#1254): the member is woken in its own chat + // and its place in this round is empty. + const dispatch = dispatchOf(room, opener, named, said.all) + // A card that could not be written is not a reason to leave the member uncalled: the dispatch + // only replaces the turn once the board actually took it (#1254). + const wrote = dispatch ? await assignDispatch(room, dispatch, opener.botId) : undefined // A person's plain line (not @all) in a face-to-face room: the Bots decide who answers (#299). The // owner's own line is never left unanswered; a friend's attested line is answered by their home too. const route = routeFor(room, opener, startSeq) - const speakers = lastFirst(openingSpeakers(room, opener), route) + const speakers = dispatch && wrote !== undefined ? [] : lastFirst(openingSpeakers(room, opener), route) const round = { roomId: room.id, members: room.members, @@ -1018,6 +1026,30 @@ export function install(rt) { return !said.all && said.bots.length === 0 && said.friends.length === 0 } + // #1254: the room's task lead naming one member with an issue id and an imperative is a board + // dispatch, not a round — the host writes that member's card and the drive wakes it in its own + // chat (#1205). A person's line, a member that does not divide the board, a question, a remark, + // `@all`, a face-to-face room (no board), and a line a running round already answered all keep + // the turn they had. + function dispatchOf(room, opener, named, all) { + if (isFaceRoom(room) || all || opener.quiet === true) return undefined + if (opener.botId === undefined || opener.called === true || opener.displayOnly === true) return undefined + if (isEvent(opener)) return undefined + if (typeof rt.taskLeadOf !== 'function' || rt.taskLeadOf(room) !== opener.botId) return undefined + return workDispatch(opener.text ?? '', { callees: named.filter(bot => bot.id !== opener.botId) }) + } + + // The board write one dispatch makes. A failure is the room's problem to log, never the relay's: + // the line is already in the log, and a card that could not be written must not stop the round. + async function assignDispatch(room, dispatch, by) { + try { + return await rt.assignFromDispatch?.({ roomId: room.id, by, ...dispatch }) + } catch (error) { + rt.warn('ds-bot: the dispatch in %s was not written to the board: %s', room.name, error) + return undefined + } + } + function openingSpeakers(room, opener) { // A line already in the log, posted so the pane can draw it. `@` in it is not a new call. if (opener.displayOnly === true) return [] @@ -1122,10 +1154,20 @@ export function install(rt) { round.replies.push({ botId: sender.id, name: sender.name, color: rt.botOf(sender.id)?.color, text, time: Date.now(), ...(posted?.seq === undefined ? {} : { seq: posted.seq }) }) // The members this line names are called by the round that is running (#932): drawing the bubble alone // left them uncalled until the held post opened a round after the report. `called` keeps the re-delivery - // of the held post from calling them a second time. - const callees = mentionsIn(text, room.members.map(rt.botOf).filter(Boolean)).found.filter(bot => bot.id !== sender.id) + // of the held post from calling them a second time. A task lead's dispatch is the board's instead + // (#1254): it is written here so the round does not call the member it assigned. + const everyone = room.members.map(rt.botOf).filter(Boolean) + const said = mentionsIn(text, everyone) + const callees = said.found.filter(bot => bot.id !== sender.id) if (callees.length > 0) { - round.calls = [...(round.calls ?? []), { by: sender.id, name: sender.name, callees, text }] + const dispatch = dispatchOf(room, { botId: sender.id, text }, callees, said.all) + if (dispatch) { + // The round's own call is the fallback, taken only when the board refused the write: a failed + // card must never be a member left uncalled (#1254). + void assignDispatch(room, dispatch, sender.id).then(wrote => { + if (wrote === undefined) round.calls = [...(round.calls ?? []), { by: sender.id, name: sender.name, callees, text }] + }) + } else round.calls = [...(round.calls ?? []), { by: sender.id, name: sender.name, callees, text }] extra.called = true } return true diff --git a/core/src/host/tasks.js b/core/src/host/tasks.js index 6c61c00ab..32fa5ee0f 100644 --- a/core/src/host/tasks.js +++ b/core/src/host/tasks.js @@ -18,6 +18,7 @@ import { randomBytes } from 'node:crypto' import { columnOf, compareForQueue, compareForWork, sameRole } from '../shared/task-board.js' import { currentParticipants, deriveParticipants } from './room-membership.js' +import { namesIssue } from './work-dispatch.js' const ACTIVE = new Set(['open', 'claimed', 'working', 'blocked']) // result-ready still waits for the requester: prune must not evict it with terminals (#111). @@ -499,6 +500,66 @@ export function install(rt) { } } + // #1254: the room's task lead naming one member with an issue id and an imperative is an assign, + // not a round (`groups.js` `dispatchOf`). The card that member already holds for that issue is + // updated; otherwise one is opened, owned by that member and still `open` — the host assigns, and + // the member claims on its own turn, where #1253's claim line is posted in its own voice. The save + // drives the wake into that member's own chat (#1205), so the room opens no part. + rt.assignFromDispatch = async function assignFromDispatch({ roomId, botId, issue, title, note, by } = {}) { + const room = state.rooms?.[String(roomId ?? '')] + if (!room || room.origin === 'face-to-face') return undefined + const bot = botRecord(botId) + if (!bot || bot.remote !== undefined) return undefined + if (!(room.members ?? []).includes(bot.id)) return undefined + const text = oneLine(note ?? title, 300) + if (text === '') return undefined + const at = stamp() + const open = Object.values(store()).filter(task => + sameRoom(task, room.id) && task.ownerBotId === bot.id && task.kind !== 'group' + && openTask(task)) + const held = issue === undefined || issue === null || String(issue).trim() === '' + ? undefined + : open.filter(task => namesIssue(`${task.title ?? ''} ${task.note ?? ''} ${task.target?.href ?? ''}`, issue)).sort(newestFirst)[0] + if (held) { + held.note = text + // Both paths below append to `actors`, and `record()` pushes without a guard, so a card + // stored before the field existed would throw after the note moved and before the save + // (#1254). + held.actors ??= [] + // A card the drive does not read is restarted through an edge the state machine already has + // (`result-ready → working`, `blocked → working`): rewriting the note alone saved a card that + // `task-drive` never wakes for, so the member never saw the dispatch. A blocked card is reused + // for the same reason one issue is one card — the dispatch answers the wait, it never adds a + // second card beside it (#1254). + if (held.status === 'result-ready' || held.status === 'blocked') { + transition(held, 'working', null, { by: 'dispatch', ...(by ? { botId: by } : {}) }) + } else { + held.updatedAt = at + // A stored card may predate `actors`: the append starts the list instead of throwing after the + // note already moved, which `groups.js` swallowed and left a half-written card (#1254). + held.actors = [...(held.actors ?? []), { status: held.status, at, by: 'dispatch', ...(by ? { botId: by } : {}) }] + } + await save() + return view(held) + } + const task = { + id: `task-${randomBytes(8).toString('hex')}`, + status: 'open', + createdAt: at, + updatedAt: at, + requesterId: bot.ownerId ?? null, + ownerBotId: bot.id, + title: oneLine(title ?? text, 300), + note: text, + source: 'conversation', + origin: { roomId: room.id }, + actors: [{ status: 'open', at, by: 'dispatch', ...(by ? { botId: by } : {}) }], + } + store()[task.id] = task + await save() + return view(task) + } + // A member claims from the room's queue (#1247). The claim is a board write: it sets the // owner and moves the card to claimed, so the delivery view and the room board read the same // card with no second row. diff --git a/core/src/host/work-dispatch.js b/core/src/host/work-dispatch.js new file mode 100644 index 000000000..636fcbf54 --- /dev/null +++ b/core/src/host/work-dispatch.js @@ -0,0 +1,50 @@ +// #1254: an admin's @ in a group is work, not a discussion. When the room's task lead names one +// member with an issue/PR id and an imperative ("@Dev1 #1234 rebase and push"), the host writes +// that member's card and the drive wakes it in its own chat (#1205); no group part opens. A +// question, a remark, a line naming two members or an `@all` keeps the group turn it had — this +// module only answers the shape of one line, the room decides who may dispatch. +const ISSUE = /#(\d{1,7})(?![0-9])|github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/(\d{1,7})(?![0-9])/gi +const MENTION = /@[^\s@,,。]{1,40}/g +const QUESTION = /[??]/ +// An imperative counts only near the id: "rebase #1234" and "#1234 rebase and push" both dispatch, +// "#1234 looks good to me" stays a remark. The window is characters, not words, so it covers CJK. +const NEAR = 40 +const IMPERATIVE = /(?:\b(?:rebase|push|fix|implement|land|merge|open|close|claim|take|add|update|remove|delete|refactor|write|read|investigate|review|run|check|test|debug|split|amend|drive|finish|post|prepare|draft|design|build|move|bump|revert|rename|wire|port|handle|address|verify|reproduce|triage|document|patch|publish|release|deploy|archive|start|continue|make|set|teach|follow)\b|修复|修改|修正|实现|合并|推送|提交|更新|处理|完成|补充|去掉|删掉|删除|检查|验证|复现|整理|重构|拆分|设计|编写|写|改|修|做|跑|查|推|补|删)/i +const oneLine = value => String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300) + +// "@Dev1" is a call, not part of the instruction: the card says what to do, and the room already +// knows who was named. +const withoutMentions = text => String(text ?? '').replace(MENTION, ' ').replace(/\s+/g, ' ').trim() + +// The issue/PR numbers a line names, normalised to `#1234`, in the order they appear. +export function issueRefs(text) { + const keys = [] + for (const match of String(text ?? '').matchAll(new RegExp(ISSUE.source, 'gi'))) { + const key = `#${match[1] ?? match[2]}` + if (!keys.includes(key)) keys.push(key) + } + return keys +} + +export function namesIssue(text, issue) { + const number = String(issue ?? '').replace(/^#/, '') + if (!/^\d+$/.test(number)) return false + return issueRefs(text).includes(`#${number}`) +} + +// One member, one issue id, one imperative near it. Anything else is not this module's to convert. +export function workDispatch(text, { callees = [] } = {}) { + if (!Array.isArray(callees) || callees.length !== 1) return undefined + const callee = callees[0] + if (!callee?.id) return undefined + const line = withoutMentions(text) + if (line === '' || QUESTION.test(line)) return undefined + for (const match of line.matchAll(new RegExp(ISSUE.source, 'gi'))) { + const near = line.slice(Math.max(0, match.index - NEAR), match.index + match[0].length + NEAR) + if (!IMPERATIVE.test(near)) continue + const issue = `#${match[1] ?? match[2]}` + const note = oneLine(line) + return { botId: callee.id, issue, title: note, note } + } + return undefined +} diff --git a/core/test/work-dispatch.test.js b/core/test/work-dispatch.test.js new file mode 100644 index 000000000..b202e43e7 --- /dev/null +++ b/core/test/work-dispatch.test.js @@ -0,0 +1,328 @@ +// #1254: an admin's @ in a group is work, not a discussion. When the room's task lead names one +// member with an issue/PR id and an imperative ("@Dev1 #1234 rebase and push"), the line is a +// dispatch: the host writes the member's card and the drive wakes it in its own chat (#1205), and +// the member gets no group part. A question, a plain @, a line from a member who does not divide +// the board, and a face-to-face room (which has no board) all keep the group turn they had. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { workDispatch } from '../src/host/work-dispatch.js' +import { fakeContext } from './fake-ctx.js' + +const team = (room = {}) => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } }, + bots: { + a: { id: 'a', name: 'Chief', role: 'Lead', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, + b: { id: 'b', name: 'Dev1', role: 'Dev', sessionId: 'b', createdAt: 2, ownerId: 'owner-1', createdBy: 'a' }, + c: { id: 'c', name: 'Tester', role: 'Test', sessionId: 'c', createdAt: 3, ownerId: 'owner-1', createdBy: 'a' }, + }, + rooms: { + war: { + id: 'war', name: 'War', members: ['a', 'b', 'c'], admin: 'a', mode: 'admin', named: true, + sessions: {}, log: [], seq: 0, seen: {}, + participants: [ + { kind: 'human', id: 'owner-1', role: 'member', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'a', role: 'admin', displayName: 'Chief', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'b', role: 'member', displayName: 'Dev1', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'c', role: 'member', displayName: 'Tester', joinedSeq: 0, joinedAt: 1 }, + ], + ...room, + }, + }, + grants: {}, + agents: {}, + exchanges: [], + exchangeTurns: {}, + questions: {}, + prefs: {}, +}) + +async function start(t, overrides = {}) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-dispatch-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(team(overrides))) + const ctx = fakeContext() + const agents = new Map() + const idleWaiters = [] + const idle = (sessionId) => { + if (sessionId !== undefined) agentOf(sessionId).status = 'idle' + for (const resolve of idleWaiters.splice(0)) resolve() + } + const agentOf = (sessionId) => { + if (!agents.has(sessionId)) { + const calls = [] + const inbox = { + nextStep: [], + nextTurn: [], + remove(id) { + const keep = message => message.id !== id + const before = this.nextTurn.length + this.nextStep.length + this.nextTurn = this.nextTurn.filter(keep) + this.nextStep = this.nextStep.filter(keep) + return this.nextTurn.length + this.nextStep.length !== before + }, + } + agents.set(sessionId, { + session: { id: sessionId }, id: sessionId, calls, status: 'idle', inbox, + followup(message) { calls.push(message); this.inbox.nextTurn.push(message) }, + steer(message) { calls.push(message) }, + inject() {}, + cancel() {}, + whenIdle() { return new Promise(resolve => idleWaiters.push(resolve)) }, + }) + } + return agents.get(sessionId) + } + let created = 0 + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { + create: async () => ({ sessionId: `made-${++created}` }), + resolveAgent: async id => ({ agent: agentOf(id) }), + selectModel: async () => {}, + page: async () => ({ records: [], hasMore: false }), + }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home }) + await rt.load() + t.after(async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + }) + // A Bot's post into the room, as the relay queues it: the room's Session has already taken it. + const post = (text, from = 'a') => ({ text, botId: from, name: rt.botOf(from)?.name ?? from }) + let rounds = 0 + // One group_relay run over the queued lines. A dispatch run ends by itself; a round that calls + // a member is stopped by the test once its assertions are done. + const relay = (lines) => { + rt.roomInbox.set('war', lines) + const stop = new AbortController() + t.after(() => stop.abort()) + let value + const done = ctx.registeredTools.get('group_relay') + .execute({}, { agent: { id: 'war' }, callId: `relay-${++rounds}`, signal: stop.signal }) + .then(body => { value = JSON.parse(body) }, () => {}) + return { done, value: () => value, stop } + } + const room = () => rt.state.rooms.war + const cards = () => Object.values(rt.state.tasks ?? {}).filter(task => task.origin?.roomId === 'war') + const groupParts = botId => (room().sessions?.[botId] === undefined ? 0 : agentOf(room().sessions[botId]).calls.length) + const wakeOf = botId => agentOf(botId).calls.find(message => message.source?.role === 'task') + return { ctx, rt, agentOf, room, cards, groupParts, wakeOf, relay, post, idle } +} + +const settle = async (check) => { + const until = Date.now() + 3000 + while (!check() && Date.now() < until) await new Promise(resolve => setImmediate(resolve)) + assert.ok(check(), 'condition not reached') +} + +test('the dispatch shape is an issue id and an imperative, never any @ (#1254)', () => { + const one = [{ id: 'a' }, { id: 'b' }] + assert.deepEqual(workDispatch('@Dev1 #1234 rebase and push', { callees: [one[0]] }), { + botId: 'a', issue: '#1234', title: '#1234 rebase and push', note: '#1234 rebase and push', + }) + assert.ok(workDispatch('@Dev1 please fix #1234', { callees: [one[0]] }), 'the verb may come first') + assert.ok(workDispatch('@Dev1 https://github.com/o/r/pull/1234 land it', { callees: [one[0]] }), 'a pull link is an id too') + assert.ok(workDispatch('@Dev1 请按 #1254 修一下 dispatch', { callees: [one[0]] }), 'Chinese imperatives count') + assert.equal(workDispatch('@Dev1 #1234 looks good to me', { callees: [one[0]] }), undefined, 'a remark is not a dispatch') + assert.equal(workDispatch('@Dev1 #1234 which file?', { callees: [one[0]] }), undefined, 'a question stays a question') + assert.equal(workDispatch('@Dev1 #1234 fix this?', { callees: [one[0]] }), undefined, 'the question mark wins over the imperative near the id') + assert.equal(workDispatch('@Dev1 rebase and push', { callees: [one[0]] }), undefined, 'no issue id, no card') + assert.equal(workDispatch('@Dev1 #1234 rebase', { callees: one }), undefined, 'two members are not one assignee') +}) + +test('an admin @ on an issue writes the card and wakes the member instead of opening a round (#1254)', async (t) => { + const app = await start(t) + const run = app.relay([app.post('@Dev1 #1234 rebase and push')]) + await run.done + assert.deepEqual(app.room().sessions, {}, 'no member Session was opened in the room') + assert.equal(app.rt.roomProgress.size, 0) + const card = app.cards().find(task => task.ownerBotId === 'b') + assert.ok(card, 'the dispatch wrote a card for Dev1') + assert.equal(card.title, '#1234 rebase and push') + assert.equal(card.note, '#1234 rebase and push') + assert.equal(card.status, 'open', 'the host assigns; the member claims on its own turn') + assert.equal(card.source, 'conversation') + const wake = app.wakeOf('b') + assert.ok(wake, 'Dev1 is woken in its own chat') + assert.match(wake.content.map(block => block.text ?? '').join('\n'), new RegExp(`Continue ${card.id} \\[normal\\] #1234 rebase and push`)) + assert.deepEqual(run.value(), { replies: [], passed: [] }, 'no group part, no pass either') +}) + +test('a dispatch on a card that exists updates that card instead of writing a second one (#1254)', async (t) => { + const app = await start(t) + await app.relay([app.post('@Dev1 #1234 rebase onto main')]).done + const [first] = app.cards() + assert.ok(first) + const { id, note, updatedAt } = first + await app.relay([app.post('@Dev1 #1234 then push the branch')]).done + assert.equal(app.cards().length, 1, 'one card for one issue') + const [again] = app.cards() + assert.equal(again.id, id) + assert.equal(again.note, '#1234 then push the branch') + assert.notEqual(again.note, note) + assert.ok(again.updatedAt > updatedAt, 'the update moves the card, so the drive wakes it') +}) + +test('a question to a member is still its group turn (#1254)', async (t) => { + const app = await start(t) + const run = app.relay([app.post('@Dev1 #1234 which file should I change?')]) + await settle(() => app.room().sessions.b !== undefined) + assert.equal(app.cards().length, 0, 'a question writes no card') + run.stop.abort() + await run.done +}) + +test('a human naming the admin still gets the admin\'s group turn (#1254)', async (t) => { + const app = await start(t) + const run = app.relay([{ text: '@Chief 现在 token 是哪个?', authorId: 'owner-1', authorKind: 'human', authorName: 'Alice' }]) + await settle(() => app.room().sessions.a !== undefined) + assert.equal(app.cards().length, 0, 'a person\'s line is not a board write') + run.stop.abort() + await run.done +}) + +test('a plain @ about an issue is not a dispatch (#1254)', async (t) => { + const app = await start(t) + const run = app.relay([app.post('@Dev1 #1234 looks good to me')]) + await settle(() => app.room().sessions.b !== undefined) + assert.equal(app.cards().length, 0, 'a remark keeps the group turn it had') + run.stop.abort() + await run.done +}) + +test('a member who does not divide the board keeps the round (#1254)', async (t) => { + const app = await start(t) + const run = app.relay([app.post('@Dev1 #1234 rebase and push', 'c')]) + await settle(() => app.room().sessions.b !== undefined) + assert.equal(app.cards().length, 0, 'only the task lead dispatches to the board') + run.stop.abort() + await run.done +}) + +test('a face-to-face room has no board, so a dispatch stays a turn (#1254)', async (t) => { + const app = await start(t, { origin: 'face-to-face' }) + const run = app.relay([app.post('@Dev1 #1234 rebase and push')]) + await settle(() => app.room().sessions.b !== undefined) + assert.equal(app.cards().length, 0) + run.stop.abort() + await run.done +}) + +test('the same dispatch inside a running round is a board assign, not a second call (#1254)', async (t) => { + const app = await start(t) + const run = app.relay([{ text: '@Dev1 say hello', authorId: 'owner-1', authorKind: 'human', authorName: 'Alice' }]) + await settle(() => app.room().sessions.b !== undefined) + app.agentOf('war').status = 'running' + await app.rt.postToRoom(app.room(), { id: 'a', name: 'Chief' }, '@Tester #1234 rebase and push', { hop: 0, origin: 'owner' }) + await settle(() => app.cards().some(task => task.ownerBotId === 'c')) + assert.equal(app.room().sessions.c, undefined, 'Tester was not called inside the round') + assert.equal(app.cards().find(task => task.ownerBotId === 'c').title, '#1234 rebase and push') + await settle(() => app.wakeOf('c') !== undefined) + app.idle('war') + run.stop.abort() + await run.done +}) + +// A card the member already holds for the issue, parked where the drive does not read it: the +// dispatch has to restart it through the one edge that exists, or the note moves and nobody wakes. +const parked = (status, extra = {}) => { + const at = Date.now() - 1000 + return { + id: `task-${status}`, status, createdAt: at, updatedAt: at, + requesterId: 'owner-1', ownerBotId: 'b', title: '#1234 rebase and push', note: 'the first pass', + source: 'conversation', origin: { roomId: 'war' }, actors: [{ status: 'open', at, by: 'dispatch' }], + ...extra, + } +} + +test('a dispatch restarts a card that is result-ready, so the drive wakes the member (#1254)', async (t) => { + const app = await start(t) + app.rt.state.tasks['task-result-ready'] = parked('result-ready') + await app.relay([app.post('@Dev1 #1234 then push the branch')]).done + assert.equal(app.cards().length, 1, 'the parked card is reused, not duplicated') + const card = app.cards()[0] + assert.equal(card.status, 'working', 'result-ready → working is the edge the state machine already has') + assert.equal(card.note, '#1234 then push the branch') + assert.equal(card.actors.at(-1).by, 'dispatch') + assert.ok(app.wakeOf('b'), 'working is driving, so the member is woken in its own chat') +}) + +test('a dispatch reuses a card blocked on an input and restarts it (#1254)', async (t) => { + const app = await start(t) + app.rt.state.tasks['task-blocked'] = parked('blocked', { waitingFor: 'input', inputRequest: { id: 'q1', question: 'which file?' } }) + await app.relay([app.post('@Dev1 #1234 rebase onto main')]).done + assert.equal(app.cards().length, 1, 'one issue is one card, even while the old one waits on a person') + const card = app.cards()[0] + assert.equal(card.status, 'working') + assert.equal(card.waitingFor, undefined, 'the dispatch answers the wait, so the card drives again') + assert.ok(app.wakeOf('b')) +}) + +test('a stored card with no actors is updated, never half-written (#1254)', async (t) => { + const app = await start(t) + const { actors, ...bare } = parked('working') + app.rt.state.tasks['task-bare'] = { ...bare, status: 'claimed' } + await app.relay([app.post('@Dev1 #1234 finish the branch')]).done + const card = app.cards()[0] + assert.equal(card.note, '#1234 finish the branch') + assert.ok(Array.isArray(card.actors), 'the append starts a list instead of throwing after the note moved') + assert.deepEqual(card.actors.map(actor => actor.by), ['dispatch']) + assert.ok(app.wakeOf('b'), 'the save ran, so the drive woke the member') +}) + +test('a dispatch restarts a result-ready card that has no actors, without throwing (#1254)', async (t) => { + const app = await start(t) + // A card stored before `actors` existed. The restart goes through `transition()` → `record()`, + // which pushes onto `task.actors` with no guard, so a bare field throws after the note moved and + // before the save: the member is never woken, and the card is left half-written. + const { actors, ...bare } = parked('result-ready') + app.rt.state.tasks['task-result-ready-bare'] = bare + const card = await app.rt.assignFromDispatch({ + roomId: 'war', botId: 'b', issue: '#1234', note: '#1234 finish the branch', by: 'a', + }) + assert.ok(card, 'the card write went through') + assert.equal(card.status, 'working', 'the restart edge still applies to a card with no history') + assert.ok(Array.isArray(card.actors), 'the restart starts the list instead of throwing into it') + assert.equal(card.actors.length, 1, 'one entry for the restart, none invented') + assert.equal(card.actors[0].by, 'dispatch') + assert.equal(card.note, '#1234 finish the branch') + assert.ok(app.wakeOf('b'), 'the save ran, so the drive woke the member') +}) + +test('a dispatch whose card write fails still calls the member inside the round (#1254)', async (t) => { + const app = await start(t) + const run = app.relay([{ text: '@Dev1 say hello', authorId: 'owner-1', authorKind: 'human', authorName: 'Alice' }]) + await settle(() => app.room().sessions.b !== undefined) + app.agentOf('war').status = 'running' + const live = () => [...app.rt.roomProgress.values()].find(round => round.roomId === 'war') + const pending = () => (live()?.calls ?? []).filter(call => call.callees.some(bot => bot.id === 'c')) + // The board refuses the write: the round's own call is the fallback, or the member is never told. + app.rt.assignFromDispatch = async () => undefined + await app.rt.postToRoom(app.room(), { id: 'a', name: 'Chief' }, '@Tester #1234 rebase and push', { hop: 0, origin: 'owner' }) + await settle(() => pending().length === 1) + assert.equal(app.cards().length, 0, 'the write failed, so there is no card to trust') + assert.equal(pending()[0].by, 'a') + app.idle('war') + run.stop.abort() + await run.done +}) + +test('a dispatch whose card write fails still opens the member\'s turn (#1254)', async (t) => { + const app = await start(t) + app.rt.assignFromDispatch = async () => undefined + const run = app.relay([app.post('@Dev1 #1234 rebase and push')]) + await settle(() => app.room().sessions.b !== undefined) + assert.equal(app.cards().length, 0) + run.stop.abort() + await run.done +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 3ab415417..7f80cd011 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -188,6 +188,10 @@ When a Bot posts with `report_back`, `reportRound` sends it the round's closing A member's own line calls the members it names in two shapes where the call used to be lost. While a round runs, `post_to_group` used to be shown in the round and re-delivered to the Session after it, so the names in it were called only by whatever round that re-delivery opened — that is, after the round's report had already listed them as silent. `showInRound` now reads the line's `@` names (the members of the room, never the sender) and keeps them as `round.calls`; `runRound` drains them at its loop head (`drainMidCalls`) with the same `ROOM_TURNS_EACH` / `ROOM_ADMIN_TURNS_EACH` bound and the same round limit as a call made from a reply, so the call lands inside the running round and never bypasses the cap. The held post carries `called: true` (queued by `turns.js` into `roomInbox`) and `openingSpeakers` opens no round for it, so a named member is called exactly once. Second shape: a late line past `ROOM_LATE_ROUNDS` is quiet, and `openingSpeakers` read `quiet` before its `@` — an explicit name was swallowed and the admin answered a line that named someone else. The mentions are now resolved first: `quiet` caps only the spread of a line nobody was named in (it returns nobody then), while a name written in the line is called. An event post (a member's failure, stop or resume notice) still calls nobody, and a name outside the room or the sender itself is not a call. Covered by `core/test/rounds.test.js`. +### A dispatch is a board assign (#1254) + +An admin's `@` used to be the room's control plane: every `@Dev #1234 rebase and push` opened a group round for that member, so dispatch stayed chat and the board only recorded what chat had already said. Now the line is the assignment. `work-dispatch.js` `workDispatch(text, { callees })` answers only the shape — exactly one named member, an issue/PR id (`#1234` or a `github.com///(issues|pull)/` link), an imperative within 40 characters of that id (English verbs, or Chinese ones such as 修 / 改 / 推 / 补), and no `?` — and `groups.js` `dispatchOf` adds the room's rules: a Bot's own post, no `@all`, not quiet or an event, a room with a board, and the poster is `rt.taskLeadOf(room)` (the picked lead, else the admin). `rt.assignFromDispatch` in `tasks.js` then updates the open card that member already holds for that issue (matched on title, note or `target`) or opens one owned by it, still `open` — the host assigns, and the member claims on its own turn, where #1253's claim line is posted in its own voice. The save runs the drive, so the member is woken in its own chat (#1205). A card that is not driving — `result-ready` or `blocked` — is restarted through an edge the state machine already has (`result-ready → working`, `blocked → working`), because `task-drive` wakes only `open`/`claimed`/`working` and rewriting the note alone would leave the member unwoken; a card blocked on an input is reused rather than duplicated, so one issue stays one card, and a stored card with no `actors` list has one started instead of throwing after the note moved. The relay's `speakers` is empty for that opener and the round records no part or pass; the same dispatch posted while a round runs is written from `showInRound` and adds no `round.calls`, so the member is not called twice — but a card write that answers `undefined` is never a silent drop: the opener's `speakers` fall back to the ordinary ones and a mid-round dispatch falls back to `round.calls`, so the member is called in the round instead of not at all. A question, a remark with no imperative, two named members, `@all`, a poster that does not divide the board, and a face-to-face room (no board) keep the group turn they had. The line is still in the room log, and the card shows on the board. Tests: `core/test/work-dispatch.test.js`. + ### Team state and access - The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. Which statuses still wait for that decision is one rule in `src/shared/approval-waiting.js` (#754): `decide()`, `heldOf()` in the chat view, the in-flight filter that invalidates holds, `PENDING_MAX` and the `APPROVALS_KEPT` cleanup all ask `isApprovalWaiting()`, and `channel.js` counts a row's waiting replies with it, so a `pending_approval` hold (Bot Social's own spelling) is decidable, drawn as waiting and never dropped mid-wait. diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 1fd3acba5..475df6cbc 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -761,7 +761,7 @@ diff --git a/src/client/sources.js b/src/client/sources.js diff --git a/src/host/groups.js b/src/host/groups.js --- a/src/host/groups.js +++ b/src/host/groups.js -@@ -7,21 +7,50 @@ +@@ -7,21 +7,51 @@ // the speaking order and the member's place in it. Nobody answers a room that has // moved on, and nobody has to count lines to know who went before. A plain message // goes to every member in roster order, admin first, or to the admin alone in admin @@ -795,6 +795,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js +import { cleanName, stripSessionChips, unwrapUntrusted } from './delivery.js' +import { createRoomParticipants } from './room-participant.js' +import { ROOM_CONTEXT_BUDGET, boardCoversCue, fitRoomLines, formatRoomBoard, formatRoomPresence, prepareRoomLine } from './room-context.js' ++import { workDispatch } from './work-dispatch.js' + +// #1253: how long an identical claim line keeps a card quiet after it was posted. A claim is a +// beacon, not a heartbeat; a re-claim inside this window says nothing new to the room. @@ -1288,7 +1289,7 @@ 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) -@@ -285,34 +285,195 @@ export function install(rt) { +@@ -285,23 +285,176 @@ export function install(rt) { // 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. function drainRoomInbox(room, round, turns, index, agent) { @@ -1334,11 +1335,11 @@ diff --git a/src/host/groups.js b/src/host/groups.js - // The user spoke in this room, so Bots may start their own rounds again. - room.botRounds = 0 + // The user spoke in this room, so late replies may be answered again. - room.lateRounds = 0 - void rt.save().catch(() => {}) - return round.slots.length - before - } - ++ room.lateRounds = 0 ++ void rt.save().catch(() => {}) ++ return round.slots.length - before ++ } ++ + // One line that reaches the round while a member speaks (#1267). It is logged at once — the room + // shows it when it is sent, not at the next slot — and `notePostedLine` keeps the next relay from + // logging it a second time. Its pending copy leaves the relay's queue, because this round answers @@ -1351,8 +1352,8 @@ diff --git a/src/host/groups.js b/src/host/groups.js + if (at !== -1) rt.roomInbox.set(room.id, queued.toSpliced(at, 1)) + round.replies.push({ kind: 'user', text: line.text, time: Date.now(), authorId: line.authorId, authorKind: line.authorKind, name: line.authorName }) + // The user spoke in this room, so late replies may be answered again. -+ room.lateRounds = 0 -+ void rt.save().catch(() => {}) + room.lateRounds = 0 + void rt.save().catch(() => {}) + // The one line that ends work in flight (#1267 B): the slot is superseded, its turn is cancelled, + // and nothing it says when it stops is posted. The line stays in the Session's queue, so the head + // drain opens it as a fresh line (admin first) for the room to answer. @@ -1469,11 +1470,10 @@ diff --git a/src/host/groups.js b/src/host/groups.js + const uncapped = new Set(callees.filter(bot => rt.isAdminOf(room, bot.id)).map(bot => bot.id)) + callMembers(round, turns, index - 1, callees, `${call.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, call.by, uncapped, call.text) + } -+ return round.slots.length - before -+ } -+ - async function runRound(room, round, signal, agent) { - let limit = round.slots.length + ROOM_EXTRA_TURNS + (room.admin ? ROOM_ADMIN_EXTRA_TURNS : 0) + return round.slots.length - before + } + +@@ -310,9 +310,17 @@ export function install(rt) { const turns = new Map() // The inbox check keeps a message sent during the last speaker's turn inside // this round: it drains at the loop's head even once the slots ran out. @@ -1572,7 +1572,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } } -@@ -397,38 +397,45 @@ export function install(rt) { +@@ -397,38 +397,52 @@ export function install(rt) { rt.roomInbox.delete(room.id) const posts = queued.length > 0 ? queued : [{ text: '' }] const startSeq = room.seq + 1 @@ -1611,11 +1611,18 @@ diff --git a/src/host/groups.js b/src/host/groups.js - const named = opener.botId === undefined ? [] : mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean)).found + // The members the opening line names: a Bot's post calls them to work for that Bot, a person's + // line addresses them. The report keeps that apart from a plain pass (#913). -+ const named = mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean), opener.references ?? []).found ++ const said = mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean), opener.references ?? []) ++ const named = said.found ++ // A task lead's @ dispatch is a card, not a turn (#1254): the member is woken in its own chat ++ // and its place in this round is empty. ++ const dispatch = dispatchOf(room, opener, named, said.all) ++ // A card that could not be written is not a reason to leave the member uncalled: the dispatch ++ // only replaces the turn once the board actually took it (#1254). ++ const wrote = dispatch ? await assignDispatch(room, dispatch, opener.botId) : undefined + // A person's plain line (not @all) in a face-to-face room: the Bots decide who answers (#299). The + // owner's own line is never left unanswered; a friend's attested line is answered by their home too. + const route = routeFor(room, opener, startSeq) -+ const speakers = lastFirst(openingSpeakers(room, opener), route) ++ const speakers = dispatch && wrote !== undefined ? [] : lastFirst(openingSpeakers(room, opener), route) const round = { roomId: room.id, members: room.members, @@ -1726,7 +1733,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js return lines.length === 0 ? `"${room.name}" has no messages yet.` : `Latest in "${room.name}" (oldest first):\n${transcript(lines)}` }, })) -@@ -548,46 +548,230 @@ export function install(rt) { +@@ -548,46 +548,264 @@ export function install(rt) { // the admin answers alone in admin mode, or first of everyone. What a member posts // after its round moved on is answered by the member that asked for it; work the // user asked for is left for the user to read. @@ -1751,6 +1758,30 @@ diff --git a/src/host/groups.js b/src/host/groups.js + const said = faceMentions(room, opener.text, rt.botOf, { references: (opener.references ?? []).flatMap(reference => reference.labels) }) + return !said.all && said.bots.length === 0 && said.friends.length === 0 + } ++ ++ // #1254: the room's task lead naming one member with an issue id and an imperative is a board ++ // dispatch, not a round — the host writes that member's card and the drive wakes it in its own ++ // chat (#1205). A person's line, a member that does not divide the board, a question, a remark, ++ // `@all`, a face-to-face room (no board), and a line a running round already answered all keep ++ // the turn they had. ++ function dispatchOf(room, opener, named, all) { ++ if (isFaceRoom(room) || all || opener.quiet === true) return undefined ++ if (opener.botId === undefined || opener.called === true || opener.displayOnly === true) return undefined ++ if (isEvent(opener)) return undefined ++ if (typeof rt.taskLeadOf !== 'function' || rt.taskLeadOf(room) !== opener.botId) return undefined ++ return workDispatch(opener.text ?? '', { callees: named.filter(bot => bot.id !== opener.botId) }) ++ } ++ ++ // The board write one dispatch makes. A failure is the room's problem to log, never the relay's: ++ // the line is already in the log, and a card that could not be written must not stop the round. ++ async function assignDispatch(room, dispatch, by) { ++ try { ++ return await rt.assignFromDispatch?.({ roomId: room.id, by, ...dispatch }) ++ } catch (error) { ++ rt.warn('ds-bot: the dispatch in %s was not written to the board: %s', room.name, error) ++ return undefined ++ } ++ } + function openingSpeakers(room, opener) { + // A line already in the log, posted so the pane can draw it. `@` in it is not a new call. @@ -1858,10 +1889,20 @@ diff --git a/src/host/groups.js b/src/host/groups.js + round.replies.push({ botId: sender.id, name: sender.name, color: rt.botOf(sender.id)?.color, text, time: Date.now(), ...(posted?.seq === undefined ? {} : { seq: posted.seq }) }) + // The members this line names are called by the round that is running (#932): drawing the bubble alone + // left them uncalled until the held post opened a round after the report. `called` keeps the re-delivery -+ // of the held post from calling them a second time. -+ const callees = mentionsIn(text, room.members.map(rt.botOf).filter(Boolean)).found.filter(bot => bot.id !== sender.id) ++ // of the held post from calling them a second time. A task lead's dispatch is the board's instead ++ // (#1254): it is written here so the round does not call the member it assigned. ++ const everyone = room.members.map(rt.botOf).filter(Boolean) ++ const said = mentionsIn(text, everyone) ++ const callees = said.found.filter(bot => bot.id !== sender.id) + if (callees.length > 0) { -+ round.calls = [...(round.calls ?? []), { by: sender.id, name: sender.name, callees, text }] ++ const dispatch = dispatchOf(room, { botId: sender.id, text }, callees, said.all) ++ if (dispatch) { ++ // The round's own call is the fallback, taken only when the board refused the write: a failed ++ // card must never be a member left uncalled (#1254). ++ void assignDispatch(room, dispatch, sender.id).then(wrote => { ++ if (wrote === undefined) round.calls = [...(round.calls ?? []), { by: sender.id, name: sender.name, callees, text }] ++ }) ++ } else round.calls = [...(round.calls ?? []), { by: sender.id, name: sender.name, callees, text }] + extra.called = true + } + return true diff --git a/patches/ds-bot/files/src/host/tasks.js b/patches/ds-bot/files/src/host/tasks.js index 6c61c00ab..32fa5ee0f 100644 --- a/patches/ds-bot/files/src/host/tasks.js +++ b/patches/ds-bot/files/src/host/tasks.js @@ -18,6 +18,7 @@ import { randomBytes } from 'node:crypto' import { columnOf, compareForQueue, compareForWork, sameRole } from '../shared/task-board.js' import { currentParticipants, deriveParticipants } from './room-membership.js' +import { namesIssue } from './work-dispatch.js' const ACTIVE = new Set(['open', 'claimed', 'working', 'blocked']) // result-ready still waits for the requester: prune must not evict it with terminals (#111). @@ -499,6 +500,66 @@ export function install(rt) { } } + // #1254: the room's task lead naming one member with an issue id and an imperative is an assign, + // not a round (`groups.js` `dispatchOf`). The card that member already holds for that issue is + // updated; otherwise one is opened, owned by that member and still `open` — the host assigns, and + // the member claims on its own turn, where #1253's claim line is posted in its own voice. The save + // drives the wake into that member's own chat (#1205), so the room opens no part. + rt.assignFromDispatch = async function assignFromDispatch({ roomId, botId, issue, title, note, by } = {}) { + const room = state.rooms?.[String(roomId ?? '')] + if (!room || room.origin === 'face-to-face') return undefined + const bot = botRecord(botId) + if (!bot || bot.remote !== undefined) return undefined + if (!(room.members ?? []).includes(bot.id)) return undefined + const text = oneLine(note ?? title, 300) + if (text === '') return undefined + const at = stamp() + const open = Object.values(store()).filter(task => + sameRoom(task, room.id) && task.ownerBotId === bot.id && task.kind !== 'group' + && openTask(task)) + const held = issue === undefined || issue === null || String(issue).trim() === '' + ? undefined + : open.filter(task => namesIssue(`${task.title ?? ''} ${task.note ?? ''} ${task.target?.href ?? ''}`, issue)).sort(newestFirst)[0] + if (held) { + held.note = text + // Both paths below append to `actors`, and `record()` pushes without a guard, so a card + // stored before the field existed would throw after the note moved and before the save + // (#1254). + held.actors ??= [] + // A card the drive does not read is restarted through an edge the state machine already has + // (`result-ready → working`, `blocked → working`): rewriting the note alone saved a card that + // `task-drive` never wakes for, so the member never saw the dispatch. A blocked card is reused + // for the same reason one issue is one card — the dispatch answers the wait, it never adds a + // second card beside it (#1254). + if (held.status === 'result-ready' || held.status === 'blocked') { + transition(held, 'working', null, { by: 'dispatch', ...(by ? { botId: by } : {}) }) + } else { + held.updatedAt = at + // A stored card may predate `actors`: the append starts the list instead of throwing after the + // note already moved, which `groups.js` swallowed and left a half-written card (#1254). + held.actors = [...(held.actors ?? []), { status: held.status, at, by: 'dispatch', ...(by ? { botId: by } : {}) }] + } + await save() + return view(held) + } + const task = { + id: `task-${randomBytes(8).toString('hex')}`, + status: 'open', + createdAt: at, + updatedAt: at, + requesterId: bot.ownerId ?? null, + ownerBotId: bot.id, + title: oneLine(title ?? text, 300), + note: text, + source: 'conversation', + origin: { roomId: room.id }, + actors: [{ status: 'open', at, by: 'dispatch', ...(by ? { botId: by } : {}) }], + } + store()[task.id] = task + await save() + return view(task) + } + // A member claims from the room's queue (#1247). The claim is a board write: it sets the // owner and moves the card to claimed, so the delivery view and the room board read the same // card with no second row. diff --git a/patches/ds-bot/files/src/host/work-dispatch.js b/patches/ds-bot/files/src/host/work-dispatch.js new file mode 100644 index 000000000..636fcbf54 --- /dev/null +++ b/patches/ds-bot/files/src/host/work-dispatch.js @@ -0,0 +1,50 @@ +// #1254: an admin's @ in a group is work, not a discussion. When the room's task lead names one +// member with an issue/PR id and an imperative ("@Dev1 #1234 rebase and push"), the host writes +// that member's card and the drive wakes it in its own chat (#1205); no group part opens. A +// question, a remark, a line naming two members or an `@all` keeps the group turn it had — this +// module only answers the shape of one line, the room decides who may dispatch. +const ISSUE = /#(\d{1,7})(?![0-9])|github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/(\d{1,7})(?![0-9])/gi +const MENTION = /@[^\s@,,。]{1,40}/g +const QUESTION = /[??]/ +// An imperative counts only near the id: "rebase #1234" and "#1234 rebase and push" both dispatch, +// "#1234 looks good to me" stays a remark. The window is characters, not words, so it covers CJK. +const NEAR = 40 +const IMPERATIVE = /(?:\b(?:rebase|push|fix|implement|land|merge|open|close|claim|take|add|update|remove|delete|refactor|write|read|investigate|review|run|check|test|debug|split|amend|drive|finish|post|prepare|draft|design|build|move|bump|revert|rename|wire|port|handle|address|verify|reproduce|triage|document|patch|publish|release|deploy|archive|start|continue|make|set|teach|follow)\b|修复|修改|修正|实现|合并|推送|提交|更新|处理|完成|补充|去掉|删掉|删除|检查|验证|复现|整理|重构|拆分|设计|编写|写|改|修|做|跑|查|推|补|删)/i +const oneLine = value => String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300) + +// "@Dev1" is a call, not part of the instruction: the card says what to do, and the room already +// knows who was named. +const withoutMentions = text => String(text ?? '').replace(MENTION, ' ').replace(/\s+/g, ' ').trim() + +// The issue/PR numbers a line names, normalised to `#1234`, in the order they appear. +export function issueRefs(text) { + const keys = [] + for (const match of String(text ?? '').matchAll(new RegExp(ISSUE.source, 'gi'))) { + const key = `#${match[1] ?? match[2]}` + if (!keys.includes(key)) keys.push(key) + } + return keys +} + +export function namesIssue(text, issue) { + const number = String(issue ?? '').replace(/^#/, '') + if (!/^\d+$/.test(number)) return false + return issueRefs(text).includes(`#${number}`) +} + +// One member, one issue id, one imperative near it. Anything else is not this module's to convert. +export function workDispatch(text, { callees = [] } = {}) { + if (!Array.isArray(callees) || callees.length !== 1) return undefined + const callee = callees[0] + if (!callee?.id) return undefined + const line = withoutMentions(text) + if (line === '' || QUESTION.test(line)) return undefined + for (const match of line.matchAll(new RegExp(ISSUE.source, 'gi'))) { + const near = line.slice(Math.max(0, match.index - NEAR), match.index + match[0].length + NEAR) + if (!IMPERATIVE.test(near)) continue + const issue = `#${match[1] ?? match[2]}` + const note = oneLine(line) + return { botId: callee.id, issue, title: note, note } + } + return undefined +} diff --git a/patches/ds-bot/files/test/work-dispatch.test.js b/patches/ds-bot/files/test/work-dispatch.test.js new file mode 100644 index 000000000..b202e43e7 --- /dev/null +++ b/patches/ds-bot/files/test/work-dispatch.test.js @@ -0,0 +1,328 @@ +// #1254: an admin's @ in a group is work, not a discussion. When the room's task lead names one +// member with an issue/PR id and an imperative ("@Dev1 #1234 rebase and push"), the line is a +// dispatch: the host writes the member's card and the drive wakes it in its own chat (#1205), and +// the member gets no group part. A question, a plain @, a line from a member who does not divide +// the board, and a face-to-face room (which has no board) all keep the group turn they had. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { workDispatch } from '../src/host/work-dispatch.js' +import { fakeContext } from './fake-ctx.js' + +const team = (room = {}) => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } }, + bots: { + a: { id: 'a', name: 'Chief', role: 'Lead', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, + b: { id: 'b', name: 'Dev1', role: 'Dev', sessionId: 'b', createdAt: 2, ownerId: 'owner-1', createdBy: 'a' }, + c: { id: 'c', name: 'Tester', role: 'Test', sessionId: 'c', createdAt: 3, ownerId: 'owner-1', createdBy: 'a' }, + }, + rooms: { + war: { + id: 'war', name: 'War', members: ['a', 'b', 'c'], admin: 'a', mode: 'admin', named: true, + sessions: {}, log: [], seq: 0, seen: {}, + participants: [ + { kind: 'human', id: 'owner-1', role: 'member', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'a', role: 'admin', displayName: 'Chief', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'b', role: 'member', displayName: 'Dev1', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'c', role: 'member', displayName: 'Tester', joinedSeq: 0, joinedAt: 1 }, + ], + ...room, + }, + }, + grants: {}, + agents: {}, + exchanges: [], + exchangeTurns: {}, + questions: {}, + prefs: {}, +}) + +async function start(t, overrides = {}) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-dispatch-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(team(overrides))) + const ctx = fakeContext() + const agents = new Map() + const idleWaiters = [] + const idle = (sessionId) => { + if (sessionId !== undefined) agentOf(sessionId).status = 'idle' + for (const resolve of idleWaiters.splice(0)) resolve() + } + const agentOf = (sessionId) => { + if (!agents.has(sessionId)) { + const calls = [] + const inbox = { + nextStep: [], + nextTurn: [], + remove(id) { + const keep = message => message.id !== id + const before = this.nextTurn.length + this.nextStep.length + this.nextTurn = this.nextTurn.filter(keep) + this.nextStep = this.nextStep.filter(keep) + return this.nextTurn.length + this.nextStep.length !== before + }, + } + agents.set(sessionId, { + session: { id: sessionId }, id: sessionId, calls, status: 'idle', inbox, + followup(message) { calls.push(message); this.inbox.nextTurn.push(message) }, + steer(message) { calls.push(message) }, + inject() {}, + cancel() {}, + whenIdle() { return new Promise(resolve => idleWaiters.push(resolve)) }, + }) + } + return agents.get(sessionId) + } + let created = 0 + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { + create: async () => ({ sessionId: `made-${++created}` }), + resolveAgent: async id => ({ agent: agentOf(id) }), + selectModel: async () => {}, + page: async () => ({ records: [], hasMore: false }), + }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home }) + await rt.load() + t.after(async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + }) + // A Bot's post into the room, as the relay queues it: the room's Session has already taken it. + const post = (text, from = 'a') => ({ text, botId: from, name: rt.botOf(from)?.name ?? from }) + let rounds = 0 + // One group_relay run over the queued lines. A dispatch run ends by itself; a round that calls + // a member is stopped by the test once its assertions are done. + const relay = (lines) => { + rt.roomInbox.set('war', lines) + const stop = new AbortController() + t.after(() => stop.abort()) + let value + const done = ctx.registeredTools.get('group_relay') + .execute({}, { agent: { id: 'war' }, callId: `relay-${++rounds}`, signal: stop.signal }) + .then(body => { value = JSON.parse(body) }, () => {}) + return { done, value: () => value, stop } + } + const room = () => rt.state.rooms.war + const cards = () => Object.values(rt.state.tasks ?? {}).filter(task => task.origin?.roomId === 'war') + const groupParts = botId => (room().sessions?.[botId] === undefined ? 0 : agentOf(room().sessions[botId]).calls.length) + const wakeOf = botId => agentOf(botId).calls.find(message => message.source?.role === 'task') + return { ctx, rt, agentOf, room, cards, groupParts, wakeOf, relay, post, idle } +} + +const settle = async (check) => { + const until = Date.now() + 3000 + while (!check() && Date.now() < until) await new Promise(resolve => setImmediate(resolve)) + assert.ok(check(), 'condition not reached') +} + +test('the dispatch shape is an issue id and an imperative, never any @ (#1254)', () => { + const one = [{ id: 'a' }, { id: 'b' }] + assert.deepEqual(workDispatch('@Dev1 #1234 rebase and push', { callees: [one[0]] }), { + botId: 'a', issue: '#1234', title: '#1234 rebase and push', note: '#1234 rebase and push', + }) + assert.ok(workDispatch('@Dev1 please fix #1234', { callees: [one[0]] }), 'the verb may come first') + assert.ok(workDispatch('@Dev1 https://github.com/o/r/pull/1234 land it', { callees: [one[0]] }), 'a pull link is an id too') + assert.ok(workDispatch('@Dev1 请按 #1254 修一下 dispatch', { callees: [one[0]] }), 'Chinese imperatives count') + assert.equal(workDispatch('@Dev1 #1234 looks good to me', { callees: [one[0]] }), undefined, 'a remark is not a dispatch') + assert.equal(workDispatch('@Dev1 #1234 which file?', { callees: [one[0]] }), undefined, 'a question stays a question') + assert.equal(workDispatch('@Dev1 #1234 fix this?', { callees: [one[0]] }), undefined, 'the question mark wins over the imperative near the id') + assert.equal(workDispatch('@Dev1 rebase and push', { callees: [one[0]] }), undefined, 'no issue id, no card') + assert.equal(workDispatch('@Dev1 #1234 rebase', { callees: one }), undefined, 'two members are not one assignee') +}) + +test('an admin @ on an issue writes the card and wakes the member instead of opening a round (#1254)', async (t) => { + const app = await start(t) + const run = app.relay([app.post('@Dev1 #1234 rebase and push')]) + await run.done + assert.deepEqual(app.room().sessions, {}, 'no member Session was opened in the room') + assert.equal(app.rt.roomProgress.size, 0) + const card = app.cards().find(task => task.ownerBotId === 'b') + assert.ok(card, 'the dispatch wrote a card for Dev1') + assert.equal(card.title, '#1234 rebase and push') + assert.equal(card.note, '#1234 rebase and push') + assert.equal(card.status, 'open', 'the host assigns; the member claims on its own turn') + assert.equal(card.source, 'conversation') + const wake = app.wakeOf('b') + assert.ok(wake, 'Dev1 is woken in its own chat') + assert.match(wake.content.map(block => block.text ?? '').join('\n'), new RegExp(`Continue ${card.id} \\[normal\\] #1234 rebase and push`)) + assert.deepEqual(run.value(), { replies: [], passed: [] }, 'no group part, no pass either') +}) + +test('a dispatch on a card that exists updates that card instead of writing a second one (#1254)', async (t) => { + const app = await start(t) + await app.relay([app.post('@Dev1 #1234 rebase onto main')]).done + const [first] = app.cards() + assert.ok(first) + const { id, note, updatedAt } = first + await app.relay([app.post('@Dev1 #1234 then push the branch')]).done + assert.equal(app.cards().length, 1, 'one card for one issue') + const [again] = app.cards() + assert.equal(again.id, id) + assert.equal(again.note, '#1234 then push the branch') + assert.notEqual(again.note, note) + assert.ok(again.updatedAt > updatedAt, 'the update moves the card, so the drive wakes it') +}) + +test('a question to a member is still its group turn (#1254)', async (t) => { + const app = await start(t) + const run = app.relay([app.post('@Dev1 #1234 which file should I change?')]) + await settle(() => app.room().sessions.b !== undefined) + assert.equal(app.cards().length, 0, 'a question writes no card') + run.stop.abort() + await run.done +}) + +test('a human naming the admin still gets the admin\'s group turn (#1254)', async (t) => { + const app = await start(t) + const run = app.relay([{ text: '@Chief 现在 token 是哪个?', authorId: 'owner-1', authorKind: 'human', authorName: 'Alice' }]) + await settle(() => app.room().sessions.a !== undefined) + assert.equal(app.cards().length, 0, 'a person\'s line is not a board write') + run.stop.abort() + await run.done +}) + +test('a plain @ about an issue is not a dispatch (#1254)', async (t) => { + const app = await start(t) + const run = app.relay([app.post('@Dev1 #1234 looks good to me')]) + await settle(() => app.room().sessions.b !== undefined) + assert.equal(app.cards().length, 0, 'a remark keeps the group turn it had') + run.stop.abort() + await run.done +}) + +test('a member who does not divide the board keeps the round (#1254)', async (t) => { + const app = await start(t) + const run = app.relay([app.post('@Dev1 #1234 rebase and push', 'c')]) + await settle(() => app.room().sessions.b !== undefined) + assert.equal(app.cards().length, 0, 'only the task lead dispatches to the board') + run.stop.abort() + await run.done +}) + +test('a face-to-face room has no board, so a dispatch stays a turn (#1254)', async (t) => { + const app = await start(t, { origin: 'face-to-face' }) + const run = app.relay([app.post('@Dev1 #1234 rebase and push')]) + await settle(() => app.room().sessions.b !== undefined) + assert.equal(app.cards().length, 0) + run.stop.abort() + await run.done +}) + +test('the same dispatch inside a running round is a board assign, not a second call (#1254)', async (t) => { + const app = await start(t) + const run = app.relay([{ text: '@Dev1 say hello', authorId: 'owner-1', authorKind: 'human', authorName: 'Alice' }]) + await settle(() => app.room().sessions.b !== undefined) + app.agentOf('war').status = 'running' + await app.rt.postToRoom(app.room(), { id: 'a', name: 'Chief' }, '@Tester #1234 rebase and push', { hop: 0, origin: 'owner' }) + await settle(() => app.cards().some(task => task.ownerBotId === 'c')) + assert.equal(app.room().sessions.c, undefined, 'Tester was not called inside the round') + assert.equal(app.cards().find(task => task.ownerBotId === 'c').title, '#1234 rebase and push') + await settle(() => app.wakeOf('c') !== undefined) + app.idle('war') + run.stop.abort() + await run.done +}) + +// A card the member already holds for the issue, parked where the drive does not read it: the +// dispatch has to restart it through the one edge that exists, or the note moves and nobody wakes. +const parked = (status, extra = {}) => { + const at = Date.now() - 1000 + return { + id: `task-${status}`, status, createdAt: at, updatedAt: at, + requesterId: 'owner-1', ownerBotId: 'b', title: '#1234 rebase and push', note: 'the first pass', + source: 'conversation', origin: { roomId: 'war' }, actors: [{ status: 'open', at, by: 'dispatch' }], + ...extra, + } +} + +test('a dispatch restarts a card that is result-ready, so the drive wakes the member (#1254)', async (t) => { + const app = await start(t) + app.rt.state.tasks['task-result-ready'] = parked('result-ready') + await app.relay([app.post('@Dev1 #1234 then push the branch')]).done + assert.equal(app.cards().length, 1, 'the parked card is reused, not duplicated') + const card = app.cards()[0] + assert.equal(card.status, 'working', 'result-ready → working is the edge the state machine already has') + assert.equal(card.note, '#1234 then push the branch') + assert.equal(card.actors.at(-1).by, 'dispatch') + assert.ok(app.wakeOf('b'), 'working is driving, so the member is woken in its own chat') +}) + +test('a dispatch reuses a card blocked on an input and restarts it (#1254)', async (t) => { + const app = await start(t) + app.rt.state.tasks['task-blocked'] = parked('blocked', { waitingFor: 'input', inputRequest: { id: 'q1', question: 'which file?' } }) + await app.relay([app.post('@Dev1 #1234 rebase onto main')]).done + assert.equal(app.cards().length, 1, 'one issue is one card, even while the old one waits on a person') + const card = app.cards()[0] + assert.equal(card.status, 'working') + assert.equal(card.waitingFor, undefined, 'the dispatch answers the wait, so the card drives again') + assert.ok(app.wakeOf('b')) +}) + +test('a stored card with no actors is updated, never half-written (#1254)', async (t) => { + const app = await start(t) + const { actors, ...bare } = parked('working') + app.rt.state.tasks['task-bare'] = { ...bare, status: 'claimed' } + await app.relay([app.post('@Dev1 #1234 finish the branch')]).done + const card = app.cards()[0] + assert.equal(card.note, '#1234 finish the branch') + assert.ok(Array.isArray(card.actors), 'the append starts a list instead of throwing after the note moved') + assert.deepEqual(card.actors.map(actor => actor.by), ['dispatch']) + assert.ok(app.wakeOf('b'), 'the save ran, so the drive woke the member') +}) + +test('a dispatch restarts a result-ready card that has no actors, without throwing (#1254)', async (t) => { + const app = await start(t) + // A card stored before `actors` existed. The restart goes through `transition()` → `record()`, + // which pushes onto `task.actors` with no guard, so a bare field throws after the note moved and + // before the save: the member is never woken, and the card is left half-written. + const { actors, ...bare } = parked('result-ready') + app.rt.state.tasks['task-result-ready-bare'] = bare + const card = await app.rt.assignFromDispatch({ + roomId: 'war', botId: 'b', issue: '#1234', note: '#1234 finish the branch', by: 'a', + }) + assert.ok(card, 'the card write went through') + assert.equal(card.status, 'working', 'the restart edge still applies to a card with no history') + assert.ok(Array.isArray(card.actors), 'the restart starts the list instead of throwing into it') + assert.equal(card.actors.length, 1, 'one entry for the restart, none invented') + assert.equal(card.actors[0].by, 'dispatch') + assert.equal(card.note, '#1234 finish the branch') + assert.ok(app.wakeOf('b'), 'the save ran, so the drive woke the member') +}) + +test('a dispatch whose card write fails still calls the member inside the round (#1254)', async (t) => { + const app = await start(t) + const run = app.relay([{ text: '@Dev1 say hello', authorId: 'owner-1', authorKind: 'human', authorName: 'Alice' }]) + await settle(() => app.room().sessions.b !== undefined) + app.agentOf('war').status = 'running' + const live = () => [...app.rt.roomProgress.values()].find(round => round.roomId === 'war') + const pending = () => (live()?.calls ?? []).filter(call => call.callees.some(bot => bot.id === 'c')) + // The board refuses the write: the round's own call is the fallback, or the member is never told. + app.rt.assignFromDispatch = async () => undefined + await app.rt.postToRoom(app.room(), { id: 'a', name: 'Chief' }, '@Tester #1234 rebase and push', { hop: 0, origin: 'owner' }) + await settle(() => pending().length === 1) + assert.equal(app.cards().length, 0, 'the write failed, so there is no card to trust') + assert.equal(pending()[0].by, 'a') + app.idle('war') + run.stop.abort() + await run.done +}) + +test('a dispatch whose card write fails still opens the member\'s turn (#1254)', async (t) => { + const app = await start(t) + app.rt.assignFromDispatch = async () => undefined + const run = app.relay([app.post('@Dev1 #1234 rebase and push')]) + await settle(() => app.room().sessions.b !== undefined) + assert.equal(app.cards().length, 0) + run.stop.abort() + await run.done +})