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 +})