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/1253-claim-cue.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Claiming or assigning a card in a group now posts the one public line the room notice asks for — `接了 #1253,第一步是 …` — in the claiming member's voice. The line goes into the room log without opening a round or waking anyone, takes the card's own note as the first step, and is not repeated for the same card within 30 minutes; progress stays on the card. ([#1253](https://github.com/ThinkFlowLab/FlyBot/issues/1253))
43 changes: 43 additions & 0 deletions core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@ 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'

// #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.
export const CLAIM_CUE_REPEAT_MS = 30 * 60 * 1000
// The claim line the room notice asks for (`接了 #1234,第一步是 <做什么>`): the reference is the issue
// number the title carries, else the title; the first step is the card's own note, and a card with
// neither still names itself rather than inventing a step. The wording is Chinese because the notice
// that requires the line is (#1253), not because the room is assumed to speak Chinese.
const CLAIM_CUE_REF_MAX = 60
const CLAIM_CUE_STEP_MAX = 100
const claimLine = (value, max) => String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max)
export function claimCueText(task) {
const title = claimLine(task?.title, 300)
if (title === '') return ''
const found = title.match(/^#(\d{1,7})\b/) ?? title.match(/#(\d{1,7})\b/)
const ref = found ? `#${found[1]}` : title.slice(0, CLAIM_CUE_REF_MAX)
const rest = found ? title.slice(title.indexOf(found[0]) + found[0].length).trim() : ''
const step = claimLine(task?.note, CLAIM_CUE_STEP_MAX) || claimLine(rest, CLAIM_CUE_STEP_MAX)
return step === '' ? `接了 ${ref}` : `接了 ${ref},第一步是 ${step}`
}

const ROOM_LOG_MAX = 60
const ROOM_FIRST_LOOK = 12
const ROOM_DELTA_MAX = 24
Expand Down Expand Up @@ -1155,6 +1175,29 @@ export function install(rt) {
})
}

// #1253: taking a card in a room is public. The line is appended to the room log — where the pane
// and `read_group_chat` read it — and nothing is posted into the room's Session: no followup, no
// model turn, no round. The card's write already woke the member in its own chat (#1205); this is
// only the beacon the room notice asks for. An identical line for the same card inside
// CLAIM_CUE_REPEAT_MS is not posted again, so a re-claim and a progress tick stay quiet.
rt.postClaimLine = function postClaimLine(task) {
const room = rt.roomOf(task?.origin?.roomId)
const member = rt.botOf(task?.ownerBotId)
if (!room || !member || isFaceRoom(room)) return false
if (!(room.members ?? []).includes(member.id)) return false
const text = claimCueText(task)
if (text === '') return false
const at = Date.now()
const last = task.claimCue
if (last?.text === text && Number.isFinite(last.at) && at - last.at < CLAIM_CUE_REPEAT_MS) return false
ensureRoomLog(room)
const line = rt.authoredRoomLine?.(room, { who: member.name, botId: member.id, authorId: member.id, authorKind: 'bot', text })
?? { who: member.name, botId: member.id, text }
const posted = appendRoom(room, line)
task.claimCue = { seq: posted.seq, at, text }
return true
}

// A member the round moved on from (it went on working, went quiet or waited for an
// approval) posts what its turn ended with. The member that asked for it and anyone
// it names answer; past the late round limit nobody does, so late posts cannot keep a
Expand Down
23 changes: 22 additions & 1 deletion core/src/host/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,11 @@ export function install(rt) {
}
const view = (task) => {
const parent = task.parentId ? store()[task.parentId] : undefined
// `claimCue` is the board's own memory of the line it posted (#1253); no reader of a card needs
// the seq and text of its last claim line, so the view leaves it out.
const { claimCue, ...row } = task
return {
...task,
...row,
actors: [...task.actors ?? []],
...(botRecord(task.ownerBotId)?.name ? { ownerBotName: botRecord(task.ownerBotId).name } : {}),
...(parent?.title ? { parentTitle: parent.title } : {}),
Expand Down Expand Up @@ -483,6 +486,19 @@ export function install(rt) {
return view(task)
}

// #1253: taking a card in a room is public — one line in that member's voice, logged in the room
// and opening no round (`groups.js` `postClaimLine`). A card outside a room, a progress tick on a
// card the member already holds, and an identical line at the same card stay quiet.
const announceClaim = (task) => {
if (!task.origin?.roomId) return
if (task.status !== 'claimed' && task.status !== 'working') return
try {
rt.postClaimLine?.(task)
} catch (error) {
rt.warn('ds-bot: the claim line for %s was not posted: %s', task.id, error)
}
}

// 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 Expand Up @@ -524,6 +540,7 @@ export function install(rt) {
delete live.retryAt
delete live.queueReason
transition(live, 'claimed', null, { by: 'claimed from the queue' })
announceClaim(live)
await save()
return { item: view(live) }
}
Expand Down Expand Up @@ -676,6 +693,9 @@ export function install(rt) {
const { progress } = applyFields(task, item)
if (!task.source) task.source = 'conversation'
const before = task.updatedAt
// #1253: only the move out of the queue/not-started is a claim, so the progress ticks that
// follow it on the same card never reach the room.
const wasOpen = task.status === 'open'
if (answer) resumeFromAnswer(task, answer)
const moved = applyColumn(task, column, null, 'conversation')
if (column === 'done' && progress === undefined) task.progress = 100
Expand All @@ -685,6 +705,7 @@ export function install(rt) {
} else if (!answer && !isNew && !moved && task.updatedAt === before) record(task, null, { by: 'conversation' })
// Assigning claims the card. The assignee starts the work; nobody confirms first (#1182).
if (isNew && parent && task.ownerBotId !== botId && task.status === 'open') transition(task, 'claimed', null, { by: 'assigned' })
if (wasOpen && (task.status === 'claimed' || task.status === 'working')) announceClaim(task)
}
if (task.title) batch.set(task.title, task)
batch.set(task.id, task)
Expand Down
182 changes: 182 additions & 0 deletions core/test/claim-cue.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// #1253: a claim in a room is public. The room notice requires one short line when a card is
// taken — `接了 #1234,第一步是 …` — and the board write (claim or assign) is the moment to post
// it. The line is logged in the room and drawn from the log, so it opens no group round and wakes
// nobody: the member's own work arrives as its own-chat wake (#1205), and these cases pin that
// the two never merge. Progress stays on the card, and a re-claim of the same card does not
// repeat the line.
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 { CLAIM_CUE_REPEAT_MS } from '../src/host/groups.js'
import { fakeContext } from './fake-ctx.js'

const team = () => ({
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' },
d1: { id: 'd1', name: 'Dev1', role: 'Dev', sessionId: 'd1', createdAt: 2, ownerId: 'owner-1', createdBy: 'a' },
d2: { id: 'd2', name: 'Dev2', role: 'Dev', sessionId: 'd2', createdAt: 3, ownerId: 'owner-1', createdBy: 'a' },
},
rooms: {
war: {
id: 'war', name: 'War', members: ['a', 'd1', 'd2'], admin: 'a', mode: 'everyone', 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: 'd1', role: 'member', displayName: 'Dev1', joinedSeq: 0, joinedAt: 1 },
{ kind: 'bot', id: 'd2', role: 'member', displayName: 'Dev2', joinedSeq: 0, joinedAt: 1 },
],
},
},
grants: {},
agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {},
})

async function start(t) {
const home = mkdtempSync(join(tmpdir(), 'ds-bot-claim-cue-'))
writeFileSync(join(home, 'state.json'), JSON.stringify(team()))
const ctx = fakeContext()
const agents = new Map()
const agentOf = (sessionId) => {
if (!agents.has(sessionId)) {
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) { this.calls.push(message); this.inbox.nextTurn.push(message) },
steer() {}, inject() {}, cancel() {}, async whenIdle() {},
})
}
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 })
})
const track = (sessionId, args) => ctx.registeredTools.get('track_tasks').execute(args ?? {}, { agent: { id: sessionId } })
const calls = (sessionId) => agents.get(sessionId)?.calls ?? []
const idOf = (body, title) => {
const line = body.split('\n').find(item => item.startsWith('- ') && item.includes(title))
const id = line?.match(/task-[0-9a-f]+/)?.[0]
assert.ok(id, body)
return id
}
const room = () => rt.state.rooms.war
const lines = () => room().log.map(line => line.text)
const enqueue = async (spec = {}) => {
const body = await track('a', { tasks: [{ group: 'War', queue: true, ...spec }] })
return { body, id: idOf(body, spec.title) }
}
return { ctx, rt, track, calls, idOf, room, lines, enqueue, home }
}

test('a claim in a room posts one line in the claimer\'s voice and opens no round (#1253)', async (t) => {
const app = await start(t)
const { id } = await app.enqueue({ title: '#1253 the claim cue line', role: 'Dev', priority: 'high' })
assert.equal(app.lines().length, 0, 'the queue itself is a board write, not a room line')
const said = await app.track('d1', { claim: true, group: 'War' })
assert.match(said, new RegExp(`^Claimed ${id} `))
const posted = app.room().log.at(-1)
assert.equal(app.room().log.length, 1, `one line, got:\n${app.lines().join('\n')}`)
assert.equal(posted.botId, 'd1', 'in the claimer\'s voice')
assert.equal(posted.who, 'Dev1')
assert.match(posted.text, /^接了 #1253,第一步是 the claim cue line$/)
// No group round: the room's own Session was never woken, and nothing is in progress.
assert.equal(app.rt.roomProgress.size, 0)
assert.equal(app.calls('war').length, 0, 'the line does not open a round')
assert.equal(app.calls('d2').length, 0, 'the other members are not called')
// The claimer's work still arrives as its own-chat wake (#1205), not into the room: the queue
// offer it had before the claim, then the card's own continue cue.
const wake = app.calls('d1').at(-1)
assert.equal(wake.source.role, 'task')
assert.match(wake.content[0].text, /\[Continue task\]/)
assert.match(wake.content[0].text, new RegExp(`Continue ${id} \\[high\\] #1253 the claim cue line`))
})

test('the note is the first step, and it is the card\'s note, not invented (#1253)', async (t) => {
const app = await start(t)
const { id } = await app.enqueue({ title: '#1253 the claim cue line', role: 'Dev', note: 'reading tasks.js, then the test' })
await app.track('d1', { claim: true, group: 'War', id })
assert.equal(app.room().log.at(-1).text, '接了 #1253,第一步是 reading tasks.js, then the test')
})

test('a card assigned in the room posts the line in the assignee\'s voice (#1253)', async (t) => {
const app = await start(t)
const plan = await app.track('a', { tasks: [{ group: 'War', overall: true, title: '#1253 the plan' }] })
const planId = app.idOf(plan, 'the plan')
const body = await app.track('a', { tasks: [{ group: 'War', title: '#1253 write the failing test', assignee: 'Dev1', parent: planId }] })
const id = app.idOf(body, 'write the failing test')
assert.equal(app.rt.state.tasks[id].status, 'claimed', 'assigning claims the card')
const posted = app.room().log.at(-1)
assert.equal(app.room().log.length, 1)
assert.equal(posted.botId, 'd1', 'the assignee speaks, not the lead')
assert.equal(posted.who, 'Dev1')
assert.match(posted.text, /^接了 #1253,第一步是 write the failing test$/)
assert.equal(app.rt.roomProgress.size, 0)
assert.equal(app.calls('war').length, 0)
})

test('progress on a card the member already holds posts nothing (#1253)', async (t) => {
const app = await start(t)
const { id } = await app.enqueue({ title: '#1253 the claim cue line', role: 'Dev' })
await app.track('d1', { claim: true, group: 'War', id })
assert.equal(app.room().log.length, 1)
await app.track('d1', { tasks: [{ id, column: 'in-progress', progress: 30, note: 'halfway' }] })
await app.track('d1', { tasks: [{ id, progress: 70, note: 'almost there' }] })
assert.equal(app.room().log.length, 1, `progress stays on the card, got:\n${app.lines().join('\n')}`)
})

test('a re-claim of the same card does not repeat the line (#1253)', async (t) => {
const app = await start(t)
const { id } = await app.enqueue({ title: '#1253 the claim cue line', role: 'Dev' })
await app.track('d1', { claim: true, group: 'War', id })
assert.equal(app.room().log.length, 1)
await app.track('d1', { release: { task: id, reason: 'needs a decision', retryInSec: 0 } })
await app.track('d2', { claim: true, group: 'War' })
assert.equal(app.room().log.length, 1, `an identical claim line is not repeated, got:\n${app.lines().join('\n')}`)
assert.equal(app.rt.state.tasks[id].claimedBy, 'd2', 'the re-claim itself still happened')
})

test('a claim outside a room posts no line (#1253)', async (t) => {
const app = await start(t)
const body = await app.track('d1', { tasks: [{ title: '#1253 a card in my own chat' }] })
const id = app.idOf(body, 'a card in my own chat')
await app.track('d1', { tasks: [{ id, column: 'in-progress', progress: 10 }] })
assert.equal(app.lines().length, 0)
assert.equal(app.rt.state.tasks[id].status, 'working')
})

test('the repeat window is a stated number of minutes, not forever (#1253)', () => {
assert.equal(CLAIM_CUE_REPEAT_MS, 30 * 60 * 1000)
})
Loading
Loading