diff --git a/changelog.d/1709-retired-bot-paths.fixed.md b/changelog.d/1709-retired-bot-paths.fixed.md new file mode 100644 index 000000000..522a69554 --- /dev/null +++ b/changelog.d/1709-retired-bot-paths.fixed.md @@ -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)). diff --git a/core/src/host/capabilities.js b/core/src/host/capabilities.js index 054b593af..e95d189e2 100644 --- a/core/src/host/capabilities.js +++ b/core/src/host/capabilities.js @@ -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]+)*$/ @@ -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}.`) diff --git a/core/src/host/groups.js b/core/src/host/groups.js index 77910572f..2e75d8b0c 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -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. @@ -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. @@ -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. @@ -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) @@ -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. @@ -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 @@ -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) { diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 1de63b2f3..d36c72b8d 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -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 @@ -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() diff --git a/core/src/host/memory.js b/core/src/host/memory.js index 21a5d6491..baa6e066e 100644 --- a/core/src/host/memory.js +++ b/core/src/host/memory.js @@ -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 @@ -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) { diff --git a/core/src/host/resume.js b/core/src/host/resume.js index 3fdc5bb23..d010bf247 100644 --- a/core/src/host/resume.js +++ b/core/src/host/resume.js @@ -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 @@ -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) @@ -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) { diff --git a/core/src/host/roster.js b/core/src/host/roster.js index e23862f1e..ed28d43b6 100644 --- a/core/src/host/roster.js +++ b/core/src/host/roster.js @@ -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) { diff --git a/core/src/host/schedules.js b/core/src/host/schedules.js index 8a30d52c2..d262ab58b 100644 --- a/core/src/host/schedules.js +++ b/core/src/host/schedules.js @@ -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. @@ -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') @@ -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) @@ -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) { @@ -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') @@ -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, diff --git a/core/src/host/store.js b/core/src/host/store.js index 78619daa0..577bbf494 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -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 diff --git a/core/src/host/task-drive.js b/core/src/host/task-drive.js index 75cae8295..5bcb4a5de 100644 --- a/core/src/host/task-drive.js +++ b/core/src/host/task-drive.js @@ -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. @@ -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 diff --git a/core/src/host/tasks.js b/core/src/host/tasks.js index f0049d7b5..749180e37 100644 --- a/core/src/host/tasks.js +++ b/core/src/host/tasks.js @@ -17,6 +17,7 @@ // failed task never becomes accepted. Records keep pointers and a summary, not transcripts. import { randomBytes } from 'node:crypto' import { columnOf, compareForQueue, compareForWork, occupiesSlot, sameRole } from '../shared/task-board.js' +import { retiredBot } from './store.js' import { currentParticipants, deriveParticipants } from './room-membership.js' import { cardNamesIssue } from '../shared/issue-ref.js' import { ownEntry } from './store.js' @@ -756,7 +757,7 @@ export function install(rt) { const memberBot = (room, name) => { const wanted = String(name).trim() const ids = liveParticipants(room).filter(person => person.kind === 'bot' && !person.remote).map(person => person.id) - const matches = [...new Set(ids)].map(id => botRecord(id)).filter(candidate => candidate && candidate.name === wanted) + const matches = [...new Set(ids)].map(id => botRecord(id)).filter(candidate => candidate && !retiredBot(candidate) && candidate.name === wanted) if (matches.length !== 1) throw invalid(`No single Bot named "${wanted.slice(0, 80)}" in this group`) return matches[0] } @@ -1240,10 +1241,10 @@ export function install(rt) { return save() } // The responsible Bot is gone: its open tasks fail (they are never silently dropped). - rt.taskBotDeleted = botId => { + rt.taskBotDeleted = (botId, why = 'the responsible Bot was deleted') => { for (const task of Object.values(store())) { if (!ACTIVE.has(task.status) || (task.ownerBotId !== botId && task.workBotId !== botId)) continue - transition(task, 'failed', null, { why: 'the responsible Bot was deleted' }) + transition(task, 'failed', null, { why }) // Deletion is another door a child ends through; its overall card must not stay working. settleOverall(task) } @@ -1264,7 +1265,7 @@ export function install(rt) { const next = typeof route?.ownerBotId === 'string' ? route.ownerBotId : '' if (next === '' || next === task.ownerBotId) return const bot = botRecord(next) - if (bot === undefined || bot.remote !== undefined) return + if (bot === undefined || bot.remote !== undefined || retiredBot(bot)) return task.ownerBotId = next record(task, null, { by: 'routed' }) await save() @@ -1275,7 +1276,9 @@ export function install(rt) { // endpoint's own gate, so the answer and the write cannot disagree. const assertCreate = (body, actor) => { const bot = state.bots?.[String(body?.botId ?? '')] - if (bot === undefined || bot.remote !== undefined) throw forbidden('No such Bot') + // The same gate the endpoint had, plus retirement (#1709): a retired Bot takes no new card, + // whether it arrives from task-create or a pre-flight. + if (bot === undefined || bot.remote !== undefined || retiredBot(bot)) throw forbidden('No such Bot') const roomId = body?.roomId ? String(body.roomId) : undefined if (!mayCreateOn(bot, actor, roomId)) throw forbidden('You cannot assign a task to that Bot') return bot @@ -1436,6 +1439,9 @@ export function install(rt) { if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) requireRequester(task, actor) if (task.status !== 'result-ready') throw stale('Only a result waiting for the requester can be sent back') + // Sending back opens a rework card on the same owner, so a retired Bot would get a + // fresh card through this door (#1709). Refused before any write: no card is born. + if (retiredBot(botRecord(task.ownerBotId))) throw forbidden('No such Bot') const note = oneLine(body.note, 500) if (note === '') throw invalid('Send back needs a note for the Bot') transition(task, 'working', actor, { by: 'sent back' }) diff --git a/core/src/host/tools.js b/core/src/host/tools.js index f59915c71..4713acd52 100644 --- a/core/src/host/tools.js +++ b/core/src/host/tools.js @@ -10,6 +10,7 @@ import { currentParticipants } from './room-membership.js' import { questionReply, replyText } from './parts.js' import { OTHER, settled } from './turn-origin.js' import { cleanName } from './delivery.js' +import { retiredBot } from './store.js' // A run Bot (ephemeral) is not a team member: every tool below has an effect that reaches past // the run Bot's own Session — it messages, stops or edits another Bot, edits a group, writes or @@ -208,10 +209,6 @@ export function install(rt) { const refuse = (message) => { throw new Error(message) } // A run Bot keeps its name (findBot still sees it) but no team tool can address it (#1373). const teamBot = bot => bot !== undefined && bot.remote === undefined && bot.ephemeral === undefined - // Retired (#638): the tree no longer counts it and the default Bot list leaves it out. - // Retirement is terminal, so no team tool addresses it either (#1700): every tool that takes - // a Bot name resolves through findTeamBot, which is where the rule belongs. - const retiredBot = bot => bot?.fields?.['flybot-tree']?.retired === true const findTeamBot = (name) => { const bot = rt.findBot(name) return teamBot(bot) && !retiredBot(bot) ? bot : undefined @@ -405,12 +402,16 @@ export function install(rt) { const target = findTeamBot(args.bot) if (target === undefined) return `No Bot named ${args.bot}. Team: ${teamNames()}` if (target.id === sender.id) return 'You cannot archive yourself.' + let freed try { - await rt.archiveBot(target.id, { reason: args.reason }) + ;({ freed } = await rt.archiveBot(target.id, { reason: args.reason })) } catch (error) { return `Could not archive the Bot: ${error.message}` } - return `Archived ${target.name}. It is out of the tree and the default Bot list; its chats and history stay readable, and it is not deleted.` + // A timer left armed is a Bot that still wakes on its own, so the answer counts them. + const paused = freed?.available === true && freed.paused > 0 ? ` Paused ${freed.paused} task(s) in its own chat.` : '' + const armed = freed?.available === true && freed.armed > 0 ? ` ${freed.armed} task(s) in its own chat are still armed.` : '' + return `Archived ${target.name}. It is out of the tree and the default Bot list; its chats and history stay readable, and it is not deleted.${paused}${armed}` }, })) diff --git a/core/test/retired-bot-paths.test.js b/core/test/retired-bot-paths.test.js new file mode 100644 index 000000000..acaa28a77 --- /dev/null +++ b/core/test/retired-bot-paths.test.js @@ -0,0 +1,475 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +// #1709: retirement (#638) was final only for the tools that resolve a Bot through +// findTeamBot (#1700). Five host paths resolved a Bot by name without that check, so an +// archived Bot was still woken, still edited, still written to, and still kept a timer. +// Every path below is a behaviour red on the base: a delivery, a write, or an armed row. + +const bot = (id, name, createdAt, fields) => ({ + id, sessionId: id, name, role: `${name} role`, model: 'deepseek/v4', instructions: `${name} instructions`, createdAt, capabilities: [], + ...(fields ? { fields: { 'flybot-tree': fields } } : {}), +}) +const team = () => ({ + version: 1, + mainBotIds: ['m1'], + revision: 1, + whaleStored: true, + bots: { + m1: bot('m1', 'Chief', 1, { treeRole: 'root' }), + b2: bot('b2', 'Writer', 2, { parent: 'm1' }), + b3: bot('b3', 'Coder', 3, { parent: 'm1' }), + }, + rooms: { + r1: { + id: 'r1', name: 'Launch', named: true, members: ['m1', 'b2', 'b3'], admin: 'm1', mode: 'everyone', notice: '', createdAt: 4, + // Named group Sessions, so a delivery names the Bot it woke. + sessions: { m1: 's-m1', b2: 's-b2', b3: 's-b3' }, + }, + }, + exchanges: [], + exchangeTurns: {}, + questions: {}, +}) + +// An in-memory stand-in for DSH's schedule service, the same shapes schedules.test.js +// fakes: catalog is what a Bot-wide read and the archive see. +function scheduleService() { + const tasks = new Map() + let next = 1 + const KINDS = { + at: at => ({ kind: 'at', scheduledAt: at }), + every_seconds: seconds => ({ kind: 'every', everySeconds: seconds, scheduledAt: new Date(Date.now() + seconds * 1000).toISOString() }), + daily: ({ time, time_zone }) => ({ kind: 'daily', time, timeZone: time_zone, scheduledAt: '2030-01-01T01:00:00.000Z' }), + weekly: ({ time, time_zone, weekdays }) => ({ kind: 'weekly', time, timeZone: time_zone, weekdays, scheduledAt: '2030-01-01T01:00:00.000Z' }), + cron: ({ expression, time_zone }) => ({ kind: 'cron', expression, timeZone: time_zone, scheduledAt: '2030-01-01T01:00:00.000Z' }), + } + const service = { + tasks, + async create(sessionId, request) { + const id = `schedule-${next++}` + const kind = Object.keys(request).find(key => !['title', 'prompt'].includes(key)) + const record = { id, title: request.title, prompt: request.prompt, timing: request[kind], ...KINDS[kind](request[kind]) } + tasks.set(id, { sessionId, record, status: 'active' }) + return record + }, + async list({ sessionId }) { return [...tasks.values()].filter(task => task.sessionId === sessionId && task.status === 'active').map(task => task.record) }, + async catalog() { return [...tasks.values()].map(task => ({ ...task.record, sessionId: task.sessionId, status: task.status })) }, + async update(request) { + const task = tasks.get(request.id) + if (task?.sessionId !== request.sessionId) return { id: request.id, updated: false, code: 'schedule_not_found' } + task.record = { ...task.record, title: request.title, prompt: request.prompt } + return { id: request.id, updated: true, record: task.record } + }, + async delete({ sessionId, id }) { + if (tasks.get(id)?.sessionId !== sessionId) return { id, deleted: false, code: 'schedule_not_found' } + tasks.delete(id) + return { id, deleted: true } + }, + async getDomain() { + return { table: () => ({ entries: () => tasks.entries(), get: id => tasks.get(id), put: (id, task) => tasks.set(id, task) }) } + }, + serialize: work => work(), + } + return service +} + +// `existingHome` starts a second runtime on a home the first one left: the restart the +// resume path reads its notes from. +function start(existingHome, seedState) { + const home = existingHome ?? mkdtempSync(join(tmpdir(), 'ds-bot-retired-')) + if (existingHome === undefined) writeFileSync(join(home, 'state.json'), JSON.stringify(seedState ?? team())) + const schedule = scheduleService() + const ctx = fakeContext({ services: { schedule } }) + // A delivery lands on the target's Agent; what it records is the proof a retired Bot + // was woken (or, after the fix, that nothing was delivered at all). + const sent = [] + const inboxes = new Map() + const inboxOf = (sessionId) => { + if (!inboxes.has(sessionId)) { + const inbox = { nextStep: [], nextTurn: [], remove() {} } + inboxes.set(sessionId, inbox) + } + return inboxes.get(sessionId) + } + let sessions = 0 + const agent = sessionId => ({ + session: { id: sessionId, seq: 1, requestHeader: () => undefined }, + id: sessionId, + status: 'idle', + followup(message) { sent.push({ sessionId, message, kind: 'followup' }) }, + steer(message) { sent.push({ sessionId, message, kind: 'steer' }) }, + inject(message) { sent.push({ sessionId, message, kind: 'inject' }) }, + cancel() {}, + async whenIdle() {}, + async runMaintenance(fn) { return fn(AbortSignal.timeout(60_000)) }, + inbox: inboxOf(sessionId), + hideBlockingQuestion() {}, + }) + Object.assign(ctx, { + llm: { + listProviders: () => [{ id: 'deepseek', name: 'DeepSeek' }], + listModels: async () => [{ id: 'deepseek-v4-flash' }], + resolveModelInfo: async () => ({}), + }, + sessionController: { + create: async () => ({ sessionId: `gs${++sessions}` }), + resolveAgent: async sessionId => ({ agent: agent(sessionId) }), + selectModel: async () => {}, + page: async () => ({ events: [], records: [], hasMore: false }), + prompt: async () => ({ accepted: true }), + }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {}, messageProjections: [] }, + workspaceRegistry: { + get: () => undefined, + resolveByPath: async () => undefined, + create: async path => ({ id: 'w1', path }), + archiveSession: async () => {}, + }, + }) + let route + const register = ctx.connection.fetch.register + ctx.connection.fetch.register = (options) => { + route = options + return register(options) + } + const rt = createRuntime(ctx, { home }) + const call = async (endpoint, payload) => { + const response = await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) })) + return response.json() + } + const tool = (name, args, sessionId) => ctx.registeredTools.get(name).execute(args, { agent: { id: sessionId } }) + const emit = async (sessionId, event) => { + for (const listener of ctx.listeners.get('session/event') ?? []) await listener({ id: sessionId }, event) + } + const userMessage = text => ({ type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text }] } }) + const relay = (roomId, callId) => ctx.registeredTools.get('group_relay') + .execute({}, { agent: { id: roomId, inbox: inboxOf(roomId) }, callId, signal: new AbortController().signal }) + // The Bot's answer to its group turn: a delivery source plus the reply text, the way DSH + // reports the events of one turn. + const answer = async (sessionId, exchangeId, text, turn) => { + await emit(sessionId, { type: 'turn/start', data: { turn } }) + await emit(sessionId, { type: 'user/message', data: { source: { kind: 'bot', role: 'room', exchangeId }, content: [{ type: 'text', text: 'your turn' }] } }) + await emit(sessionId, { type: 'assistant/message', data: { message: { content: [{ type: 'text', text }] } } }) + await emit(sessionId, { type: 'turn/end', data: { reason: { kind: 'stop' } } }) + } + const saved = () => JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + const memoryFile = botId => join(home, 'memory', 'bots', botId, 'MEMORY.md') + const activeRows = sessionId => [...schedule.tasks.values()].filter(task => task.sessionId === sessionId && task.status === 'active') + const stop = async ({ keepHome = false } = {}) => { + await ctx.dispose() + if (!keepHome) rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + } + return { ctx, rt, schedule, call, tool, emit, userMessage, relay, answer, saved, memoryFile, activeRows, sent, home, stop } +} + +const settle = () => new Promise(resolve => setTimeout(resolve, 25)) +const waitFor = async (check) => { + for (let at = 0; at < 100; at += 1) { + const value = check() + if (value !== undefined) return value + await settle() + } + return undefined +} + +// 1. track_tasks assignee — a card for a retired Bot wakes it through task-drive. +test('#1709 (1): a retired Bot cannot be a task assignee, so no card and no delivery', async (t) => { + const { tool, saved, sent, stop } = start() + t.after(stop) + assert.match(await tool('archive_bot', { bot: 'Writer' }, 'm1'), /^Archived Writer\./) + await tool('track_tasks', { tasks: [{ group: 'Launch', title: 'Ship it', overall: true }] }, 'm1') + const refused = await tool('track_tasks', { tasks: [{ group: 'Launch', title: 'Write docs', parent: 'Ship it', assignee: 'Writer' }] }, 'm1') + assert.match(String(refused), /No single Bot named "Writer"/) + const cards = Object.values(saved().tasks ?? {}) + assert.equal(cards.filter(card => card.ownerBotId === 'b2').length, 0, 'no card was written for the retired Bot') + assert.deepEqual(sent.filter(entry => entry.sessionId === 'b2'), [], 'the retired Bot was not woken') + // The live Bot keeps its path: the same card for Coder is written and it is woken. + const made = await tool('track_tasks', { tasks: [{ group: 'Launch', title: 'Code it', parent: 'Ship it', assignee: 'Coder' }] }, 'm1') + assert.match(String(made), /Code it/) + assert.equal(Object.values(saved().tasks).filter(card => card.ownerBotId === 'b3').length, 1) +}) + +// 2. A group @Name turn — a retired Bot seated in the room still took a turn. +test('#1709 (2): a group @Name does not call a retired Bot, while a live one still answers', async (t) => { + const { emit, userMessage, relay, tool, sent, answer, stop } = start() + t.after(stop) + assert.match(await tool('archive_bot', { bot: 'Writer' }, 'm1'), /^Archived Writer\./) + await emit('r1', userMessage('@Writer ping, @Coder take a look')) + // Every Bot the line called takes its turn; each answer lets the next slot run. + const running = relay('r1', 'round-1') + const woken = [] + for (let at = 0; at < 4; at += 1) { + const entry = await waitFor(() => sent.find(item => !woken.includes(item))) + if (entry === undefined) break + woken.push(entry) + await answer(entry.sessionId, entry.message.source.exchangeId, 'On it.', at + 1) + } + await running + assert.deepEqual(woken.map(entry => entry.sessionId), ['s-b3'], 'the retired Bot takes no turn, while the live one still answers') +}) + +// 3. attach_capability — a retired Bot still gained capabilities. +test('#1709 (3): a retired Bot gains no capability, while a live Bot still does', async (t) => { + const { call, tool, saved, stop } = start() + t.after(stop) + const made = await call('capability-save', { name: 'probe-skill', kind: 'skill', content: 'Do the probe.' }) + assert.equal(made.ok, true, JSON.stringify(made)) + assert.match(await tool('archive_bot', { bot: 'Writer' }, 'm1'), /^Archived Writer\./) + assert.match(String(await tool('attach_capability', { name: 'probe-skill', bot: 'Writer' }, 'm1')), /No Bot named Writer/) + assert.deepEqual(saved().bots.b2.capabilities ?? [], []) + assert.match(await tool('attach_capability', { name: 'probe-skill', bot: 'Coder' }, 'm1'), /Attached probe-skill to Coder/) + assert.deepEqual(saved().bots.b3.capabilities, ['probe-skill']) +}) + +// 4. remember scope: — a retired Bot still received memory writes. +test('#1709 (4): a retired Bot receives no memory write, while a live Bot still saves one', async (t) => { + const { tool, saved, memoryFile, stop } = start() + t.after(stop) + assert.match(await tool('archive_bot', { bot: 'Writer' }, 'm1'), /^Archived Writer\./) + assert.match(await tool('remember', { text: 'Writes in Chinese', scope: 'Writer' }, 'm1'), /^No Bot named Writer/) + assert.equal(existsSync(memoryFile('b2')), false, 'nothing was written into the retired Bot\'s memory') + assert.match(await tool('remember', { text: 'Writes in Chinese', scope: 'Coder' }, 'm1'), /^Saved to Coder's memory/) + assert.match(readFileSync(memoryFile('b3'), 'utf8'), /Writes in Chinese/) + assert.equal(saved().bots.b3.id, 'b3') +}) + +// 5a. schedule-create — a retired target still got a live row bound to its chat Session. +test('#1709 (5a): schedule-create refuses a retired target, while a live one is accepted', async (t) => { + const { call, tool, schedule, activeRows, stop } = start() + t.after(stop) + assert.match(await tool('archive_bot', { bot: 'Writer' }, 'm1'), /^Archived Writer\./) + const refused = await call('schedule-create', { target: 'b2', title: 'Nightly writing', prompt: 'Write', when: { kind: 'daily', time: '09:00', timeZone: 'UTC' } }) + assert.equal(refused.ok, false, JSON.stringify(refused)) + assert.match(refused.error.message, /No such Bot or group chat/) + assert.deepEqual(activeRows('b2'), [], 'no row was armed on the retired Bot\'s chat') + assert.equal(schedule.tasks.size, 0) + const accepted = await call('schedule-create', { target: 'b3', title: 'Nightly coding', prompt: 'Code', when: { kind: 'daily', time: '10:00', timeZone: 'UTC' } }) + assert.equal(accepted.ok, true, JSON.stringify(accepted)) + assert.equal(activeRows('b3').length, 1) +}) + +// 5b. schedule-update — the same door, reached through updateSchedule's contentOf. +test('#1709 (5b): schedule-update refuses to move a task onto a retired Bot', async (t) => { + const { call, tool, activeRows, stop } = start() + t.after(stop) + const made = await call('schedule-create', { target: 'b3', title: 'Nightly coding', prompt: 'Code', when: { kind: 'daily', time: '10:00', timeZone: 'UTC' } }) + assert.equal(made.ok, true, JSON.stringify(made)) + const id = activeRows('b3')[0].record.id + assert.match(await tool('archive_bot', { bot: 'Writer' }, 'm1'), /^Archived Writer\./) + const refused = await call('schedule-update', { sessionId: 'b3', id, target: 'b2', title: 'Moved', prompt: 'Moved', when: { kind: 'keep' } }) + assert.equal(refused.ok, false, JSON.stringify(refused)) + assert.match(refused.error.message, /No such Bot or group chat/) + assert.deepEqual(activeRows('b2'), [], 'no row on the retired Bot') + assert.equal(activeRows('b3').length, 1, 'the live Bot keeps its task') +}) + +// 5c. schedule-copy — the copy target resolves beside contentOf and had no door either. +test('#1709 (5c): schedule-copy refuses a retired target', async (t) => { + const { call, tool, activeRows, stop } = start() + t.after(stop) + const made = await call('schedule-create', { target: 'b3', title: 'Nightly coding', prompt: 'Code', when: { kind: 'daily', time: '10:00', timeZone: 'UTC' } }) + assert.equal(made.ok, true, JSON.stringify(made)) + const id = activeRows('b3')[0].record.id + assert.match(await tool('archive_bot', { bot: 'Writer' }, 'm1'), /^Archived Writer\./) + const refused = await call('schedule-create', { target: 'b2', title: 'Copied', prompt: 'Copied', copyFrom: { sessionId: 'b3', id } }) + assert.equal(refused.ok, false, JSON.stringify(refused)) + assert.match(refused.error.message, /No such Bot or group chat/) + assert.deepEqual(activeRows('b2'), [], 'nothing was copied onto the retired Bot') + assert.equal(activeRows('b3').length, 1) +}) + +// 5d. Part B: retiring disarms the rows already armed on the Bot's own chat Session. +test('#1709 (5d): archiving pauses the active rows of the Bot\'s own chat, and says so', async (t) => { + const { call, tool, schedule, activeRows, saved, stop } = start() + t.after(stop) + for (const [title, time] of [['Morning', '08:00'], ['Evening', '18:00']]) { + const made = await call('schedule-create', { target: 'b3', title, prompt: 'Write', when: { kind: 'daily', time, timeZone: 'UTC' } }) + assert.equal(made.ok, true, JSON.stringify(made)) + } + const other = await call('schedule-create', { target: 'm1', title: 'Chief brief', prompt: 'Brief', when: { kind: 'daily', time: '09:00', timeZone: 'UTC' } }) + assert.equal(other.ok, true, JSON.stringify(other)) + assert.equal(activeRows('b3').length, 2) + + const answer = await tool('archive_bot', { bot: 'Coder' }, 'm1') + assert.match(answer, /^Archived Coder\./) + assert.match(answer, /Paused 2 task\(s\)/, `the result says what it disarmed: ${answer}`) + assert.deepEqual(activeRows('b3'), [], 'no row is left armed on the archived Bot\'s chat') + assert.equal(Object.keys(saved().pausedSchedules ?? {}).length, 2, 'the paused rows are in state.pausedSchedules') + assert.equal(schedule.tasks.size, 1, 'the unrelated live Bot keeps its task') + assert.equal(activeRows('m1').length, 1) + // And the retired Bot cannot take a new one afterwards. + const refused = await call('schedule-create', { target: 'b3', title: 'Back', prompt: 'Back', when: { kind: 'daily', time: '07:00', timeZone: 'UTC' } }) + assert.equal(refused.ok, false, JSON.stringify(refused)) +}) + +// 5e. schedule-resume — pausing a task and then retiring its Bot put the same row back: +// Resume re-creates the held record on the retired Bot's chat Session, so the door Part A +// closes on create/copy/update stayed open one step later (#1709). +test('#1709 (5e): schedule-resume refuses a paused task of a retired Bot', async (t) => { + const { call, tool, schedule, activeRows, saved, stop } = start() + t.after(stop) + const made = await call('schedule-create', { target: 'b2', title: 'Nightly writing', prompt: 'Write', when: { kind: 'daily', time: '09:00', timeZone: 'UTC' } }) + assert.equal(made.ok, true, JSON.stringify(made)) + const id = activeRows('b2')[0].record.id + const held = await call('schedule-pause', { sessionId: 'b2', id }) + assert.equal(held.ok, true, JSON.stringify(held)) + assert.deepEqual(activeRows('b2'), [], 'paused here, before the Bot retires') + assert.match(await tool('archive_bot', { bot: 'Writer' }, 'm1'), /^Archived Writer\./) + + const refused = await call('schedule-resume', { sessionId: 'b2', id }) + assert.equal(refused.ok, false, JSON.stringify(refused)) + assert.match(refused.error.message, /No such Bot or group chat/) + assert.deepEqual(activeRows('b2'), [], 'no row is armed on the retired Bot\'s chat again') + assert.equal(Object.keys(saved().pausedSchedules ?? {}).length, 1, 'the paused record stays where it was') + assert.equal(schedule.tasks.size, 0) +}) + +// 6. task-drive — a card that predates the retirement still woke its Bot on the next drive, +// because the driver resolved the owner without the retired check (#1709). The card is the +// one a host starts with: a board row somebody else left behind. +test('#1709 (6): a card that predates the retirement wakes nobody', async (t) => { + const seeded = team() + seeded.bots.b3.fields['flybot-tree'].retired = true + seeded.bots.b3.hidden = true + seeded.tasks = { + 'task-left-behind': { + id: 'task-left-behind', status: 'open', createdAt: 1, updatedAt: 1, requesterId: 'owner-1', + ownerBotId: 'b3', title: 'Write docs', source: 'conversation', origin: { roomId: 'r1' }, + actors: [{ status: 'open', at: 1 }], + }, + } + const { ctx, sent, saved, stop } = start(undefined, seeded) + t.after(stop) + await ctx.taskDrive.consider() + await ctx.taskDrive.settled() + assert.deepEqual(sent.filter(entry => entry.sessionId === 'b3'), [], 'the retired Bot is not woken') + assert.equal(saved().tasks['task-left-behind'].status, 'open', 'the row is left as it was') +}) + +// 6b. Retiring means to the board what deleting means (#1709): the live cards a Bot holds +// fail instead of waiting for a Bot that will not run again. +test('#1709 (6b): archiving fails the live cards the Bot held', async (t) => { + const { ctx, tool, saved, sent, stop } = start() + t.after(stop) + await tool('track_tasks', { tasks: [{ group: 'Launch', title: 'Ship it', overall: true }] }, 'm1') + assert.match(String(await tool('track_tasks', { tasks: [{ group: 'Launch', title: 'Write docs', parent: 'Ship it', assignee: 'Coder' }] }, 'm1')), /Write docs/) + await ctx.taskDrive.settled() + const card = () => Object.values(saved().tasks ?? {}).find(task => task.ownerBotId === 'b3') + assert.equal(card().status, 'claimed', JSON.stringify(card())) + assert.match(await tool('archive_bot', { bot: 'Coder' }, 'm1'), /^Archived Coder\./) + const after = card() + assert.equal(after.status, 'failed', `the card is failed like a deleted Bot's: ${JSON.stringify(after)}`) + assert.equal(after.actors.at(-1).why, 'the responsible Bot was retired') + const before = sent.filter(entry => entry.sessionId === 'b3').length + await ctx.taskDrive.consider() + await ctx.taskDrive.settled() + assert.equal(sent.filter(entry => entry.sessionId === 'b3').length, before, 'nothing more is delivered') +}) + +// 7. task-create — the client's own way in (client/index.js calls it) accepted a retired +// owner and wrote a card that the drive then delivered. +test('#1709 (7): task-create refuses a retired Bot as the owner, while a live one is accepted', async (t) => { + const { call, tool, saved, stop } = start() + t.after(stop) + assert.match(await tool('archive_bot', { bot: 'Coder' }, 'm1'), /^Archived Coder\./) + const refused = await call('task-create', { botId: 'b3', title: 'Write docs' }) + assert.equal(refused.ok, false, JSON.stringify(refused)) + assert.equal(Object.values(saved().tasks ?? {}).filter(task => task.ownerBotId === 'b3').length, 0, 'no card was written for the retired Bot') + const accepted = await call('task-create', { botId: 'b2', title: 'Write docs' }) + assert.equal(accepted.ok, true, JSON.stringify(accepted)) +}) + +// 8. routeOpenTask — a plugin's route could hand an open card to a retired Bot. +test('#1709 (8): a plugin route cannot put an open card on a retired Bot', async (t) => { + const { rt, call, tool, saved, stop } = start() + t.after(stop) + assert.match(await tool('archive_bot', { bot: 'Coder' }, 'm1'), /^Archived Coder\./) + rt.onTaskWrite = async () => ({ ownerBotId: 'b3' }) + const made = await call('task-create', { botId: 'm1', title: 'Routed card' }) + assert.equal(made.ok, true, JSON.stringify(made)) + assert.equal(saved().tasks[made.value.id].ownerBotId, 'm1', 'the retired Bot does not take the card') + // A live Bot still takes the same route. + rt.onTaskWrite = async () => ({ ownerBotId: 'b2' }) + const second = await call('task-create', { botId: 'm1', title: 'Second card' }) + assert.equal(second.ok, true, JSON.stringify(second)) + assert.equal(saved().tasks[second.value.id].ownerBotId, 'b2') +}) + +// 9. openRework — sending a result back opened a second card on the same retired owner. +test('#1709 (9): sending work back opens no rework card on a retired Bot', async (t) => { + const { call, tool, saved, stop } = start() + t.after(stop) + const created = await call('task-create', { botId: 'b3', title: 'Write docs' }) + assert.equal(created.ok, true, JSON.stringify(created)) + const id = created.value.id + for (const endpoint of ['task-accept', 'task-start']) { + const moved = await call(endpoint, { id }) + assert.equal(moved.ok, true, `${endpoint}: ${JSON.stringify(moved)}`) + } + const returned = await call('task-return', { id, summary: 'Draft is ready' }) + assert.equal(returned.ok, true, JSON.stringify(returned)) + assert.equal(returned.value.status, 'result-ready') + // A returned result stays for the requester even after retirement: that is the card a + // send-back would reopen work from. + assert.match(await tool('archive_bot', { bot: 'Coder' }, 'm1'), /^Archived Coder\./) + assert.equal(Object.values(saved().tasks).find(task => task.id === id).status, 'result-ready') + const refused = await call('task-send-back', { id, note: 'Please tighten it' }) + assert.equal(refused.ok, false, JSON.stringify(refused)) + assert.deepEqual(Object.values(saved().tasks).filter(task => task.reworkOf === id), [], 'no rework card was born on the retired Bot') +}) + +// 10. hooks — a held send still verified only that the target exists, so approving it +// delivered to a Bot that had been retired while it waited. +test('#1709 (10): a held send to a retired Bot goes stale instead of being delivered', async (t) => { + const { rt, call, tool, sent, stop } = start() + t.after(stop) + const flybot = rt.flybot + flybot.bot.send.use({ name: 'gate', handle: async msg => (msg.meta?.hold ? { action: 'hold', reason: 'held by test' } : undefined) }) + const held = await flybot.bot.send({ from: 'm1', to: 'b3', text: 'please take this', meta: { hold: true } }) + assert.equal(held.action, 'hold', JSON.stringify(held)) + assert.match(await tool('archive_bot', { bot: 'Coder' }, 'm1'), /^Archived Coder\./) + const decided = await call('flybot/decide', { id: held.approvalId, decision: 'approve' }) + assert.equal(decided.ok, true, JSON.stringify(decided)) + assert.equal(decided.value.status, 'stale', `the hold is stale: ${JSON.stringify(decided.value)}`) + assert.match(String(decided.value.error ?? ''), /retired/) + assert.deepEqual(sent.filter(entry => entry.sessionId === 'b3'), [], 'nothing was delivered to the retired Bot') +}) + +// 11. resume — a restart picked the interrupted turn of a retired Bot up again, which is a +// delivery to a Bot that no longer runs. +test('#1709 (11b): a restart does not send again what was queued for a retired Bot', async (t) => { + const first = start() + const home = first.home + assert.match(await first.tool('archive_bot', { bot: 'Coder' }, 'm1'), /^Archived Coder\./) + await first.stop({ keepHome: true }) + // A delivery noted before the Bot was retired: the note is what the next start would send. + writeFileSync(join(home, 'inflight.json'), JSON.stringify({ + turns: {}, + queued: { b3: [{ at: Date.now(), args: { fromId: 'm1', toId: 'b3', role: 'request', text: 'queued before retirement', exchangeId: 'exchange-retired' } }] }, + })) + const second = start(home) + t.after(second.stop) + await new Promise(resolve => setTimeout(resolve, 3_600)) + assert.deepEqual(second.sent.filter(entry => entry.sessionId === 'b3'), [], 'nothing queued is delivered to the retired Bot') +}) + +test('#1709 (11): a restart does not pick up a retired Bot\'s turn', async (t) => { + const first = start() + const home = first.home + assert.match(await first.tool('archive_bot', { bot: 'Coder' }, 'm1'), /^Archived Coder\./) + await first.stop({ keepHome: true }) + // What the last run left behind: one turn noted for the retired Bot's Session. + writeFileSync(join(home, 'inflight.json'), JSON.stringify({ turns: { b3: { botId: 'b3', since: 1 } }, queued: {} })) + const second = start(home) + t.after(second.stop) + await new Promise(resolve => setTimeout(resolve, 3_600)) + assert.deepEqual(second.sent.filter(entry => entry.sessionId === 'b3'), [], 'the retired Bot is not woken by a restart') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 911f897fe..6f085524e 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -214,7 +214,7 @@ An admin's `@` used to be the room's control plane: every `@Dev #1234 rebase and - `list_bots` (`src/host/tools.js`) tags a Bot with `N to approve` while one of its outbound sends is held for the owner's decision (#587): a count of that Bot's pending held sends (`state.flybot.approvals`), never the held message's text, so a coordinator reading the roster sees who is blocked and the tag clears once the owner decides. Covered by the roster case in `core/test/flybot.test.js`. - The Main Bot list is deployment-wide (#613): `set-main` requires the actor to own `body.id` and, when `body.replace` names the Main Bot whose place the target takes, that Bot too, so a member cannot swap another owner's Main Bot for their own or demote it (both are `bot/forbidden`); `roster.setMain` keeps the same invariant for every caller, refusing a `replace` across owners and leaving `state.mainBotIds` unchanged. A swap inside one owner's team, the plain promotion of a Bot the caller owns, and the deployment-wide list itself are unchanged. -- **A Main Bot retires a Bot; it never deletes one (#638).** `archive_bot` (`tools.js`, Main Bot only) calls `roster.archiveBot`: it writes `fields['flybot-tree'].retired = true` through `rt.setBotField` — the same path and `change` event a plugin's `fields.set` uses, so the tree and the sidebar refresh — and sets `bot.hidden = true`, the flag the tree's own archive uses to keep a Bot out of the default list while Search still finds it (#452). The record, its chat, parts and memory stay where they are, so its history stays readable; retirement has no un-retire path, and the restore entries in the sidebar and the palette only clear `hidden` (`core/src/client/new-chat.js:104`, `core/src/client/palette.js:60`), which opens the chat again but leaves the record retired. The tree's `forestOf` skips a retired record, so its slot under `maxChildren` is free again and adoption leaves it alone. A Main Bot, the caller itself and an unknown name are refused, nothing reparents, and `list_bots` leaves a retired Bot out (the record is still found by name). Retirement is terminal (#1700): the tools that resolve a Bot through `findTeamBot` — `message_bot`, `stop_bot`, `update_bot`, `create_group`, `update_group` (`add_members`, `admin`, `task_lead`) and `archive_bot` — refuse a retired record exactly as they refuse an unknown name, so `message_bot` sends nothing and starts no turn, and `archive_bot` aimed at an already-retired name answers the same "No Bot named …" line. Other host paths that resolve a Bot by name — task assignee, group `@Name`, capability attach, memory scope — are outside this change and tracked in [#1709](https://github.com/ThinkFlowLab/FlyBot/issues/1709). Deleting remains the UI's `delete-bot` call (`roster.deleteBot`), which archives the Bot's memory first. Covered by `core/test/archive-bot.test.js`. +- **A Main Bot retires a Bot; it never deletes one (#638).** `archive_bot` (`tools.js`, Main Bot only) calls `roster.archiveBot`: it writes `fields['flybot-tree'].retired = true` through `rt.setBotField` — the same path and `change` event a plugin's `fields.set` uses, so the tree and the sidebar refresh — and sets `bot.hidden = true`, the flag the tree's own archive uses to keep a Bot out of the default list while Search still finds it (#452). The record, its chat, parts and memory stay where they are, so its history stays readable; retirement has no un-retire path, and the restore entries in the sidebar and the palette only clear `hidden` (`core/src/client/new-chat.js:104`, `core/src/client/palette.js:60`), which opens the chat again but leaves the record retired. The tree's `forestOf` skips a retired record, so its slot under `maxChildren` is free again and adoption leaves it alone. A Main Bot, the caller itself and an unknown name are refused, nothing reparents, and `list_bots` leaves a retired Bot out (the record is still found by name). Retirement is terminal (#1700): the tools that resolve a Bot through `findTeamBot` — `message_bot`, `stop_bot`, `update_bot`, `create_group`, `update_group` (`add_members`, `admin`, `task_lead`) and `archive_bot` — refuse a retired record exactly as they refuse an unknown name, so `message_bot` sends nothing and starts no turn, and `archive_bot` aimed at an already-retired name answers the same "No Bot named …" line. Since [#1709](https://github.com/ThinkFlowLab/FlyBot/issues/1709) the same `retiredBot` predicate (`store.js`, one copy, imported by every caller) is the one check the host paths outside `findTeamBot` apply, and each of them is listed here: `track_tasks`' assignee and a group `@Name` resolve through it, so no card is written and no turn starts (no delivery, no Session); `attach_capability`/`detach_capability` and a `remember`/`recall` `scope:` treat a retired record exactly as an unknown name; `schedule-create`/`schedule-update`/`schedule-copy` refuse it with the same `No such Bot or group chat` answer, and `schedule-resume` refuses a paused row of a retired Bot the same way; `task-create` (the endpoint the client calls), a plugin's `routeOpenTask` and `task-send-back` (which opens a rework card on the same owner) refuse it with `No such Bot`; a send held for approval whose target has been retired meanwhile is stale (`the target Bot was retired`) instead of delivered; and a restart neither picks up a retired Bot's interrupted turn nor sends again what was queued for it. The task drive never wakes a retired owner: `drivingTasks` returns nothing for it, so a card still on the board is not delivered, and a card that arrives through none of these doors is left exactly where it is. Archiving also disarms the target's own chat Session: `rt.pauseBotSchedules` (installed by `schedules.js`) writes each active row into `state.pausedSchedules` before DSH drops it, the way pausing one task does, and `archive_bot`'s answer says `Paused N task(s)`—or how many are still armed—instead of staying silent. Retiring means to the board what deleting means: `archiveBot` calls `rt.taskBotDeleted` with `the responsible Bot was retired`, so the cards a retired Bot still holds in `open`, `claimed`, `working` or `blocked` fail instead of waiting for a Bot that will not run again, while a card in `result-ready` stays for its requester to accept or cancel. Still not covered: an un-retire path (there is none), the memory and chat the record keeps, and the connectors a retired Bot's own Session no longer starts. Deleting remains the UI's `delete-bot` call (`roster.deleteBot`), which archives the Bot's memory first. Covered by `core/test/archive-bot.test.js` and `core/test/retired-bot-paths.test.js`. - A deleted Bot's Sessions keep their owner (#1453): `state.retiredSessions` maps a Session id to the Bot id that owned it, written by `roster.deleteBot` for the own chat and its parts and for each group Session and its parts before the Bot leaves the state, so `indexSessions` can put them in `pastOwners` again on the next rebuild. The record only fills what the live indexes do not claim — a live Bot or room holding the same id wins — and it only grows, one entry per Session of each Bot ever deleted, with no pruning in this change. `selfOf` then names a Bot that no longer exists, so `connector-access.js` `whoseAgent` answers `{ unknown: true }` and every connector is refused with `the Bot this Agent works for could not be determined`, in the process that deleted the Bot and after a reload, because the team file carries the record. The owner's own Session, an id no Bot ever had and a call with no Agent keep the connectors. The schedule tools are not covered by this change: they carry no connector id and are refused or allowed before that branch, and widening them is the #1237 decision. - Context sharing (#53) is specified in [team](team.md), **Context sharing foundation**: the own-chat brief is share-gated (`share-brief` / `briefShared`), a memory review reads only its owner as the user (`isUserLine`) and labels another human as `peer` (not `bot`), and keeps cross-owner entries out of the team scope (`teamAllowedFrom`), `list_bots` lists only the caller's owner's Bots (a granted child: that child's `createdBy` subtree), Bots granted to the same owner, and Bots in the current room — another owner's folder and instructions stay private — and a `dsh-session:` chip in actor-authored `send` / `room-post` / `answer-question` / `schedule-create` text must resolve for the author (`sessionOwnedBy`; `schedule-create` authz keys off `body.target`). Bot-authored group posts and Bot-to-Bot / own-chat followups drop chips so they cannot tunnel a private chat into another Session. Host modules: `src/host/groups.js`, `src/host/memory.js`, `src/host/tools.js`, `src/host/channel.js`, `src/host/room-membership.js`, `src/host/authz.js`. Team mode stays disabled. diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index bf11cd72c..f025edd94 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -59,8 +59,8 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 7. `cleanRemote` is the record boundary for a friend's granted Bot: `name`, `homeName` and `owner` strip `@` (so they cannot look like a mention of another Bot in `nameMentions` chips/previews) and control characters/newlines, then cap at 60 characters. Handles stay on the `handle` field. Optional look fields (`color`, `avatar` object, `avatarUpdatedAt`) pass through when present so remote chats, group lists, and headers can render a peer's chosen look; missing look stays handle-seeded. Those labels then flow into delivery `fromName` (still `cleanName`'d and the body fenced) and into `list_bots` friends lines via the remote-chat roster. Group room logs use local team names, not peer labels. 8. A user's @ of a friend's Bot in a group is answered in that group (#825): the line reaches the Bot as a room line and its reply comes back as one, which is why `mentionReceipt` (`hooks.js`, relay role `mention`) posts only what went wrong — why the send was denied, held, or failed — and no "sent" line at all. The receipt's role stays outside the room round trigger (`groups.js` `opens`), so it never opens a round; peer names go through `cleanName`. A human line that names a friend's Bot already in that room is **not** 1:1-relayed as the group's admin (Chief): the room message is the delivery, so Chief's missing grant does not block it (#143). A Chief-originated `bot.send` to an ungranted friend Bot is still deny. A remaining grant deny on a mention that is *not* in the room says to invite the Bot or add it to the group. 9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` (`fn(room, line, { mentions })`: for the owner's typed line, the handles its `@` names, #293) / `postNotice` / `postHuman` (quiet; it reads the friend's line for `@` of a local Bot by label or handle and passes `callIds`, so only those Bots answer, #287; an optional `mentions` handle list from an attested line is resolved first, #293; `attested: true`, passed only for a line the server stamps `typed_by_owner`, lets a plain or `@all` line start this home's Bots in one capped pass, #295; a plain line is answered by the Bots that judge it theirs, `@all` is not routed, #299) / `postRemote`; `postHuman` draws another member's typed line as a chat bubble from that person, #180). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send — not Open/Start/Leave together. Slot `flybot.room.members` adds rows at the foot of a local group's Members card (not a face or shared room), with `host(endpoint, payload)` for core endpoints (#876). Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (after New group chat; `label` / `hint` go through the same `t()` map). Still not team mode. `upsertFace` also takes `lead` (`{ handle, picked, canPick, supported }`, #476) and a member `role` label. -10. Held sends are owner-bound (#52): a hold stamps server-derived provenance on the record — `requestId`, a sha256 `version` over the request content, `origin` (`via`/`hop`/audience) and the sender Bot's `ownerId`; message claims (`meta`, text) never confer authority. `flybot/decide` passes authz for any signed-in actor and hooks binds the decision to the sender Bot's owner's session; `release` revalidates and marks records `stale` (changed request, deleted sender/target Bot, changed owner — `deleteBot` invalidates eagerly; a demoted Chief's sender-side holds are invalidated eagerly on `setMain`, not inside `staleReason`). `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. The pending list is filtered per actor under member enforcement. The default approval card shows `senderName`/`targetName` and a delegated marker (`Approval.origin`). -11. Task lifecycle (#54): `bot.send` stamps `meta.taskId` from the working task (`rt.taskIdForSend`); a held send then calls `rt.taskHeld` (blocks **that** task). The owner's `flybot/decide` calls `rt.taskDecided` (approve resumes, reject cancels); `deleteBot` of a local Bot calls `rt.taskBotDeleted` (open tasks fail). Incoming holds stay the owner's. The state machine lives in `tasks.js`; hooks only fire those three seams. Clarification and `workBotId` wait on #55. `flybot.tasks.onWrite(fn)` (#1318) hears the saved view after `task-create` and `task-update`. The first object returned may name `ownerBotId`. The host applies it when it is a different local Bot, records the move, and saves again without calling listeners. A throw is logged and the next listener still runs. Starting a workflow run stays inside the plugin; this seam only moves the card's owner. `flybot.tasks.onProjectProvider(fn)` (#1696) is the read side a plugin exposes for the Project board: the host calls it with `{ hasTask }` and reads back `{ taskId, runId, nodeId? }` refs through `rt.projectRunRefs({ hasTask })`, so core never reads `workflows.json`; a provider that throws, or names a `taskId` the core task record no longer has, is skipped instead of failing the read. +10. Held sends are owner-bound (#52): a hold stamps server-derived provenance on the record — `requestId`, a sha256 `version` over the request content, `origin` (`via`/`hop`/audience) and the sender Bot's `ownerId`; message claims (`meta`, text) never confer authority. `flybot/decide` passes authz for any signed-in actor and hooks binds the decision to the sender Bot's owner's session; `release` revalidates and marks records `stale` (changed request, deleted sender/target Bot, a target Bot retired while the hold waited — `the target Bot was retired` — changed owner — `deleteBot` invalidates eagerly; a demoted Chief's sender-side holds are invalidated eagerly on `setMain`, not inside `staleReason`). `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. The pending list is filtered per actor under member enforcement. The default approval card shows `senderName`/`targetName` and a delegated marker (`Approval.origin`). +11. Task lifecycle (#54): `bot.send` stamps `meta.taskId` from the working task (`rt.taskIdForSend`); a held send then calls `rt.taskHeld` (blocks **that** task). The owner's `flybot/decide` calls `rt.taskDecided` (approve resumes, reject cancels); `deleteBot` of a local Bot calls `rt.taskBotDeleted` (open tasks fail), and so does `archive_bot` with `the responsible Bot was retired`, because retirement means what deletion does to the board; `task-create`, a plugin's `routeOpenTask` and `task-send-back` refuse a retired owner with `No such Bot`, since the last one opens a rework card on that same owner. Incoming holds stay the owner's. The state machine lives in `tasks.js`; hooks only fire those three seams. Clarification and `workBotId` wait on #55. `flybot.tasks.onWrite(fn)` (#1318) hears the saved view after `task-create` and `task-update`. The first object returned may name `ownerBotId`. The host applies it when it is a different local Bot, records the move, and saves again without calling listeners. A throw is logged and the next listener still runs. Starting a workflow run stays inside the plugin; this seam only moves the card's owner. `flybot.tasks.onProjectProvider(fn)` (#1696) is the read side a plugin exposes for the Project board: the host calls it with `{ hasTask }` and reads back `{ taskId, runId, nodeId? }` refs through `rt.projectRunRefs({ hasTask })`, so core never reads `workflows.json`; a provider that throws, or names a `taskId` the core task record no longer has, is skipped instead of failing the read. 12. Activity feed (#241): plugins that want to see where team time goes subscribe through `flybot.on(...)` and never to raw DSH `session/event` (the maintainer decision on #241). Events: `turn.start` / `turn.end` (a Bot's own chat `place: 'chat'`, its hidden group Session `'group'`, the group's own Session `'room'`, whose turn is one round, or a friend's Bot chat `'remote'` (#248: its Session runs no model and only carries lines to and from the friend, so those turns are transport, not a team Bot's work; `remote: {handle, name}` names the friend as the sidebar does (`Helper · Bob's Bot`, `Bob's Chief`; a `{kind: 'remote'}` party carries the same `name` when the roster knows it), and the friend's line into the chat is an `exchange.delivered` with `role: 'remote'`)), `tool.start` / `tool.end` (paired by `callId`, closed `interrupted` when their turn ends first) and `exchange.delivered` (a message arrived in a Session's log: `exchangeId` as in `state.exchanges`, `replyTo` of the answer, `from` a user / Bot / group / friend's Bot). The feed is metadata only: message text, tool arguments, tool output and model output never leave `flybot-feed.js`, so a consumer that stores it cannot persist a secret. Strings from outside the team (tool names, remote handles) go through `cleanLabel` (which also removes `untrusted` fence markers when spaced out or split), error outcomes carry an upper-case code (`RATE_LIMIT`, else `UNKNOWN`) and no message. `turn.end` carries `model` (`provider/model`, cleaned) when the turn made a model call (#366): the model of the Session's latest `request/header` (DSH logs one only when it changes, so the last one seen applies; before the feed saw one, the Session's own `requestHeader()`). A turn with no step, and every `'remote'` turn, has none; `turn.start` never has one, since the model is known only once the first request goes out. Delivered means "in the recipient's log", not read; the feed cannot see read. Host nudges (retry, resume, deferred and re-sent deliveries) are not deliveries. Not replayed: a listener hears events after it subscribes. Listeners get copies and a throwing one cannot reach the Session or the other listeners. Events fire in a read-only host too, since they only observe. A friend's Bot in a feed entry is named as the sidebar names it, `Helper (Bob)` (`remoteTitle` → `remoteBotTitle`, #384); the approval overlay's recipient uses the same name. One event is not folded from a Session: `decision` (#396) is fired directly by `decisions.js` through `rt.fireFeed` when the host enables `config.decisions`, carrying `{ botId, option, choice, why?, time }` for one considered-and-rejected choice. Every new local Bot is announced with `flybot.on('bot.created', …)`: the first Main Bot onboarding creates (on the first `state` request) and a `duplicate-bot` copy included, so a plugin that adopts new Bots (the tree root) never misses one; `bot.removed` fires after a Bot is deleted. ### Run Bots (#1373) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index b13b7c1f9..cff560c32 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -638,7 +638,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js // Shared 'Main Bot': '主 Bot', Working: '工作中', -@@ -12,6 +12,67 @@ +@@ -12,6 +12,67 @@ const ZH = { 'Unread activity': '有未读', Search: '搜索', 'New chat': '新对话', @@ -706,7 +706,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'New…': '新建', 'Animation': '动画', 'Bot animation': 'Bot 动画', -@@ -26,6 +26,21 @@ +@@ -26,6 +26,21 @@ const ZH = { Duplicate: '复制一份', Hidden: '已隐藏', Admin: '管理员', @@ -728,7 +728,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Add: '添加', Close: '关闭', Copy: '复制', -@@ -41,22 +41,73 @@ +@@ -41,22 +41,73 @@ const ZH = { ' and ': '、', and: '和', '{count} members': '{count} 位成员', @@ -805,7 +805,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Connectors: '连接器', 'Search connectors': '搜索连接器', 'No connectors': '暂无连接器', -@@ -93,6 +93,59 @@ +@@ -93,6 +93,59 @@ const ZH = { 'Connecting…': '正在连接…', 'Could not connect: {error}': '连接失败:{error}', 'Connected as {account}': '已连接:{account}', @@ -865,7 +865,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Connected: '已连接', Disconnect: '断开', Connect: '连接', -@@ -105,17 +105,64 @@ +@@ -105,17 +105,64 @@ const ZH = { 'GitHub addresses must start with https://': 'GitHub 地址必须以 https:// 开头', 'This DSH cannot load connectors': '这个 DSH 不能加载连接器', 'No such connector': '没有这个连接器', @@ -934,7 +934,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js // Header 'Message {name}': '给 {name} 发消息', -@@ -128,8 +128,6 @@ +@@ -128,8 +128,6 @@ const ZH = { New: '新', // New chat @@ -943,7 +943,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Add to group chat': '加入群聊', 'Message Bot': '给 Bot 发消息', 'To:': '发给:', -@@ -154,9 +154,24 @@ +@@ -154,9 +154,24 @@ const ZH = { Everyone: '所有人', 'Every member answers in turn, admin first.': '每位成员轮流回答,管理员先说。', 'Members · {count}': '成员 · {count}', @@ -968,7 +968,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Remove {name} from the group': '把 {name} 移出群聊', 'Remove from group': '移出群聊', 'Add member': '添加成员', -@@ -172,6 +172,16 @@ +@@ -172,6 +172,16 @@ const ZH = { 'Close details': '关闭详情', 'Copied conversation': '已复制对话', 'Share conversation': '分享对话', @@ -985,7 +985,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Copy conversation as Markdown': '把对话复制为 Markdown', 'This conversation is not part of your Bot team.': '这个对话不属于你的 Bot 团队。', 'Admin {name}': '管理员 {name}', -@@ -215,6 +215,25 @@ +@@ -215,6 +215,25 @@ const ZH = { 'This model is not available now. Pick another one.': '这个模型现在不可用,请换一个。', 'Every message of this Bot runs on it.': '这个 Bot 的每条消息都用它。', 'Applies from the next message.': '从下一条消息开始生效。', @@ -1011,7 +1011,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Profile: '资料', Label: '标签', 'Optional. Shown under the name.': '可选,显示在名字下方。', -@@ -238,6 +238,7 @@ +@@ -238,6 +238,7 @@ const ZH = { 'Kept above the other chats.': '排在其他对话上面。', 'Search still finds it.': '仍然可以搜索到。', Manage: '管理', @@ -1019,7 +1019,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js // Group chat settings 'Leave it empty to name the group after its members.': '留空就按成员命名。', -@@ -274,6 +274,8 @@ +@@ -274,6 +274,8 @@ const ZH = { 'Harness default': 'Harness 默认', Saving: '保存中', 'Manage models': '管理模型', @@ -1028,7 +1028,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js // Shapes Whale: '鲸鱼', Star: '星星', Moon: '月亮', Wave: '声波', Squircle: '圆角方', Bull: '牛角', Blossom: '花朵', -@@ -312,9 +312,10 @@ +@@ -312,9 +312,10 @@ const ZH = { 'No group chats yet.': '还没有群聊。', 'A group chat lets several Bots work on one thing with you.': '在群聊里,几个 Bot 和你一起做一件事。', 'Back to group chats': '返回群聊列表', @@ -1041,7 +1041,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Harness settings': 'Harness 设置', 'Back to Bots': '返回 Bot 列表', 'Close settings': '关闭设置', -@@ -324,7 +324,6 @@ +@@ -324,7 +324,6 @@ const ZH = { Pin: '置顶', Pinned: '已置顶', 'Mark as Unread': '标为未读', @@ -1049,7 +1049,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Group settings': '群设置', 'Rename group': '重命名群聊', 'Copy conversation ID': '复制对话 ID', -@@ -333,6 +333,8 @@ +@@ -333,6 +333,8 @@ const ZH = { // The DSH Agent's own row and Session list. "DSH Agent" is a product name and // stays in English. 'Add DSH Agent': '添加 DSH Agent', @@ -1058,7 +1058,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Remove DSH Agent': '移除 DSH Agent', 'New session': '新会话', Archive: '归档', -@@ -344,13 +344,13 @@ +@@ -344,13 +344,13 @@ const ZH = { '{n} d': '{n} 天', // Plugin updates @@ -1078,7 +1078,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'You have {version}.': '当前版本 {version}。', 'The update did not finish: {reason}': '更新没有完成:{reason}', "What's new": '更新说明', -@@ -360,9 +360,75 @@ +@@ -360,9 +360,75 @@ const ZH = { 'DSH update': 'DSH 更新', 'DSH {version}': 'DSH {version}', 'Restart DSH to start using DSH {version}.': '重启 DSH 后开始使用 DSH {version}。', @@ -1155,7 +1155,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js // Command palette Messages: '消息', Commands: '命令', -@@ -390,10 +390,13 @@ +@@ -390,10 +390,13 @@ const ZH = { Reply: '回复', 'More message actions': '更多操作', More: '更多', @@ -1169,7 +1169,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Earlier messages': '更早的消息', 'Loading earlier messages…': '正在加载更早的消息…', 'Couldn’t load earlier messages · Retry': '更早的消息没加载出来 · 重试', -@@ -403,6 +403,9 @@ +@@ -403,6 +403,9 @@ const ZH = { 'Replies from': '群聊回复:', 'Reply from': '收到回复:', 'Message from': '收到消息:', @@ -1179,7 +1179,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Stopped by': '叫停者:', '{count} messages with': '{count} 条消息,来自', 'Routine started': '例行任务开始', -@@ -412,6 +412,8 @@ +@@ -412,6 +412,8 @@ const ZH = { 'Working out a plan': '理一下思路', 'Sorting out the details': '梳理细节', 'On it': '在做了', @@ -1188,7 +1188,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Busy with it': '正在处理', 'Moving ahead': '推进中', 'Taking care of it': '处理中', -@@ -439,7 +439,6 @@ +@@ -439,7 +439,6 @@ const ZH = { 'Looking back through the chat': '翻看聊天记录', '{name} looked up its own chat': '{name} 查看了自己的对话', '{name} looked up its own chat for “{text}”': '{name} 在自己的对话里查找「{text}」', @@ -1196,7 +1196,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js "{name} didn't answer in time": '{name} 没有及时回应', '{name} paused for your approval': '{name} 停下来等你批准', '{name} started on it': '{name} 开始做了', -@@ -450,12 +450,31 @@ +@@ -450,12 +450,31 @@ const ZH = { 'Asked to make the tool call it wrote as text': '工具调用被写成了文字,已让它重新调用', 'A Bot': '一个 Bot', '{name} is waiting for your approval': '{name} 等你批准', @@ -1229,7 +1229,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js "Couldn't message {name}": '没能给 {name} 发消息', Messaged: '已发消息给', "Couldn't stop {name}": '没能叫停 {name}', -@@ -478,6 +478,7 @@ +@@ -478,6 +478,7 @@ const ZH = { Selected: '已选', Skip: '跳过', Submit: '提交', @@ -1237,7 +1237,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Choose all that apply': '可多选', 'Dismiss question': '关闭问题', Dismiss: '关闭', -@@ -485,7 +485,7 @@ +@@ -485,7 +485,7 @@ const ZH = { 'Your answer': '你的回答', // Secrets @@ -1246,7 +1246,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'No Bot': '没有 Bot', 'Paste or type the key': '粘贴或输入密钥', 'Only {name}': '只给 {name}', -@@ -501,7 +501,6 @@ +@@ -501,7 +501,6 @@ const ZH = { 'At least {count} characters.': '至少 {count} 个字符。', 'Replace value': '替换值', 'Saving…': '保存中…', @@ -1254,7 +1254,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Allowing…': '允许中…', Allow: '允许', '{bot} asked for {name}': '{bot} 请求了 {name}', -@@ -528,7 +528,7 @@ +@@ -528,7 +528,7 @@ const ZH = { 'NAME, e.g. GITHUB_TOKEN': '名字,例如 GITHUB_TOKEN', 'What it is for': '用途', Value: '值', @@ -1263,7 +1263,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Save key': '保存密钥', 'Keys your Bots asked for. Bots never see the values: they use them as ': '你的 Bot 请求过的密钥。Bot 永远看不到值:它们在 shell 里通过 ', ' in their shell, and every value is replaced by ': ' 使用,而且发给模型之前,每个值都会被替换成 ', -@@ -568,7 +568,6 @@ +@@ -568,7 +568,6 @@ const ZH = { Remove: '删除', 'Ask or update': '询问或更新', "Ask about or update {name}'s memory": '询问或更新 {name} 的记忆', @@ -1271,7 +1271,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Added: {text}': '已添加:{text}', 'Added to team memory: {text}': '已加入团队记忆:{text}', 'Updated: {text}': '已更新:{text}', -@@ -697,11 +697,11 @@ +@@ -697,11 +697,11 @@ const ZH = { 'Compared with the {count} days before': '与再往前 {count} 天相比', '{share}% of all usage from {span}': '占 {span} 全部用量的 {share}%', // About @@ -1286,7 +1286,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Updates: '更新', 'Check for updates': '检查更新', 'Checking…': '正在检查…', -@@ -711,6 +711,13 @@ +@@ -711,6 +711,13 @@ const ZH = { 'Update checks are off.': '已关闭更新检查。', 'Installed from a local folder; update it there.': '从本地文件夹安装,请在那里更新。', 'Updates are not offered for this install.': '这种安装方式不提供更新。', @@ -1300,7 +1300,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Links: '链接', 'Source code': '源代码', 'Release notes': '发布说明', -@@ -726,7 +726,7 @@ +@@ -726,7 +726,7 @@ const ZH = { Show: '查看', Hide: '收起', 'GitHub issue': 'GitHub issue', @@ -1309,7 +1309,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Report on GitHub': '在 GitHub 上报告', 'Copied the diagnostics.': '已复制诊断信息。', 'Could not copy. Select the text below and copy it.': '无法复制。请选中下面的文字手动复制。', -@@ -738,7 +738,7 @@ +@@ -738,7 +738,7 @@ const ZH = { 'Recent errors': '最近的错误', 'Recent errors · {count}': '最近的错误 · {count}', 'No errors recorded.': '没有错误记录。', @@ -1318,7 +1318,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Model call': '模型调用', 'This window': '这个窗口', 'Error log': '错误日志', -@@ -771,12 +771,21 @@ +@@ -771,12 +771,21 @@ const ZH = { 'Add at least one more Bot': '再至少添加一个 Bot', Members: '成员', // Connectors and scheduled tasks @@ -1340,7 +1340,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Once, {time}': '一次,{time}', 'Every day at {time}': '每天 {time}', 'Every {days} at {time}': '每{days} {time}', -@@ -784,7 +784,21 @@ +@@ -784,7 +784,21 @@ const ZH = { 'Every weekend at {time}': '每个周末 {time}', 'On the schedule {expression}': '按计划 {expression}', 'Next: {time}': '下次 {time}', @@ -1362,7 +1362,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'in {name}': '在 {name}', 'Click again to delete': '再点一次删除', 'Delete task': '删除任务', -@@ -794,6 +794,7 @@ +@@ -794,6 +794,7 @@ const ZH = { minutes: '分钟', hours: '小时', days: '天', @@ -1370,7 +1370,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'Morning briefing': '早间简报', 'What to do': '要做什么', 'Sum up what changed since yesterday and what needs me today.': '总结一下昨天以来的变化,以及今天需要我处理的事。', -@@ -806,9 +806,20 @@ +@@ -806,9 +806,20 @@ const ZH = { Unit: '单位', 'Create task': '创建任务', 'Turn on Automation tasks in DSH': '在 DSH 里打开「自动化任务」', @@ -1393,7 +1393,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'No scheduled tasks yet.': '还没有定时任务。', 'No scheduled tasks yet. Add one': '还没有定时任务,去添加', 'Automation tasks are off in DSH': 'DSH 的「自动化任务」没有打开', -@@ -819,10 +819,19 @@ +@@ -819,10 +819,19 @@ const ZH = { 'Pick a date and time': '选一个日期和时间', 'Pick a time in the future': '选一个将来的时间', 'Pick a time of day': '选一个时间', @@ -1427,7 +1427,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js } // Host errors stay English, since Bots read them too; they are translated where the -@@ -853,8 +853,11 @@ +@@ -853,8 +853,11 @@ const HOST_ERRORS = [ [/^Only the user, a Main Bot, or the admin of "(.+)" can change it$/, '只有你、主 Bot 或「$1」的管理员可以修改'], [/^A key must have at least (\d+) characters$/, '密钥至少 $1 个字符'], [/^A key can have at most (\d+) characters$/, '密钥最多 $1 个字符'], @@ -1439,7 +1439,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js [/^HTTP (\d+)$/, '请求失败(HTTP $1)'], [/^GitHub answered (\d+)$/, 'GitHub 返回了 $1'], [/^Keep the name under (\d+) characters$/, '名字不能超过 $1 个字'], -@@ -883,6 +883,20 @@ +@@ -883,6 +883,20 @@ const HOST_ZH = { 'Unknown Bot': '找不到这个 Bot', 'Unknown conversation': '找不到这个对话', 'A group chat needs at least two Bots': '群聊至少要两个 Bot', @@ -1460,7 +1460,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'The admin must be a member of the group': '管理员必须是群成员', 'Unknown group chat': '找不到这个群聊', 'Hand the admin role to another member instead of leaving the group without one': '请把管理员交给其他成员,不要让群聊没有管理员', -@@ -905,6 +905,8 @@ +@@ -905,6 +905,8 @@ const HOST_ZH = { 'Write the entry to save.': '请写下要保存的内容。', 'This DeepSeek Harness has no folder chooser': '这个 DeepSeek Harness 没有文件夹选择窗口', 'This DeepSeek Harness cannot list folders': '这个 DeepSeek Harness 不能列出文件夹', @@ -3004,11 +3004,11 @@ diff --git a/src/host/capabilities.js b/src/host/capabilities.js @@ -1,4 +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]+)*$/ -@@ -7,8 +7,8 @@ +@@ -7,8 +7,8 @@ const ENSURE_TIMEOUT_MS = 15_000 export function validateCapabilityName(value) { const name = String(value ?? '').trim() @@ -3019,7 +3019,7 @@ diff --git a/src/host/capabilities.js b/src/host/capabilities.js } return name } -@@ -139,7 +139,7 @@ +@@ -139,7 +139,7 @@ export function install(rt) { await rt.load() writeCheck() const items = ensureLibrary() @@ -3028,16 +3028,20 @@ diff --git a/src/host/capabilities.js b/src/host/capabilities.js if (!item) throw new Error(`No capability named ${name}.`) if (!ui) editCheck(item, botId, 'delete') delete items[item.name] -@@ -156,7 +156,7 @@ +@@ -154,9 +154,10 @@ 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}.`) +- 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 = ensureLibrary()[String(name ?? '')] + const item = ownEntry(ensureLibrary(), String(name ?? '')) if (!item) throw new Error(`No capability named ${name}.`) await setAttached(target, item.name, endpoint === 'attach') return { bot: target.name, attached: endpoint === 'attach' } -@@ -264,7 +264,7 @@ +@@ -264,7 +264,7 @@ export function install(rt) { return { transport: 'streamable-http', serverName: item.name, url: item.url, headers: resolveValues(item.headers, botId), failOnStartupError: true } } const disposeMount = async mount => { @@ -3046,7 +3050,7 @@ diff --git a/src/host/capabilities.js b/src/host/capabilities.js } const mountOne = async (agent, bot, agentMounts, name) => { const sessionId = agent.session.id -@@ -454,7 +454,7 @@ +@@ -454,7 +454,7 @@ export function install(rt) { if (endpoint === 'capability-approve') { await rt.load() writeCheck() @@ -3055,7 +3059,7 @@ diff --git a/src/host/capabilities.js b/src/host/capabilities.js if (!item || item.kind !== 'mcp' || item.transport !== 'stdio') throw new Error(`No unapproved stdio MCP named ${body.name}.`) item.approved = true item.updatedAt = Date.now() -@@ -467,7 +467,7 @@ +@@ -467,7 +467,7 @@ export function install(rt) { const bot = rt.botOf(String(body.botId ?? '')) if (!bot) throw new Error('Unknown Bot') const name = String(body.name ?? '') @@ -4391,7 +4395,7 @@ diff --git a/src/host/images.js b/src/host/images.js diff --git a/src/host/memory.js b/src/host/memory.js --- a/src/host/memory.js +++ b/src/host/memory.js -@@ -25,9 +25,11 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSyn +@@ -25,9 +25,12 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSyn import { appendFile, mkdir, rename, unlink, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { BlockAssembler } from '@deepseek-ai/dsh-llm' @@ -4400,6 +4404,7 @@ diff --git a/src/host/memory.js b/src/host/memory.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 @@ -4457,6 +4462,15 @@ diff --git a/src/host/memory.js b/src/host/memory.js // `raw` names a scope: "own", "team", or a Bot. Undefined for the default. function namedScope(raw, botId) { +@@ -495,7 +495,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) { @@ -632,7 +632,7 @@ export function install(rt) { news.delete(botId) return queued(async () => { @@ -4902,11 +4916,12 @@ diff --git a/src/host/prompt.js b/src/host/prompt.js diff --git a/src/host/resume.js b/src/host/resume.js --- a/src/host/resume.js +++ b/src/host/resume.js -@@ -9,6 +9,7 @@ import { readFileSync } from 'node:fs' +@@ -9,6 +9,8 @@ import { readFileSync } from 'node:fs' 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 @@ -5001,6 +5016,17 @@ diff --git a/src/host/resume.js b/src/host/resume.js update(id, () => undefined) break } +@@ -164,7 +164,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) @@ -173,17 +173,17 @@ export function install(rt) { // A turn already picked up once and cut off again stays stopped: a restart loop // must not run it forever. @@ -5075,7 +5101,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js } } -@@ -211,26 +211,41 @@ export function install(rt) { +@@ -211,26 +211,50 @@ export function install(rt) { if (rt.readOnly() !== undefined) return const { turns: noted, queued: dropped } = interrupted interrupted = { turns: {}, queued: {} } @@ -5105,8 +5131,18 @@ diff --git a/src/host/resume.js b/src/host/resume.js firstTurn = undefined for (const [sessionId, list] of Object.entries(dropped)) { - if (!Array.isArray(list)) continue +- await resendQueued(sessionId, list).catch(error => rt.warn('ds-bot: sending again what %s was sent failed: %s', sessionId, error)) + 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) { @@ -5128,7 +5164,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js diff --git a/src/host/schedules.js b/src/host/schedules.js --- a/src/host/schedules.js +++ b/src/host/schedules.js -@@ -5,8 +5,42 @@ +@@ -5,8 +5,43 @@ // them. A group chat's task lives on the group's own Session, so the reminder reaches // the group like a message from the user (see reminderText). DSH 0.2.0 mounts the service only with its Automation tasks bundle on, so // the service is optional and every endpoint reports whether it is there. @@ -5160,6 +5196,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js +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. @@ -5244,7 +5281,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js } default: throw invalid('Pick when it runs') -@@ -89,63 +89,502 @@ export function install(rt) { +@@ -89,63 +89,543 @@ export function install(rt) { return rt.groupOwners.get(sessionId) ?? rt.pastOwners.get(sessionId) } const known = owner => owner !== undefined && (owner.botId === undefined ? state.rooms[owner.roomId] !== undefined : state.bots[owner.botId] !== undefined) @@ -5399,7 +5436,10 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + 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') @@ -5430,6 +5470,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + 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) @@ -5485,13 +5526,14 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + if (!known(owner)) continue + const lastRun = lastRunOf({ id: entry.id }) + tasks.push({ ...entry.record, id: entry.id, sessionId: entry.sessionId, status: 'paused', pausedAt: entry.pausedAt, ...owner, ...(lastRun ? { lastRun } : {}) }) -+ } + } +- return { available: true, tasks } + // #1166: a repeat rule can still fire inside the window — the page says so instead of + // claiming a silence FlyBot cannot keep. + const unchecked = quietHours.on === true && tasks.some(task => task.kind === 'every' || task.kind === 'cron') + return { available: true, tasks, quietHours, ...(unchecked ? { quietUnchecked: true } : {}) } -+ } -+ + } + + // Edit: a paused task stays paused with its new content; an active one is updated in + // place on the same Session, otherwise created anew before the old one is deleted. + // `when: { kind: 'keep' }` keeps the timing (a cron rule the form cannot show). @@ -5556,6 +5598,39 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + 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) { @@ -5564,6 +5639,9 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + 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') @@ -5600,11 +5678,10 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + void rt.save().catch(() => {}) + } + throw error - } -- return { available: true, tasks } ++ } + return schedulesView() - } - ++ } ++ + // Which Session a task lives in, named as the Bot reads it: its own chat (now, or an + // earlier part), or a group chat it speaks in (now, or an earlier part). + const placeOf = (sessionId, botId) => { @@ -5771,6 +5848,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js - Object.assign(rt, { scheduleEndpoint: (endpoint, body) => endpoints[endpoint](body ?? {}), moveSchedules }) + Object.assign(rt, { + scheduleEndpoint: (endpoint, body, actor) => endpoints[endpoint](body ?? {}, actor), ++ pauseBotSchedules, + moveSchedules, + scheduleRunStarted: runStarted, + scheduleRunEnded: runEnded, diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index f107ce76e..3a3e55ac1 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -530,7 +530,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js next = others.with(at, id) } else if (primary) { next = [id, ...others] -@@ -417,12 +417,30 @@ export function install(rt) { +@@ -417,14 +417,38 @@ export function install(rt) { } // The sidebar shows every Main Bot, so a hidden Bot would vanish again once demoted. if (main) delete bot.hidden @@ -540,9 +540,9 @@ diff --git a/src/host/roster.js b/src/host/roster.js - } state.mainBotIds = next + rt.syncDesignatedChief() -+ await rt.save() -+ } -+ + await rt.save() + } + + // #638: retire a Bot instead of deleting it. The tree already reads fields['flybot-tree'].retired: + // adoption skips the record and it no longer counts against maxChildren, while its chat, its parts + // and its memory stay where they are. `hidden` keeps it out of the default Bot list the way the @@ -560,11 +560,19 @@ diff --git a/src/host/roster.js b/src/host/roster.js + bot.fields['flybot-tree'].retired = true + } + bot.hidden = true - await rt.save() -+ return bot - } - ++ // 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, freed } ++ } ++ async function deleteBot(id) { + const bot = rt.botOf(id) + if (bot === undefined) throw new Error('Unknown Bot') @@ -434,22 +434,42 @@ export function install(rt) { const current = rt.chatOf(id) // Questions and exchange turns are kept per Session, so every part of the chat is cleared. @@ -630,7 +638,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js diff --git a/src/host/store.js b/src/host/store.js --- a/src/host/store.js +++ b/src/host/store.js -@@ -1,21 +1,45 @@ +@@ -1,21 +1,50 @@ // Store: the saved team (state.json), its migrations, the lock that keeps a second dsh // host on the same data from writing it, and the indexes that map a Session to the Bot // or group chat it belongs to. @@ -658,6 +666,11 @@ diff --git a/src/host/store.js b/src/host/store.js +// 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 } = rt @@ -786,10 +799,9 @@ diff --git a/src/host/store.js b/src/host/store.js + warn('ds-bot: unreadable state %s: %s', statePath, backupProblem) + return undefined + } - stuck = undefined - return parsed - } -- stuck = 'saved by a newer DS Bot; update DS Bot' ++ stuck = undefined ++ return parsed ++ } + if (parsed?.version === STATE_VERSION) { + const problem = ownershipProblem(parsed) + if (problem !== undefined) { @@ -797,9 +809,10 @@ diff --git a/src/host/store.js b/src/host/store.js + warn('ds-bot: state %s was refused: %s', statePath, problem) + return undefined + } -+ stuck = undefined -+ return parsed -+ } + stuck = undefined + return parsed + } +- stuck = 'saved by a newer DS Bot; update DS Bot' + stuck = 'saved by a newer FlyBot; update FlyBot' return undefined } @@ -1396,7 +1409,7 @@ diff --git a/src/host/store.js b/src/host/store.js diff --git a/src/host/tools.js b/src/host/tools.js --- a/src/host/tools.js +++ b/src/host/tools.js -@@ -1,9 +1,46 @@ +@@ -1,9 +1,47 @@ // Team tools: the roster, bot-to-bot messages, group chat settings and posts, and the // question card. Group rounds and reading chats are in groups.js. import { randomUUID } from 'node:crypto' @@ -1411,6 +1424,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js +import { questionReply, replyText } from './parts.js' +import { OTHER, settled } from './turn-origin.js' +import { cleanName } from './delivery.js' ++import { retiredBot } from './store.js' + +// A run Bot (ephemeral) is not a team member: every tool below has an effect that reaches past +// the run Bot's own Session — it messages, stops or edits another Bot, edits a group, writes or @@ -1587,16 +1601,12 @@ diff --git a/src/host/tools.js b/src/host/tools.js ].join('\n') }, })) -@@ -81,12 +81,22 @@ export function install(rt) { +@@ -81,12 +81,18 @@ export function install(rt) { // A tool that throws shows as failed in the chat, instead of as sent or stopped. const refuse = (message) => { throw new Error(message) } + // A run Bot keeps its name (findBot still sees it) but no team tool can address it (#1373). + const teamBot = bot => bot !== undefined && bot.remote === undefined && bot.ephemeral === undefined -+ // Retired (#638): the tree no longer counts it and the default Bot list leaves it out. -+ // Retirement is terminal, so no team tool addresses it either (#1700): every tool that takes -+ // a Bot name resolves through findTeamBot, which is where the rule belongs. -+ const retiredBot = bot => bot?.fields?.['flybot-tree']?.retired === true + const findTeamBot = (name) => { + const bot = rt.findBot(name) + return teamBot(bot) && !retiredBot(bot) ? bot : undefined @@ -1712,7 +1722,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js if (bot === undefined) return `No Bot named ${args.bot}.` if (bot.id !== caller.id && !rt.isMain(self)) return 'Only a Main Bot can change another Bot. Leave out bot to change yourself, or ask a Main Bot with message_bot.' try { -@@ -230,6 +230,30 @@ export function install(rt) { +@@ -230,6 +230,34 @@ export function install(rt) { }, })) @@ -1731,12 +1741,16 @@ diff --git a/src/host/tools.js b/src/host/tools.js + const target = findTeamBot(args.bot) + if (target === undefined) return `No Bot named ${args.bot}. Team: ${teamNames()}` + if (target.id === sender.id) return 'You cannot archive yourself.' ++ let freed + try { -+ await rt.archiveBot(target.id, { reason: args.reason }) ++ ;({ freed } = await rt.archiveBot(target.id, { reason: args.reason })) + } catch (error) { + return `Could not archive the Bot: ${error.message}` + } -+ return `Archived ${target.name}. It is out of the tree and the default Bot list; its chats and history stay readable, and it is not deleted.` ++ // A timer left armed is a Bot that still wakes on its own, so the answer counts them. ++ const paused = freed?.available === true && freed.paused > 0 ? ` Paused ${freed.paused} task(s) in its own chat.` : '' ++ const armed = freed?.available === true && freed.armed > 0 ? ` ${freed.armed} task(s) in its own chat are still armed.` : '' ++ return `Archived ${target.name}. It is out of the tree and the default Bot list; its chats and history stay readable, and it is not deleted.${paused}${armed}` + }, + })) + diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 2ef13fd40..a546db383 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -822,7 +822,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,51 @@ +@@ -7,21 +7,52 @@ // 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 @@ -857,6 +857,7 @@ diff --git a/src/host/groups.js b/src/host/groups.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. @@ -1004,7 +1005,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js if (slot.status === 'missed') return `${slot.name} (no answer)` if (slot.status === 'working') return `${slot.name} (working on it; posts when done)` if (slot.status === 'waiting-approval') return `${slot.name} (waiting for the user's approval; posts when done)` -@@ -100,162 +100,242 @@ const slotLabel = (slot) => { +@@ -100,162 +100,247 @@ const slotLabel = (slot) => { return slot.name } @@ -1334,7 +1335,11 @@ diff --git a/src/host/groups.js b/src/host/groups.js - if (QUICK_TOOLS.has(tool)) return - for (const waiter of waitersOf(sessionId)) waiter.work() - } -- ++ // 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. @@ -1362,7 +1367,8 @@ diff --git a/src/host/groups.js b/src/host/groups.js 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 = 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. @@ -1611,7 +1617,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js if (reply?.failure !== undefined) { // Not a pass: the user sees why the member is silent, and the round goes on. slot.status = 'failed' -@@ -366,9 +366,18 @@ export function install(rt) { +@@ -366,18 +366,31 @@ export function install(rt) { if (reply?.missed) round.replies.push({ kind: 'missed', botId: member.id, name: member.name, color: member.color, time: Date.now() }) continue } @@ -1632,7 +1638,9 @@ diff --git a/src/host/groups.js b/src/host/groups.js 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) -@@ -377,7 +377,11 @@ export function install(rt) { +- 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) const callees = leads && all ? [...found, ...others.filter(other => !found.includes(other))] : found @@ -1684,7 +1692,7 @@ 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 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. @@ -1857,9 +1865,10 @@ diff --git a/src/host/groups.js b/src/host/groups.js + } + function openingSpeakers(room, opener) { +- const everyone = room.members.map(rt.botOf).filter(Boolean) + // 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)) - if (opener.kickoff) return admin ? [admin] : [] @@ -1964,7 +1973,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js + // 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) { diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 1de63b2f3..d36c72b8d 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -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 @@ -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() diff --git a/patches/ds-bot/files/src/host/task-drive.js b/patches/ds-bot/files/src/host/task-drive.js index 75cae8295..5bcb4a5de 100644 --- a/patches/ds-bot/files/src/host/task-drive.js +++ b/patches/ds-bot/files/src/host/task-drive.js @@ -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. @@ -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 diff --git a/patches/ds-bot/files/src/host/tasks.js b/patches/ds-bot/files/src/host/tasks.js index f0049d7b5..749180e37 100644 --- a/patches/ds-bot/files/src/host/tasks.js +++ b/patches/ds-bot/files/src/host/tasks.js @@ -17,6 +17,7 @@ // failed task never becomes accepted. Records keep pointers and a summary, not transcripts. import { randomBytes } from 'node:crypto' import { columnOf, compareForQueue, compareForWork, occupiesSlot, sameRole } from '../shared/task-board.js' +import { retiredBot } from './store.js' import { currentParticipants, deriveParticipants } from './room-membership.js' import { cardNamesIssue } from '../shared/issue-ref.js' import { ownEntry } from './store.js' @@ -756,7 +757,7 @@ export function install(rt) { const memberBot = (room, name) => { const wanted = String(name).trim() const ids = liveParticipants(room).filter(person => person.kind === 'bot' && !person.remote).map(person => person.id) - const matches = [...new Set(ids)].map(id => botRecord(id)).filter(candidate => candidate && candidate.name === wanted) + const matches = [...new Set(ids)].map(id => botRecord(id)).filter(candidate => candidate && !retiredBot(candidate) && candidate.name === wanted) if (matches.length !== 1) throw invalid(`No single Bot named "${wanted.slice(0, 80)}" in this group`) return matches[0] } @@ -1240,10 +1241,10 @@ export function install(rt) { return save() } // The responsible Bot is gone: its open tasks fail (they are never silently dropped). - rt.taskBotDeleted = botId => { + rt.taskBotDeleted = (botId, why = 'the responsible Bot was deleted') => { for (const task of Object.values(store())) { if (!ACTIVE.has(task.status) || (task.ownerBotId !== botId && task.workBotId !== botId)) continue - transition(task, 'failed', null, { why: 'the responsible Bot was deleted' }) + transition(task, 'failed', null, { why }) // Deletion is another door a child ends through; its overall card must not stay working. settleOverall(task) } @@ -1264,7 +1265,7 @@ export function install(rt) { const next = typeof route?.ownerBotId === 'string' ? route.ownerBotId : '' if (next === '' || next === task.ownerBotId) return const bot = botRecord(next) - if (bot === undefined || bot.remote !== undefined) return + if (bot === undefined || bot.remote !== undefined || retiredBot(bot)) return task.ownerBotId = next record(task, null, { by: 'routed' }) await save() @@ -1275,7 +1276,9 @@ export function install(rt) { // endpoint's own gate, so the answer and the write cannot disagree. const assertCreate = (body, actor) => { const bot = state.bots?.[String(body?.botId ?? '')] - if (bot === undefined || bot.remote !== undefined) throw forbidden('No such Bot') + // The same gate the endpoint had, plus retirement (#1709): a retired Bot takes no new card, + // whether it arrives from task-create or a pre-flight. + if (bot === undefined || bot.remote !== undefined || retiredBot(bot)) throw forbidden('No such Bot') const roomId = body?.roomId ? String(body.roomId) : undefined if (!mayCreateOn(bot, actor, roomId)) throw forbidden('You cannot assign a task to that Bot') return bot @@ -1436,6 +1439,9 @@ export function install(rt) { if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) requireRequester(task, actor) if (task.status !== 'result-ready') throw stale('Only a result waiting for the requester can be sent back') + // Sending back opens a rework card on the same owner, so a retired Bot would get a + // fresh card through this door (#1709). Refused before any write: no card is born. + if (retiredBot(botRecord(task.ownerBotId))) throw forbidden('No such Bot') const note = oneLine(body.note, 500) if (note === '') throw invalid('Send back needs a note for the Bot') transition(task, 'working', actor, { by: 'sent back' }) diff --git a/patches/ds-bot/files/test/retired-bot-paths.test.js b/patches/ds-bot/files/test/retired-bot-paths.test.js new file mode 100644 index 000000000..acaa28a77 --- /dev/null +++ b/patches/ds-bot/files/test/retired-bot-paths.test.js @@ -0,0 +1,475 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +// #1709: retirement (#638) was final only for the tools that resolve a Bot through +// findTeamBot (#1700). Five host paths resolved a Bot by name without that check, so an +// archived Bot was still woken, still edited, still written to, and still kept a timer. +// Every path below is a behaviour red on the base: a delivery, a write, or an armed row. + +const bot = (id, name, createdAt, fields) => ({ + id, sessionId: id, name, role: `${name} role`, model: 'deepseek/v4', instructions: `${name} instructions`, createdAt, capabilities: [], + ...(fields ? { fields: { 'flybot-tree': fields } } : {}), +}) +const team = () => ({ + version: 1, + mainBotIds: ['m1'], + revision: 1, + whaleStored: true, + bots: { + m1: bot('m1', 'Chief', 1, { treeRole: 'root' }), + b2: bot('b2', 'Writer', 2, { parent: 'm1' }), + b3: bot('b3', 'Coder', 3, { parent: 'm1' }), + }, + rooms: { + r1: { + id: 'r1', name: 'Launch', named: true, members: ['m1', 'b2', 'b3'], admin: 'm1', mode: 'everyone', notice: '', createdAt: 4, + // Named group Sessions, so a delivery names the Bot it woke. + sessions: { m1: 's-m1', b2: 's-b2', b3: 's-b3' }, + }, + }, + exchanges: [], + exchangeTurns: {}, + questions: {}, +}) + +// An in-memory stand-in for DSH's schedule service, the same shapes schedules.test.js +// fakes: catalog is what a Bot-wide read and the archive see. +function scheduleService() { + const tasks = new Map() + let next = 1 + const KINDS = { + at: at => ({ kind: 'at', scheduledAt: at }), + every_seconds: seconds => ({ kind: 'every', everySeconds: seconds, scheduledAt: new Date(Date.now() + seconds * 1000).toISOString() }), + daily: ({ time, time_zone }) => ({ kind: 'daily', time, timeZone: time_zone, scheduledAt: '2030-01-01T01:00:00.000Z' }), + weekly: ({ time, time_zone, weekdays }) => ({ kind: 'weekly', time, timeZone: time_zone, weekdays, scheduledAt: '2030-01-01T01:00:00.000Z' }), + cron: ({ expression, time_zone }) => ({ kind: 'cron', expression, timeZone: time_zone, scheduledAt: '2030-01-01T01:00:00.000Z' }), + } + const service = { + tasks, + async create(sessionId, request) { + const id = `schedule-${next++}` + const kind = Object.keys(request).find(key => !['title', 'prompt'].includes(key)) + const record = { id, title: request.title, prompt: request.prompt, timing: request[kind], ...KINDS[kind](request[kind]) } + tasks.set(id, { sessionId, record, status: 'active' }) + return record + }, + async list({ sessionId }) { return [...tasks.values()].filter(task => task.sessionId === sessionId && task.status === 'active').map(task => task.record) }, + async catalog() { return [...tasks.values()].map(task => ({ ...task.record, sessionId: task.sessionId, status: task.status })) }, + async update(request) { + const task = tasks.get(request.id) + if (task?.sessionId !== request.sessionId) return { id: request.id, updated: false, code: 'schedule_not_found' } + task.record = { ...task.record, title: request.title, prompt: request.prompt } + return { id: request.id, updated: true, record: task.record } + }, + async delete({ sessionId, id }) { + if (tasks.get(id)?.sessionId !== sessionId) return { id, deleted: false, code: 'schedule_not_found' } + tasks.delete(id) + return { id, deleted: true } + }, + async getDomain() { + return { table: () => ({ entries: () => tasks.entries(), get: id => tasks.get(id), put: (id, task) => tasks.set(id, task) }) } + }, + serialize: work => work(), + } + return service +} + +// `existingHome` starts a second runtime on a home the first one left: the restart the +// resume path reads its notes from. +function start(existingHome, seedState) { + const home = existingHome ?? mkdtempSync(join(tmpdir(), 'ds-bot-retired-')) + if (existingHome === undefined) writeFileSync(join(home, 'state.json'), JSON.stringify(seedState ?? team())) + const schedule = scheduleService() + const ctx = fakeContext({ services: { schedule } }) + // A delivery lands on the target's Agent; what it records is the proof a retired Bot + // was woken (or, after the fix, that nothing was delivered at all). + const sent = [] + const inboxes = new Map() + const inboxOf = (sessionId) => { + if (!inboxes.has(sessionId)) { + const inbox = { nextStep: [], nextTurn: [], remove() {} } + inboxes.set(sessionId, inbox) + } + return inboxes.get(sessionId) + } + let sessions = 0 + const agent = sessionId => ({ + session: { id: sessionId, seq: 1, requestHeader: () => undefined }, + id: sessionId, + status: 'idle', + followup(message) { sent.push({ sessionId, message, kind: 'followup' }) }, + steer(message) { sent.push({ sessionId, message, kind: 'steer' }) }, + inject(message) { sent.push({ sessionId, message, kind: 'inject' }) }, + cancel() {}, + async whenIdle() {}, + async runMaintenance(fn) { return fn(AbortSignal.timeout(60_000)) }, + inbox: inboxOf(sessionId), + hideBlockingQuestion() {}, + }) + Object.assign(ctx, { + llm: { + listProviders: () => [{ id: 'deepseek', name: 'DeepSeek' }], + listModels: async () => [{ id: 'deepseek-v4-flash' }], + resolveModelInfo: async () => ({}), + }, + sessionController: { + create: async () => ({ sessionId: `gs${++sessions}` }), + resolveAgent: async sessionId => ({ agent: agent(sessionId) }), + selectModel: async () => {}, + page: async () => ({ events: [], records: [], hasMore: false }), + prompt: async () => ({ accepted: true }), + }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {}, messageProjections: [] }, + workspaceRegistry: { + get: () => undefined, + resolveByPath: async () => undefined, + create: async path => ({ id: 'w1', path }), + archiveSession: async () => {}, + }, + }) + let route + const register = ctx.connection.fetch.register + ctx.connection.fetch.register = (options) => { + route = options + return register(options) + } + const rt = createRuntime(ctx, { home }) + const call = async (endpoint, payload) => { + const response = await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) })) + return response.json() + } + const tool = (name, args, sessionId) => ctx.registeredTools.get(name).execute(args, { agent: { id: sessionId } }) + const emit = async (sessionId, event) => { + for (const listener of ctx.listeners.get('session/event') ?? []) await listener({ id: sessionId }, event) + } + const userMessage = text => ({ type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text }] } }) + const relay = (roomId, callId) => ctx.registeredTools.get('group_relay') + .execute({}, { agent: { id: roomId, inbox: inboxOf(roomId) }, callId, signal: new AbortController().signal }) + // The Bot's answer to its group turn: a delivery source plus the reply text, the way DSH + // reports the events of one turn. + const answer = async (sessionId, exchangeId, text, turn) => { + await emit(sessionId, { type: 'turn/start', data: { turn } }) + await emit(sessionId, { type: 'user/message', data: { source: { kind: 'bot', role: 'room', exchangeId }, content: [{ type: 'text', text: 'your turn' }] } }) + await emit(sessionId, { type: 'assistant/message', data: { message: { content: [{ type: 'text', text }] } } }) + await emit(sessionId, { type: 'turn/end', data: { reason: { kind: 'stop' } } }) + } + const saved = () => JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + const memoryFile = botId => join(home, 'memory', 'bots', botId, 'MEMORY.md') + const activeRows = sessionId => [...schedule.tasks.values()].filter(task => task.sessionId === sessionId && task.status === 'active') + const stop = async ({ keepHome = false } = {}) => { + await ctx.dispose() + if (!keepHome) rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + } + return { ctx, rt, schedule, call, tool, emit, userMessage, relay, answer, saved, memoryFile, activeRows, sent, home, stop } +} + +const settle = () => new Promise(resolve => setTimeout(resolve, 25)) +const waitFor = async (check) => { + for (let at = 0; at < 100; at += 1) { + const value = check() + if (value !== undefined) return value + await settle() + } + return undefined +} + +// 1. track_tasks assignee — a card for a retired Bot wakes it through task-drive. +test('#1709 (1): a retired Bot cannot be a task assignee, so no card and no delivery', async (t) => { + const { tool, saved, sent, stop } = start() + t.after(stop) + assert.match(await tool('archive_bot', { bot: 'Writer' }, 'm1'), /^Archived Writer\./) + await tool('track_tasks', { tasks: [{ group: 'Launch', title: 'Ship it', overall: true }] }, 'm1') + const refused = await tool('track_tasks', { tasks: [{ group: 'Launch', title: 'Write docs', parent: 'Ship it', assignee: 'Writer' }] }, 'm1') + assert.match(String(refused), /No single Bot named "Writer"/) + const cards = Object.values(saved().tasks ?? {}) + assert.equal(cards.filter(card => card.ownerBotId === 'b2').length, 0, 'no card was written for the retired Bot') + assert.deepEqual(sent.filter(entry => entry.sessionId === 'b2'), [], 'the retired Bot was not woken') + // The live Bot keeps its path: the same card for Coder is written and it is woken. + const made = await tool('track_tasks', { tasks: [{ group: 'Launch', title: 'Code it', parent: 'Ship it', assignee: 'Coder' }] }, 'm1') + assert.match(String(made), /Code it/) + assert.equal(Object.values(saved().tasks).filter(card => card.ownerBotId === 'b3').length, 1) +}) + +// 2. A group @Name turn — a retired Bot seated in the room still took a turn. +test('#1709 (2): a group @Name does not call a retired Bot, while a live one still answers', async (t) => { + const { emit, userMessage, relay, tool, sent, answer, stop } = start() + t.after(stop) + assert.match(await tool('archive_bot', { bot: 'Writer' }, 'm1'), /^Archived Writer\./) + await emit('r1', userMessage('@Writer ping, @Coder take a look')) + // Every Bot the line called takes its turn; each answer lets the next slot run. + const running = relay('r1', 'round-1') + const woken = [] + for (let at = 0; at < 4; at += 1) { + const entry = await waitFor(() => sent.find(item => !woken.includes(item))) + if (entry === undefined) break + woken.push(entry) + await answer(entry.sessionId, entry.message.source.exchangeId, 'On it.', at + 1) + } + await running + assert.deepEqual(woken.map(entry => entry.sessionId), ['s-b3'], 'the retired Bot takes no turn, while the live one still answers') +}) + +// 3. attach_capability — a retired Bot still gained capabilities. +test('#1709 (3): a retired Bot gains no capability, while a live Bot still does', async (t) => { + const { call, tool, saved, stop } = start() + t.after(stop) + const made = await call('capability-save', { name: 'probe-skill', kind: 'skill', content: 'Do the probe.' }) + assert.equal(made.ok, true, JSON.stringify(made)) + assert.match(await tool('archive_bot', { bot: 'Writer' }, 'm1'), /^Archived Writer\./) + assert.match(String(await tool('attach_capability', { name: 'probe-skill', bot: 'Writer' }, 'm1')), /No Bot named Writer/) + assert.deepEqual(saved().bots.b2.capabilities ?? [], []) + assert.match(await tool('attach_capability', { name: 'probe-skill', bot: 'Coder' }, 'm1'), /Attached probe-skill to Coder/) + assert.deepEqual(saved().bots.b3.capabilities, ['probe-skill']) +}) + +// 4. remember scope: — a retired Bot still received memory writes. +test('#1709 (4): a retired Bot receives no memory write, while a live Bot still saves one', async (t) => { + const { tool, saved, memoryFile, stop } = start() + t.after(stop) + assert.match(await tool('archive_bot', { bot: 'Writer' }, 'm1'), /^Archived Writer\./) + assert.match(await tool('remember', { text: 'Writes in Chinese', scope: 'Writer' }, 'm1'), /^No Bot named Writer/) + assert.equal(existsSync(memoryFile('b2')), false, 'nothing was written into the retired Bot\'s memory') + assert.match(await tool('remember', { text: 'Writes in Chinese', scope: 'Coder' }, 'm1'), /^Saved to Coder's memory/) + assert.match(readFileSync(memoryFile('b3'), 'utf8'), /Writes in Chinese/) + assert.equal(saved().bots.b3.id, 'b3') +}) + +// 5a. schedule-create — a retired target still got a live row bound to its chat Session. +test('#1709 (5a): schedule-create refuses a retired target, while a live one is accepted', async (t) => { + const { call, tool, schedule, activeRows, stop } = start() + t.after(stop) + assert.match(await tool('archive_bot', { bot: 'Writer' }, 'm1'), /^Archived Writer\./) + const refused = await call('schedule-create', { target: 'b2', title: 'Nightly writing', prompt: 'Write', when: { kind: 'daily', time: '09:00', timeZone: 'UTC' } }) + assert.equal(refused.ok, false, JSON.stringify(refused)) + assert.match(refused.error.message, /No such Bot or group chat/) + assert.deepEqual(activeRows('b2'), [], 'no row was armed on the retired Bot\'s chat') + assert.equal(schedule.tasks.size, 0) + const accepted = await call('schedule-create', { target: 'b3', title: 'Nightly coding', prompt: 'Code', when: { kind: 'daily', time: '10:00', timeZone: 'UTC' } }) + assert.equal(accepted.ok, true, JSON.stringify(accepted)) + assert.equal(activeRows('b3').length, 1) +}) + +// 5b. schedule-update — the same door, reached through updateSchedule's contentOf. +test('#1709 (5b): schedule-update refuses to move a task onto a retired Bot', async (t) => { + const { call, tool, activeRows, stop } = start() + t.after(stop) + const made = await call('schedule-create', { target: 'b3', title: 'Nightly coding', prompt: 'Code', when: { kind: 'daily', time: '10:00', timeZone: 'UTC' } }) + assert.equal(made.ok, true, JSON.stringify(made)) + const id = activeRows('b3')[0].record.id + assert.match(await tool('archive_bot', { bot: 'Writer' }, 'm1'), /^Archived Writer\./) + const refused = await call('schedule-update', { sessionId: 'b3', id, target: 'b2', title: 'Moved', prompt: 'Moved', when: { kind: 'keep' } }) + assert.equal(refused.ok, false, JSON.stringify(refused)) + assert.match(refused.error.message, /No such Bot or group chat/) + assert.deepEqual(activeRows('b2'), [], 'no row on the retired Bot') + assert.equal(activeRows('b3').length, 1, 'the live Bot keeps its task') +}) + +// 5c. schedule-copy — the copy target resolves beside contentOf and had no door either. +test('#1709 (5c): schedule-copy refuses a retired target', async (t) => { + const { call, tool, activeRows, stop } = start() + t.after(stop) + const made = await call('schedule-create', { target: 'b3', title: 'Nightly coding', prompt: 'Code', when: { kind: 'daily', time: '10:00', timeZone: 'UTC' } }) + assert.equal(made.ok, true, JSON.stringify(made)) + const id = activeRows('b3')[0].record.id + assert.match(await tool('archive_bot', { bot: 'Writer' }, 'm1'), /^Archived Writer\./) + const refused = await call('schedule-create', { target: 'b2', title: 'Copied', prompt: 'Copied', copyFrom: { sessionId: 'b3', id } }) + assert.equal(refused.ok, false, JSON.stringify(refused)) + assert.match(refused.error.message, /No such Bot or group chat/) + assert.deepEqual(activeRows('b2'), [], 'nothing was copied onto the retired Bot') + assert.equal(activeRows('b3').length, 1) +}) + +// 5d. Part B: retiring disarms the rows already armed on the Bot's own chat Session. +test('#1709 (5d): archiving pauses the active rows of the Bot\'s own chat, and says so', async (t) => { + const { call, tool, schedule, activeRows, saved, stop } = start() + t.after(stop) + for (const [title, time] of [['Morning', '08:00'], ['Evening', '18:00']]) { + const made = await call('schedule-create', { target: 'b3', title, prompt: 'Write', when: { kind: 'daily', time, timeZone: 'UTC' } }) + assert.equal(made.ok, true, JSON.stringify(made)) + } + const other = await call('schedule-create', { target: 'm1', title: 'Chief brief', prompt: 'Brief', when: { kind: 'daily', time: '09:00', timeZone: 'UTC' } }) + assert.equal(other.ok, true, JSON.stringify(other)) + assert.equal(activeRows('b3').length, 2) + + const answer = await tool('archive_bot', { bot: 'Coder' }, 'm1') + assert.match(answer, /^Archived Coder\./) + assert.match(answer, /Paused 2 task\(s\)/, `the result says what it disarmed: ${answer}`) + assert.deepEqual(activeRows('b3'), [], 'no row is left armed on the archived Bot\'s chat') + assert.equal(Object.keys(saved().pausedSchedules ?? {}).length, 2, 'the paused rows are in state.pausedSchedules') + assert.equal(schedule.tasks.size, 1, 'the unrelated live Bot keeps its task') + assert.equal(activeRows('m1').length, 1) + // And the retired Bot cannot take a new one afterwards. + const refused = await call('schedule-create', { target: 'b3', title: 'Back', prompt: 'Back', when: { kind: 'daily', time: '07:00', timeZone: 'UTC' } }) + assert.equal(refused.ok, false, JSON.stringify(refused)) +}) + +// 5e. schedule-resume — pausing a task and then retiring its Bot put the same row back: +// Resume re-creates the held record on the retired Bot's chat Session, so the door Part A +// closes on create/copy/update stayed open one step later (#1709). +test('#1709 (5e): schedule-resume refuses a paused task of a retired Bot', async (t) => { + const { call, tool, schedule, activeRows, saved, stop } = start() + t.after(stop) + const made = await call('schedule-create', { target: 'b2', title: 'Nightly writing', prompt: 'Write', when: { kind: 'daily', time: '09:00', timeZone: 'UTC' } }) + assert.equal(made.ok, true, JSON.stringify(made)) + const id = activeRows('b2')[0].record.id + const held = await call('schedule-pause', { sessionId: 'b2', id }) + assert.equal(held.ok, true, JSON.stringify(held)) + assert.deepEqual(activeRows('b2'), [], 'paused here, before the Bot retires') + assert.match(await tool('archive_bot', { bot: 'Writer' }, 'm1'), /^Archived Writer\./) + + const refused = await call('schedule-resume', { sessionId: 'b2', id }) + assert.equal(refused.ok, false, JSON.stringify(refused)) + assert.match(refused.error.message, /No such Bot or group chat/) + assert.deepEqual(activeRows('b2'), [], 'no row is armed on the retired Bot\'s chat again') + assert.equal(Object.keys(saved().pausedSchedules ?? {}).length, 1, 'the paused record stays where it was') + assert.equal(schedule.tasks.size, 0) +}) + +// 6. task-drive — a card that predates the retirement still woke its Bot on the next drive, +// because the driver resolved the owner without the retired check (#1709). The card is the +// one a host starts with: a board row somebody else left behind. +test('#1709 (6): a card that predates the retirement wakes nobody', async (t) => { + const seeded = team() + seeded.bots.b3.fields['flybot-tree'].retired = true + seeded.bots.b3.hidden = true + seeded.tasks = { + 'task-left-behind': { + id: 'task-left-behind', status: 'open', createdAt: 1, updatedAt: 1, requesterId: 'owner-1', + ownerBotId: 'b3', title: 'Write docs', source: 'conversation', origin: { roomId: 'r1' }, + actors: [{ status: 'open', at: 1 }], + }, + } + const { ctx, sent, saved, stop } = start(undefined, seeded) + t.after(stop) + await ctx.taskDrive.consider() + await ctx.taskDrive.settled() + assert.deepEqual(sent.filter(entry => entry.sessionId === 'b3'), [], 'the retired Bot is not woken') + assert.equal(saved().tasks['task-left-behind'].status, 'open', 'the row is left as it was') +}) + +// 6b. Retiring means to the board what deleting means (#1709): the live cards a Bot holds +// fail instead of waiting for a Bot that will not run again. +test('#1709 (6b): archiving fails the live cards the Bot held', async (t) => { + const { ctx, tool, saved, sent, stop } = start() + t.after(stop) + await tool('track_tasks', { tasks: [{ group: 'Launch', title: 'Ship it', overall: true }] }, 'm1') + assert.match(String(await tool('track_tasks', { tasks: [{ group: 'Launch', title: 'Write docs', parent: 'Ship it', assignee: 'Coder' }] }, 'm1')), /Write docs/) + await ctx.taskDrive.settled() + const card = () => Object.values(saved().tasks ?? {}).find(task => task.ownerBotId === 'b3') + assert.equal(card().status, 'claimed', JSON.stringify(card())) + assert.match(await tool('archive_bot', { bot: 'Coder' }, 'm1'), /^Archived Coder\./) + const after = card() + assert.equal(after.status, 'failed', `the card is failed like a deleted Bot's: ${JSON.stringify(after)}`) + assert.equal(after.actors.at(-1).why, 'the responsible Bot was retired') + const before = sent.filter(entry => entry.sessionId === 'b3').length + await ctx.taskDrive.consider() + await ctx.taskDrive.settled() + assert.equal(sent.filter(entry => entry.sessionId === 'b3').length, before, 'nothing more is delivered') +}) + +// 7. task-create — the client's own way in (client/index.js calls it) accepted a retired +// owner and wrote a card that the drive then delivered. +test('#1709 (7): task-create refuses a retired Bot as the owner, while a live one is accepted', async (t) => { + const { call, tool, saved, stop } = start() + t.after(stop) + assert.match(await tool('archive_bot', { bot: 'Coder' }, 'm1'), /^Archived Coder\./) + const refused = await call('task-create', { botId: 'b3', title: 'Write docs' }) + assert.equal(refused.ok, false, JSON.stringify(refused)) + assert.equal(Object.values(saved().tasks ?? {}).filter(task => task.ownerBotId === 'b3').length, 0, 'no card was written for the retired Bot') + const accepted = await call('task-create', { botId: 'b2', title: 'Write docs' }) + assert.equal(accepted.ok, true, JSON.stringify(accepted)) +}) + +// 8. routeOpenTask — a plugin's route could hand an open card to a retired Bot. +test('#1709 (8): a plugin route cannot put an open card on a retired Bot', async (t) => { + const { rt, call, tool, saved, stop } = start() + t.after(stop) + assert.match(await tool('archive_bot', { bot: 'Coder' }, 'm1'), /^Archived Coder\./) + rt.onTaskWrite = async () => ({ ownerBotId: 'b3' }) + const made = await call('task-create', { botId: 'm1', title: 'Routed card' }) + assert.equal(made.ok, true, JSON.stringify(made)) + assert.equal(saved().tasks[made.value.id].ownerBotId, 'm1', 'the retired Bot does not take the card') + // A live Bot still takes the same route. + rt.onTaskWrite = async () => ({ ownerBotId: 'b2' }) + const second = await call('task-create', { botId: 'm1', title: 'Second card' }) + assert.equal(second.ok, true, JSON.stringify(second)) + assert.equal(saved().tasks[second.value.id].ownerBotId, 'b2') +}) + +// 9. openRework — sending a result back opened a second card on the same retired owner. +test('#1709 (9): sending work back opens no rework card on a retired Bot', async (t) => { + const { call, tool, saved, stop } = start() + t.after(stop) + const created = await call('task-create', { botId: 'b3', title: 'Write docs' }) + assert.equal(created.ok, true, JSON.stringify(created)) + const id = created.value.id + for (const endpoint of ['task-accept', 'task-start']) { + const moved = await call(endpoint, { id }) + assert.equal(moved.ok, true, `${endpoint}: ${JSON.stringify(moved)}`) + } + const returned = await call('task-return', { id, summary: 'Draft is ready' }) + assert.equal(returned.ok, true, JSON.stringify(returned)) + assert.equal(returned.value.status, 'result-ready') + // A returned result stays for the requester even after retirement: that is the card a + // send-back would reopen work from. + assert.match(await tool('archive_bot', { bot: 'Coder' }, 'm1'), /^Archived Coder\./) + assert.equal(Object.values(saved().tasks).find(task => task.id === id).status, 'result-ready') + const refused = await call('task-send-back', { id, note: 'Please tighten it' }) + assert.equal(refused.ok, false, JSON.stringify(refused)) + assert.deepEqual(Object.values(saved().tasks).filter(task => task.reworkOf === id), [], 'no rework card was born on the retired Bot') +}) + +// 10. hooks — a held send still verified only that the target exists, so approving it +// delivered to a Bot that had been retired while it waited. +test('#1709 (10): a held send to a retired Bot goes stale instead of being delivered', async (t) => { + const { rt, call, tool, sent, stop } = start() + t.after(stop) + const flybot = rt.flybot + flybot.bot.send.use({ name: 'gate', handle: async msg => (msg.meta?.hold ? { action: 'hold', reason: 'held by test' } : undefined) }) + const held = await flybot.bot.send({ from: 'm1', to: 'b3', text: 'please take this', meta: { hold: true } }) + assert.equal(held.action, 'hold', JSON.stringify(held)) + assert.match(await tool('archive_bot', { bot: 'Coder' }, 'm1'), /^Archived Coder\./) + const decided = await call('flybot/decide', { id: held.approvalId, decision: 'approve' }) + assert.equal(decided.ok, true, JSON.stringify(decided)) + assert.equal(decided.value.status, 'stale', `the hold is stale: ${JSON.stringify(decided.value)}`) + assert.match(String(decided.value.error ?? ''), /retired/) + assert.deepEqual(sent.filter(entry => entry.sessionId === 'b3'), [], 'nothing was delivered to the retired Bot') +}) + +// 11. resume — a restart picked the interrupted turn of a retired Bot up again, which is a +// delivery to a Bot that no longer runs. +test('#1709 (11b): a restart does not send again what was queued for a retired Bot', async (t) => { + const first = start() + const home = first.home + assert.match(await first.tool('archive_bot', { bot: 'Coder' }, 'm1'), /^Archived Coder\./) + await first.stop({ keepHome: true }) + // A delivery noted before the Bot was retired: the note is what the next start would send. + writeFileSync(join(home, 'inflight.json'), JSON.stringify({ + turns: {}, + queued: { b3: [{ at: Date.now(), args: { fromId: 'm1', toId: 'b3', role: 'request', text: 'queued before retirement', exchangeId: 'exchange-retired' } }] }, + })) + const second = start(home) + t.after(second.stop) + await new Promise(resolve => setTimeout(resolve, 3_600)) + assert.deepEqual(second.sent.filter(entry => entry.sessionId === 'b3'), [], 'nothing queued is delivered to the retired Bot') +}) + +test('#1709 (11): a restart does not pick up a retired Bot\'s turn', async (t) => { + const first = start() + const home = first.home + assert.match(await first.tool('archive_bot', { bot: 'Coder' }, 'm1'), /^Archived Coder\./) + await first.stop({ keepHome: true }) + // What the last run left behind: one turn noted for the retired Bot's Session. + writeFileSync(join(home, 'inflight.json'), JSON.stringify({ turns: { b3: { botId: 'b3', since: 1 } }, queued: {} })) + const second = start(home) + t.after(second.stop) + await new Promise(resolve => setTimeout(resolve, 3_600)) + assert.deepEqual(second.sent.filter(entry => entry.sessionId === 'b3'), [], 'the retired Bot is not woken by a restart') +})