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/1709-retired-bot-paths.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Retirement now reaches the host paths that resolved a Bot without `findTeamBot`: a retired Bot takes no task, group turn, capability, memory write, new or resumed schedule, or held or restarted delivery, the task drive no longer wakes it, and `archive_bot` pauses the tasks armed on its own chat and fails the cards it still holds ([#1709](https://github.com/ThinkFlowLab/FlyBot/issues/1709)).
5 changes: 3 additions & 2 deletions core/src/host/capabilities.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto'
import { RESERVED_NAMES } from './connectors.js'
import { ownEntry } from './store.js'
import { ownEntry, retiredBot } from './store.js'

const MCP_CLIENT = '@deepseek-ai/dsh-mcp-client'
const NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
Expand Down Expand Up @@ -156,7 +156,8 @@ export function install(rt) {
const caller = rt.botOf(callerId)
if (!caller) throw new Error('Only Bots on the team can change capabilities.')
const target = targetRef ? rt.findBot(targetRef) : caller
if (!target) throw new Error(`No Bot named ${targetRef}.`)
// A retired Bot is not addressable (#1709): it resolves exactly like an unknown name.
if (!target || (targetRef && retiredBot(target))) throw new Error(`No Bot named ${targetRef}.`)
if (target.id !== caller.id && !rt.isMain(callerId)) throw new Error('Only a Main Bot can change another Bot’s capabilities.')
const item = ownEntry(ensureLibrary(), String(name ?? ''))
if (!item) throw new Error(`No capability named ${name}.`)
Expand Down
16 changes: 11 additions & 5 deletions core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ 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'
import { retiredBot } from './store.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 @@ -439,6 +440,11 @@ export function install(rt) {
})
const minutesSince = at => Math.max(1, Math.round((Date.now() - at) / 60000))

// Retirement is final for every round path (#1709): a retired Bot is not a member a line can
// call, an @all can sweep in, or whose own line can call others. Its chat and history stay
// readable; only the turn is gone, so nothing is delivered and no Session starts.
const roomBots = room => room.members.map(rt.botOf).filter(bot => bot !== undefined && !retiredBot(bot))

// A called member moves up if it is still waiting, or gets one more turn if it
// already spoke; either way it goes right after slot `index`. `cap` is the caller's
// per-member turn bound for the round; `by` is the calling member, if a member called.
Expand Down Expand Up @@ -484,7 +490,7 @@ export function install(rt) {
const lines = waitingLines(agent).filter(line => agent.inbox.remove(line.id))
if (lines.length === 0) return 0
const before = round.slots.length
const members = room.members.map(rt.botOf).filter(Boolean)
const members = roomBots(room)
// The owner's own typed line reaches a face round through the Session inbox, not as the round's opener (the opener
// is the quiet "name: text" notice), so it is routed here (#299). A line added to a round that already routes
// is answered by the rule for its own opening, so that round stops routing.
Expand Down Expand Up @@ -776,7 +782,7 @@ export function install(rt) {
void rt.save().catch(() => {})
// Only the admin's "@all" calls everyone, so a member cannot set off a storm.
const leads = rt.isAdminOf(room, member.id)
const everyone = room.members.map(rt.botOf).filter(Boolean)
const everyone = roomBots(room)
const others = everyone.filter(other => other.id !== member.id)
const { all, found: named } = mentionsIn(reply, everyone)
const found = named.filter(other => other.id !== member.id)
Expand Down Expand Up @@ -824,7 +830,7 @@ 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 said = mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean), opener.references ?? [])
const said = mentionsIn(opener.text ?? '', roomBots(room), 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.
Expand Down Expand Up @@ -1065,7 +1071,7 @@ export function install(rt) {
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 []
const everyone = room.members.map(rt.botOf).filter(Boolean)
const everyone = roomBots(room)
const members = everyone.filter(bot => bot.id !== opener.botId)
const admin = members.find(bot => rt.isAdminOf(room, bot.id))
// A friend's own typed line that @-calls Bots here (#287): only those Bots answer. The caller
Expand Down Expand Up @@ -1168,7 +1174,7 @@ export function install(rt) {
// 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. 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 everyone = roomBots(room)
const said = mentionsIn(text, everyone)
const callees = said.found.filter(bot => bot.id !== sender.id)
if (callees.length > 0) {
Expand Down
4 changes: 4 additions & 0 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { faceMentions, mentionedByHandle } from './face-mentions.js'
import { FEED_EVENTS, installFeed } from './flybot-feed.js'
import { createPluginModel } from './plugin-model.js'
import { defineTool } from './tool-schema.js'
import { retiredBot } from './store.js'
import { EPHEMERAL_DENIED_TOOLS } from './tools.js'

export const FLYBOT_API_VERSION = 1
Expand Down Expand Up @@ -396,6 +397,9 @@ export function install(rt) {
if (owner === null) return 'the sender Bot has no owner on this team'
if (approval.ownerId !== undefined && approval.ownerId !== owner) return 'the sender Bot changed owners after it was held'
if (!isRemote(request.to) && rt.botOf(request.to) === undefined) return 'the target Bot no longer exists'
// A retired Bot is out of the tree but its chat still exists (#1709): delivering to it is
// the delivery retirement forbids, so the hold is stale exactly as a deleted target's is.
if (!isRemote(request.to) && retiredBot(rt.botOf(request.to))) return 'the target Bot was retired'
return undefined
}
const approvalListeners = new Set()
Expand Down
3 changes: 2 additions & 1 deletion core/src/host/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { currentParticipants } from './room-membership.js'
import { defineTool } from './tool-schema.js'
import { splitRef, unquote } from './text.js'
import { output } from './tools.js'
import { retiredBot } from './store.js'
import { botNode, buildGraph, buildIndex, relatedOf, searchIndex, topicNode } from './memory-search.js'

// Fixed sizes, in characters. Both MEMORY.md files ride in every request of every
Expand Down Expand Up @@ -514,7 +515,7 @@ export function install(rt) {
if (/^(own|me|mine|self|yours?|自己|我)$/i.test(value)) return { scope: ownScope(botId) }
if (/^(team|团队|共享)$/i.test(value)) return { scope: TEAM }
const bot = rt.findBot(value)
if (bot === undefined) return { error: `No Bot named ${value}. scope is "own", "team", or a Bot's name.` }
if (bot === undefined || retiredBot(bot)) return { error: `No Bot named ${value}. scope is "own", "team", or a Bot's name.` }
return { scope: ownScope(bot.id) }
}
function writeDenied(scope, botId) {
Expand Down
16 changes: 14 additions & 2 deletions core/src/host/resume.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { SOURCE_KIND } from './constants.js'
import { OTHER, settled } from './turn-origin.js'
import { retiredBot } from './store.js'

// Long enough for DSH to finish opening its workspaces after a start.
const RESUME_DELAY_MS = 3000
Expand Down Expand Up @@ -181,7 +182,9 @@ export function install(rt) {

async function resumeTurn(sessionId, record) {
const bot = rt.botOf(record.botId)
if (bot === undefined) return
// A retired Bot is not woken by a restart (#1709): its chat Session outlives it, so the
// pickup would deliver to a Bot that no longer runs.
if (bot === undefined || retiredBot(bot)) return
const text = resumeText(record)
if (record.roomId !== undefined) {
const room = rt.roomOf(record.roomId)
Expand Down Expand Up @@ -287,7 +290,16 @@ export function install(rt) {
firstTurn = undefined
for (const [sessionId, list] of Object.entries(dropped)) {
if (!Array.isArray(list)) { delete queued[sessionId]; continue }
await resendQueued(sessionId, list).catch(error => rt.warn('ds-bot: sending again what %s was sent failed: %s', sessionId, error))
// A delivery queued for a Bot that has since been retired is not sent again (#1709),
// exactly as the turn above is not picked up: its note goes with the decision.
const kept = list.filter(item => !retiredBot(rt.botOf(item.args?.toId)))
if (kept.length !== list.length) {
if (kept.length === 0) delete queued[sessionId]
else queued = { ...queued, [sessionId]: kept }
persist()
}
if (kept.length === 0) continue
await resendQueued(sessionId, kept).catch(error => rt.warn('ds-bot: sending again what %s was sent failed: %s', sessionId, error))
}
taken = undefined
for (const [sessionId, record] of cut) {
Expand Down
8 changes: 7 additions & 1 deletion core/src/host/roster.js
Original file line number Diff line number Diff line change
Expand Up @@ -529,8 +529,14 @@ export function install(rt) {
bot.fields['flybot-tree'].retired = true
}
bot.hidden = true
// A retired Bot means what a deleted one does to the board (#1709): the cards it holds fail
// instead of waiting for a Bot that will not run again. `hooks` does this for deletion.
await rt.taskBotDeleted?.(id, 'the responsible Bot was retired')
// Retiring disarms its own chat Session (#1709): `schedules` installs after `roster`, and a
// host without the module (a test that mounts only the roster) simply has nothing to pause.
const freed = typeof rt.pauseBotSchedules === 'function' ? await rt.pauseBotSchedules(id) : undefined
await rt.save()
return bot
return { bot, freed }
}

async function deleteBot(id) {
Expand Down
42 changes: 42 additions & 0 deletions core/src/host/schedules.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { quietHoursOf, inQuietHours, clockIn, quietError } from '../shared/quiet
import { defineTool } from './tool-schema.js'
import { textOf } from './text.js'
import { output } from './tools.js'
import { retiredBot } from './store.js'

const MAX_TITLE = 120
// How long a due task may stay undelivered while the app runs before it counts as failed.
Expand Down Expand Up @@ -320,7 +321,10 @@ export function install(rt) {
function contentOf(body) {
const bot = state.bots[body.target]
const room = state.rooms[body.target]
// A retired Bot answers as an unknown target (#1709): its chat Session is still alive, so a
// row armed here would wake it on a timer.
if (bot === undefined && room === undefined) throw invalid('No such Bot or group chat')
if (retiredBot(bot)) throw invalid('No such Bot or group chat')
const title = String(body.title ?? '').trim()
const prompt = String(body.prompt ?? '').trim()
if (title === '') throw invalid('Give the task a name')
Expand Down Expand Up @@ -351,6 +355,7 @@ export function install(rt) {
const targetBot = state.bots[body.target]
const targetRoom = state.rooms[body.target]
if (targetBot === undefined && targetRoom === undefined) throw invalid('No such Bot or group chat')
if (retiredBot(targetBot)) throw invalid('No such Bot or group chat')
const same = owner.botId !== undefined ? targetBot?.id === owner.botId : targetRoom?.id === owner.roomId
if (same) throw invalid('Pick a different Bot or group chat to copy it to')
const request = requestOf(source)
Expand Down Expand Up @@ -475,6 +480,39 @@ export function install(rt) {
return schedulesView()
}

// Retiring a Bot disarms what its own chat Session already holds (#1709), with the same
// semantics as pausing one task: the record is saved in state.pausedSchedules before DSH
// drops the row, so a refusing delete loses nothing and is counted as still armed.
async function pauseBotSchedules(botId) {
const sessionId = rt.chatOf(botId)
if (service === null || sessionId === undefined) return { paused: 0, armed: 0, available: false }
const rows = (await service.catalog()).filter(entry => entry.sessionId === sessionId && entry.status === 'active')
const held = []
for (const row of rows) {
const request = requestOf(row)
if (request === null) continue
paused()[row.id] = { id: row.id, sessionId, record: { title: row.title, prompt: row.prompt, ...timingOf(timingPart(request)) }, pausedAt: Date.now() }
held.push(row)
}
// The records are saved before DSH lets the rows go, exactly as pausing one task does.
if (held.length > 0) await rt.save()
let pausedCount = 0
let armed = rows.length - held.length
for (const row of held) {
try {
const result = await service.delete({ sessionId, id: row.id })
if (result?.deleted === false) throw invalid('No such task')
pausedCount += 1
} catch {
delete paused()[row.id]
armed += 1
}
}
// A row DSH refused to drop is not paused either, so the saved state says so.
if (armed > 0) await rt.save()
return { paused: pausedCount, armed, available: true }
}

// Resume: DSH gets the task again, on the Bot's current part, before FlyBot forgets
// the paused copy. A one-time task whose time has passed waits for an edit.
async function resumeSchedule(body) {
Expand All @@ -483,6 +521,9 @@ export function install(rt) {
const held = pausedEntry(sessionId, String(body.id ?? ''))
const owner = ownerOf(sessionId)
if (held === undefined || !known(owner)) throw invalid('No such task')
// A paused task of a retired Bot stays paused (#1709): resuming it would arm that Bot's
// chat Session again, the door create, copy and update refuse.
if (owner.botId !== undefined && retiredBot(state.bots[owner.botId])) throw invalid('No such Bot or group chat')
const request = requestOf(held.record)
if (request === null) throw invalid('No such task')
if (request.at !== undefined && Date.parse(request.at) <= Date.now()) throw invalid('Its time has passed. Edit the task to pick a new one')
Expand Down Expand Up @@ -661,6 +702,7 @@ export function install(rt) {

Object.assign(rt, {
scheduleEndpoint: (endpoint, body, actor) => endpoints[endpoint](body ?? {}, actor),
pauseBotSchedules,
moveSchedules,
scheduleRunStarted: runStarted,
scheduleRunEnded: runEnded,
Expand Down
5 changes: 5 additions & 0 deletions core/src/host/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ const isRecord = value => value !== null && typeof value === 'object' && !Array.
// the prototype setter never swallows it (#1704, #1711).
export const ownEntry = (table, id) => (Object.hasOwn(table, id) ? table[id] : undefined)
export const setEntry = (table, id, value) => Object.defineProperty(table, id, { value, writable: true, enumerable: true, configurable: true })
// Retired (#638): the record keeps its chat, parts and memory, but no host path may resolve it
// into a turn, a capability, a memory write or a timer (#1700, #1709). One predicate, here,
// because every such path imports it rather than growing its own copy.
export const retiredBot = bot => bot?.fields?.['flybot-tree']?.retired === true


export function install(rt) {
const { ctx, home, statePath, config = {} } = rt
Expand Down
4 changes: 4 additions & 0 deletions core/src/host/task-drive.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// progress, and the next assignee are board writes; chat is not the scheduler.
import { compareForQueue, compareForWork, occupiesSlot, sameRole } from '../shared/task-board.js'
import { formatRoomBoard } from './room-context.js'
import { retiredBot } from './store.js'

// A no-op track_tasks call still bumps updatedAt. The hash leaves that stamp out,
// so a wake that did not move the board waits before the next one.
Expand Down Expand Up @@ -90,6 +91,9 @@ export function install(rt) {
if (!botId) return []
const bot = rt.botOf(botId)
if (!bot || bot.remote !== undefined) return []
// A retired Bot is not driven (#1709). Its cards stay on the board in the column and status
// they had: retiring stops the delivery, it invents no new state.
if (retiredBot(bot)) return []
return Object.values(rt.state.tasks ?? {}).filter((task) => {
if (task.ownerBotId !== botId || task.waitingFor === 'input') return false
// An item with a role and no claimer belongs to the room, not to this Bot: the enqueuer
Expand Down
Loading
Loading