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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1254-work-dispatch.fixed.md
Original file line number Diff line number Diff line change
@@ -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))
52 changes: 47 additions & 5 deletions core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 []
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions core/src/host/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions core/src/host/work-dispatch.js
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading