From 9717c09bac146873dc8e028db3d94a415daaa3d4 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Fri, 30 Oct 2026 12:19:41 +0800 Subject: [PATCH 1/2] fix(host): stop a retired Bot taking cards, timers and sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replayed onto main 84385f2f: the same change set, re-applied to current main — not a new change. Conflicts kept both sides: `tasks.js` keeps main's `assertCreate` pre-flight with the retired-Bot gate inside it, `task-drive.js`/`store.js` keep main's `occupiesSlot` work beside the new `retiredBot` predicate, and the patch series carries only this PR's own hunks. --- changelog.d/1709-retired-bot-paths.fixed.md | 1 + core/src/host/capabilities.js | 5 +- core/src/host/groups.js | 16 +- core/src/host/hooks.js | 4 + core/src/host/memory.js | 3 +- core/src/host/resume.js | 16 +- core/src/host/roster.js | 8 +- core/src/host/schedules.js | 42 ++ core/src/host/store.js | 5 + core/src/host/task-drive.js | 4 + core/src/host/tasks.js | 16 +- core/src/host/tools.js | 13 +- core/test/retired-bot-paths.test.js | 475 ++++++++++++++++++ docs/subsystems/core-host.md | 2 +- docs/subsystems/plugin-runtime.md | 4 +- patches/ds-bot/0001-core-fixes.patch | 146 ++++-- patches/ds-bot/0002-flybot-api-host.patch | 166 +++--- patches/ds-bot/0012-room-membership.patch | 108 ++-- patches/ds-bot/files/src/host/hooks.js | 4 + patches/ds-bot/files/src/host/task-drive.js | 4 + patches/ds-bot/files/src/host/tasks.js | 16 +- .../files/test/retired-bot-paths.test.js | 475 ++++++++++++++++++ 22 files changed, 1344 insertions(+), 189 deletions(-) create mode 100644 changelog.d/1709-retired-bot-paths.fixed.md create mode 100644 core/test/retired-bot-paths.test.js create mode 100644 patches/ds-bot/files/test/retired-bot-paths.test.js 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..d167c8faa 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -3004,7 +3004,7 @@ 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]+)*$/ @@ -3028,9 +3028,13 @@ 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 @@ + 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 ?? '')) @@ -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 { appendFile, mkdir, rename, unlink, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { BlockAssembler } from '@deepseek-ai/dsh-llm' @@ -4400,11 +4404,12 @@ 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 // part, so together they stay near 8,000 characters, a few thousand tokens. A topic is -@@ -312,7 +312,7 @@ const teamRule = (main, writer) => (main +@@ -312,7 +312,7 @@ ? '- The team memory is shared by every Bot. As a group admin you write it with remember scope "team", for what the whole team needs.' : '- The team memory is shared by every Bot. Main Bots and group admins write it; when the whole team should know something, tell a Main Bot with message_bot.') @@ -4413,7 +4418,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js const summaryRequest = ({ name, own, team, language }) => [ `Here is everything ${name} remembers: its own memory, then the team memory that every Bot shares. Each line is one entry; [[name]] is a topic.`, -@@ -327,7 +327,7 @@ const summaryRequest = ({ name, own, team, language }) => [ +@@ -327,7 +327,7 @@ ].join('\n') const ASK_SYSTEM = [ @@ -4422,7 +4427,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js 'Reply with one JSON object and nothing else: {"reply": "…", "changes": […]}', '- reply: one to three short sentences to the user. Say what you changed, or answer the question; when the entries do not say, say so. The user does not see the entry numbers, so never mention them.', '- changes: empty unless the user asks to add, correct, or remove something. Each change is one of', -@@ -338,7 +338,7 @@ const ASK_SYSTEM = [ +@@ -338,7 +338,7 @@ ].join('\n') const reviewSystem = teamWrites => [ @@ -4431,7 +4436,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js 'Reply with one JSON object and nothing else: {"changes": […]}, with an empty list when nothing should change.', `At most ${REVIEW_CHANGES_MAX} changes in one answer, the most lasting first; the rest can wait for the next review.`, 'Keep, as a new entry:', -@@ -486,7 +486,24 @@ export function install(rt) { +@@ -486,7 +486,24 @@ // Main Bots and the admins of a group also write the team's; Main Bots read and // write every Bot's. The user may do everything from the memory panel. @@ -4457,7 +4462,16 @@ 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) { -@@ -632,7 +632,7 @@ export function install(rt) { +@@ -495,7 +495,7 @@ + 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 @@ news.delete(botId) return queued(async () => { if (rt.readOnly() !== undefined) return @@ -4466,7 +4480,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js const dir = dirOf(ownScope(botId)) if (!existsSync(dir)) return const name = `${safeId(botId)}-${new Date().toISOString().replace(/[:.]/g, '-')}` -@@ -837,11 +837,13 @@ export function install(rt) { +@@ -837,11 +837,13 @@ ctx.tools.register(defineTool({ name: 'recall', @@ -4481,7 +4495,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js }, output, async execute(args, exec) { -@@ -861,6 +861,20 @@ export function install(rt) { +@@ -861,6 +861,20 @@ const known = loaded.flatMap(({ tag, files }) => files.filter(file => file.topic).map(file => `[[${file.topic}]] (${tag})`)) return `No topic [[${String(args.topic).slice(0, 60)}]] there. Topics: ${known.join(', ') || 'none yet'}.` } @@ -4502,7 +4516,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js const where = (tag, file) => `${tag} · ${file.topic === undefined ? MAIN : `[[${file.topic}]]`}` const query = String(args.query ?? '').trim().slice(0, 120) if (query === '') { -@@ -872,6 +872,21 @@ export function install(rt) { +@@ -872,6 +872,21 @@ ].join('\n') }).join('\n\n') } @@ -4524,7 +4538,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js const terms = [...new Set(squash(query).split(' ').filter(Boolean))].slice(0, QUERY_TERMS_MAX) const scored = loaded.flatMap(({ tag, files }) => allEntries(files).map((entry) => { const hay = squash(`${entry.text} ${entry.file.topic ?? ''}`) -@@ -941,6 +941,8 @@ export function install(rt) { +@@ -941,6 +941,8 @@ // desiredModel only falls back for models that are not served; so a failed call // is tried once more on the profile default. async function botShot(bot, system, text, tag) { @@ -4533,7 +4547,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js const own = await modelFor(bot) try { return await oneShot(own, system, text, tag) -@@ -1004,7 +1004,7 @@ export function install(rt) { +@@ -1004,7 +1004,7 @@ const prior = summaryFailures.get(bot.id) const failed = prior?.hash === hash && prior.model === bot.model ? prior : undefined const wanted = body.regenerate === true || (body.summarize === true && saved?.hash !== hash && failed === undefined) @@ -4542,7 +4556,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js return { botId: bot.id, entries, -@@ -1123,17 +1123,28 @@ export function install(rt) { +@@ -1123,17 +1123,28 @@ // What is new in a group, from its log: the member's Session holds the same // messages wrapped in turn prompts. @@ -4573,7 +4587,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js return { who, text: `${who === 'user' ? 'User' : who === 'you' ? 'You' : entry.who}: ${entry.text}`, seq: entry.seq } } return { -@@ -1158,7 +1158,7 @@ export function install(rt) { +@@ -1158,7 +1158,7 @@ return { texts: kept, count: kept.length } } @@ -4582,7 +4596,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js const actor = { id: bot.id, name: bot.name, session: sessionId, auto: true } const done = [] let failed -@@ -1166,7 +1166,7 @@ export function install(rt) { +@@ -1166,7 +1166,7 @@ // A Bot deleted mid-review keeps no more changes, its team ones included. if (rt.botOf(bot.id) === undefined) return 0 // Its role may change mid-review too, so each change asks again. @@ -4591,7 +4605,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js const op = String(change?.op ?? '') const topic = op === 'add' && change?.topic !== undefined ? cleanTopic(String(change.topic)) : undefined const target = numbered[Number(change?.entry) - 1] -@@ -1272,7 +1272,7 @@ export function install(rt) { +@@ -1272,7 +1272,7 @@ const badTopic = applied.find(change => change.op === 'add' && typeof change.topic === 'string' && change.topic.trim() !== '' && cleanTopic(change.topic) === undefined) if (badTopic !== undefined) throw new Error(`the model's answer names the topic "${clip(badTopic.topic, 60)}", which cannot be a topic name`) if (rt.botOf(bot.id) === undefined) return @@ -4600,7 +4614,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js if (rt.botOf(bot.id) === undefined) return // An answer at the limit may have left some out, so the same lines are read // again, a few times at most, while each pass still changes something. -@@ -1378,8 +1378,29 @@ export function install(rt) { +@@ -1378,8 +1378,29 @@ throw new Error(`Unknown endpoint ${endpoint}`) } @@ -4902,15 +4916,16 @@ 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 { 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 -@@ -51,7 +51,7 @@ export function install(rt) { +@@ -51,7 +51,7 @@ // What the last run left; read before this run notes anything. let interrupted = readNotes(file) @@ -4919,7 +4934,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js let turns = {} /** @type {Record} deliveries no turn has taken up, by Session */ let queued = {} -@@ -76,6 +76,10 @@ export function install(rt) { +@@ -76,6 +76,10 @@ await writeFile(file, snapshot, 'utf8') }).catch(error => rt.warn('ds-bot: noting running turns failed: %s', error)) } @@ -4930,7 +4945,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js const update = (id, change) => { const next = change(turns[id]) if (next === turns[id]) return -@@ -84,26 +84,33 @@ export function install(rt) { +@@ -84,26 +84,33 @@ else turns[id] = next persist() } @@ -4975,7 +4990,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js try { return await deliver(noted) } catch (error) { -@@ -137,10 +137,13 @@ export function install(rt) { +@@ -137,10 +137,13 @@ case 'turn/start': { const owner = rt.groupOwners.get(id) const botId = owner?.botId ?? rt.chatOwners.get(id) @@ -4990,7 +5005,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js if (event.data?.source?.kind !== SOURCE_KIND) break // turns.js, installed earlier, has already claimed the turn's trigger. const info = rt.live.get(id)?.info -@@ -151,7 +151,9 @@ export function install(rt) { +@@ -151,7 +151,9 @@ case 'turn/end': { // DSH stops every turn as it shuts down; that one is picked up at the next start. const reason = event.data?.reason @@ -5001,7 +5016,18 @@ diff --git a/src/host/resume.js b/src/host/resume.js update(id, () => undefined) break } -@@ -173,17 +173,17 @@ export function install(rt) { +@@ -164,7 +164,9 @@ + + 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 @@ // A turn already picked up once and cut off again stays stopped: a restart loop // must not run it forever. if (record.resumed) return @@ -5022,7 +5048,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js } // A hard stop keeps the inbox, which the Session runs once it wakes; only what DSH -@@ -198,11 +198,50 @@ export function install(rt) { +@@ -198,11 +198,50 @@ && current(item.args) !== undefined && taken?.has(item.args.exchangeId) !== true) if (fresh.length === 0) return const target = current(fresh[0].args) @@ -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 @@ 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. @@ -5171,7 +5208,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js const TIME = /^([01]\d|2[0-3]):[0-5]\d$/ const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' }) -@@ -28,6 +28,28 @@ export function reminderText(text) { +@@ -28,6 +28,28 @@ return lines.length === 0 ? text : lines.map(prompt => `[Scheduled task] ${prompt}`).join('\n') } @@ -5200,7 +5237,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js // The create request that gives a stored record its timing again, for a move. function requestOf(record) { const base = { title: record.title, prompt: record.prompt } -@@ -39,6 +39,28 @@ function requestOf(record) { +@@ -39,6 +39,28 @@ return null } @@ -5229,7 +5266,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js // The settings form's timing as a create request selector. function selectorOf(when) { const zone = typeof when?.timeZone === 'string' && when.timeZone !== '' ? when.timeZone : 'UTC' -@@ -59,9 +59,11 @@ function selectorOf(when) { +@@ -59,9 +59,11 @@ return { weekly: { time: `${when.time}:00`, time_zone: zone, weekdays } } } case 'every': { @@ -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 @@ 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) @@ -5556,6 +5597,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 +5638,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') @@ -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..f76b56acd 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -139,7 +139,7 @@ diff --git a/src/host/home-lock.js b/src/host/home-lock.js diff --git a/src/host/roster.js b/src/host/roster.js --- a/src/host/roster.js +++ b/src/host/roster.js -@@ -5,6 +5,8 @@ import { join } from 'node:path' +@@ -5,6 +5,8 @@ import { MAX_AVATAR_IMAGE, ROOM_MAX_MEMBERS, ROOM_MODES, ROOM_NAME_MAX, ROOM_NOTICE_MAX } from './constants.js' import { currentColor, isColor, unquote } from './text.js' import { nameProblem } from '../shared/mentions.js' @@ -148,7 +148,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js export function install(rt) { const { ctx, config, home, defaults, state } = rt -@@ -15,7 +15,7 @@ export function install(rt) { +@@ -15,7 +15,7 @@ if (state.workspaceId && ctx.workspaceRegistry.get(state.workspaceId)) return state.workspaceId const path = config.workspace ?? join(home, 'workspace') await mkdir(path, { recursive: true }) @@ -157,7 +157,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js state.workspaceId = workspace.id await rt.save() return workspace.id -@@ -56,18 +56,49 @@ export function install(rt) { +@@ -56,18 +56,49 @@ return `${stem} ${index}` } @@ -215,7 +215,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js const id = await createSession(cleanName, { dir }) try { state.bots[id] = { -@@ -80,45 +80,48 @@ export function install(rt) { +@@ -80,45 +80,48 @@ ...(modelRef ? { model: modelRef } : {}), ...(effortId ? { effort: effortId } : {}), ...(dir ? { workDir: dir } : {}), @@ -288,7 +288,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js // A save that another change queued meanwhile still carries this Bot; a later // one writes the team without it. void rt.save().catch(() => {}) -@@ -128,24 +128,12 @@ export function install(rt) { +@@ -128,24 +128,12 @@ } } @@ -315,7 +315,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js name: freshName(`${bot.name} copy`), role: bot.role, instructions: bot.instructions, -@@ -155,7 +155,6 @@ export function install(rt) { +@@ -155,7 +155,6 @@ effort: served ? bot.effort : undefined, // A folder that went away since leaves the copy in the team workspace. workDir: bot.workDir !== undefined && await rt.isFolder(bot.workDir) ? bot.workDir : undefined, @@ -323,7 +323,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js }) } -@@ -189,6 +189,8 @@ export function install(rt) { +@@ -189,6 +189,8 @@ if (avatar) bot.avatar = avatar else delete bot.avatar } @@ -332,7 +332,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js if (patch.model !== undefined) { if (patch.model === null || patch.model === '') delete bot.model else bot.model = await rt.resolveModel(patch.model) -@@ -212,17 +212,11 @@ export function install(rt) { +@@ -212,17 +212,11 @@ await rt.save() if (modelChanged) await rt.applyModel(bot, { force: true }) if (moved) void rt.followFolder(bot.id) @@ -351,7 +351,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js if (entry === undefined) throw new Error('Unknown conversation') if (typeof pinned === 'boolean') entry.pinned = pinned if (typeof hidden === 'boolean') entry.hidden = hidden -@@ -235,7 +235,14 @@ export function install(rt) { +@@ -235,7 +235,14 @@ // admin may change its own group but not delete it. Only a member can be admin. const roomBusy = id => [...rt.roomProgress.values()].some(round => round.roomId === id) @@ -367,7 +367,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js const cleanNotice = text => String(text ?? '').trim().slice(0, ROOM_NOTICE_MAX) function cleanRoomName(text, selfId) { -@@ -257,7 +257,7 @@ export function install(rt) { +@@ -257,7 +257,7 @@ async function followMemberNames(rooms = Object.values(state.rooms)) { for (const room of rooms) { if (room.named) continue @@ -376,7 +376,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js if (next === room.name) continue room.name = next await retitleRoom(room) -@@ -269,9 +269,24 @@ export function install(rt) { +@@ -269,9 +269,24 @@ if (members.length > ROOM_MAX_MEMBERS) throw new Error(`A group chat holds at most ${ROOM_MAX_MEMBERS} Bots`) } @@ -404,7 +404,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js const custom = cleanRoomName(roomName ?? '') // A Main Bot leads the groups it makes; a group the user makes is led by a Main // Bot member when it has one. -@@ -294,7 +294,19 @@ export function install(rt) { +@@ -294,7 +294,19 @@ createdAt: Date.now(), createdBy: createdBy ?? null, } @@ -424,7 +424,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js return state.rooms[id] } catch (error) { // The group's Session was already made: archive it instead of leaving an orphan. -@@ -306,13 +306,19 @@ export function install(rt) { +@@ -306,13 +306,19 @@ } } @@ -447,7 +447,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js let members = Array.isArray(patch.members) ? [...new Set(patch.members)] : [...room.members] for (const member of patch.add ?? []) if (!members.includes(member)) members.push(member) members = members.filter(member => !(patch.remove ?? []).includes(member) && rt.botOf(member)) -@@ -328,6 +328,14 @@ export function install(rt) { +@@ -328,6 +328,14 @@ admin = null } if (patch.mode !== undefined && !ROOM_MODES.includes(patch.mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`) @@ -462,7 +462,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js const name = patch.name === undefined ? undefined : cleanRoomName(patch.name, room.id) const notice = patch.notice === undefined ? undefined : cleanNotice(patch.notice) const moving = patch.workDir !== undefined -@@ -340,14 +340,24 @@ export function install(rt) { +@@ -340,14 +340,24 @@ const removed = room.members.filter(member => !members.includes(member)) if (added.length > 0) changes.push(`added ${names(added)}`) if (removed.length > 0) changes.push(`removed ${names(removed)}`) @@ -492,7 +492,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js if (patch.mode !== undefined && patch.mode !== room.mode) { room.mode = patch.mode changes.push(patch.mode === 'admin' ? 'plain messages now go to the admin first' : 'every member now replies in turn') -@@ -371,7 +371,13 @@ export function install(rt) { +@@ -371,7 +371,13 @@ delete room.named } } @@ -507,7 +507,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js if (room.name !== before) { changes.push(`renamed it "${room.name}"`) await retitleRoom(room) -@@ -383,8 +383,11 @@ export function install(rt) { +@@ -383,8 +383,11 @@ async function deleteRoom(id) { const room = rt.roomOf(id) if (room === undefined) throw new Error('Unknown group chat') @@ -519,7 +519,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js rt.roomInbox.delete(id) const failed = await saveForDelete() // Armed scheduled tasks refuse a plain archive and would keep delivering into -@@ -409,6 +409,10 @@ export function install(rt) { +@@ -409,6 +409,10 @@ } else if (replace !== undefined && replace !== id) { const at = others.indexOf(replace) if (at === -1) throw new Error('Only a Main Bot can be replaced') @@ -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 @@ } // 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,12 +560,20 @@ 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) { -@@ -434,22 +434,42 @@ export function install(rt) { + const bot = rt.botOf(id) + if (bot === undefined) throw new Error('Unknown Bot') +@@ -434,22 +434,42 @@ const current = rt.chatOf(id) // Questions and exchange turns are kept per Session, so every part of the chat is cleared. const parts = new Set([id, current, ...(bot.segments ?? []).map(segment => segment.sessionId)]) @@ -608,7 +616,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js const failed = await saveForDelete() // Same as deleteRoom: stopActivity removes the Session's armed scheduled tasks. // Past parts are archived too: a part move can leave tasks on the old Session, -@@ -481,7 +481,9 @@ export function install(rt) { +@@ -481,7 +481,9 @@ if (state.mainBotIds.some(id => rt.botOf(id))) return // Only the team's first Main Bot is stored as the DeepSeek whale; other Bots get a // look from their id, and a Bot made Main Bot later keeps its own. @@ -619,7 +627,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js .finally(() => { bootstrapping = null }) await bootstrapping } -@@ -489,6 +489,6 @@ export function install(rt) { +@@ -489,6 +489,6 @@ Object.assign(rt, { ensureWorkspace, createSession, createBot, duplicateBot, updateBot, setFlags, roomBusy, autoRoomName, retitleRoom, createRoom, updateRoom, deleteRoom, @@ -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 @@ -680,7 +693,7 @@ diff --git a/src/host/store.js b/src/host/store.js const EMPTY = JSON.stringify(state) // The other dsh host that holds state.lock while this one only reads (see home-lock.js); -@@ -50,12 +50,88 @@ export function install(rt) { +@@ -50,12 +50,88 @@ } } const readOnly = () => { @@ -770,7 +783,7 @@ diff --git a/src/host/store.js b/src/host/store.js const readFileState = async () => { let contents try { -@@ -87,10 +87,32 @@ export function install(rt) { +@@ -87,10 +87,32 @@ return undefined } if (parsed?.version === 1) { @@ -804,7 +817,7 @@ diff --git a/src/host/store.js b/src/host/store.js return undefined } -@@ -104,6 +104,20 @@ export function install(rt) { +@@ -104,6 +104,20 @@ // Teams saved before the DSH Agent existed carry no `agents`; corrupt values reset. if (state.agents === null || typeof state.agents !== 'object' || Array.isArray(state.agents)) state.agents = {} let migrated = !hadCapabilities @@ -825,7 +838,7 @@ diff --git a/src/host/store.js b/src/host/store.js if (state.capabilities === null || typeof state.capabilities !== 'object' || Array.isArray(state.capabilities)) { state.capabilities = { items: {} } migrated = true -@@ -112,6 +112,11 @@ export function install(rt) { +@@ -112,6 +112,11 @@ migrated = true } for (const bot of Object.values(state.bots)) { @@ -837,7 +850,7 @@ diff --git a/src/host/store.js b/src/host/store.js if (!Array.isArray(bot.capabilities)) { bot.capabilities = [] migrated = true -@@ -120,6 +120,11 @@ export function install(rt) { +@@ -120,6 +120,11 @@ bot.color = currentColor(bot.color) migrated = true } @@ -849,7 +862,7 @@ diff --git a/src/host/store.js b/src/host/store.js // LEGACY: Bots saved with a lab ran on that lab's default model; the model they // last ran on becomes their own, so dropping the lab changes nothing for them. if ('lab' in bot) { -@@ -161,12 +161,369 @@ export function install(rt) { +@@ -161,12 +161,369 @@ migrated = true } // Unnamed groups from before names followed their members may still list old names. @@ -1220,7 +1233,7 @@ diff --git a/src/host/store.js b/src/host/store.js const load = () => loaded ??= (async () => { claimLock() if (holder !== undefined) warn('ds-bot: %s', readOnly()) -@@ -185,14 +185,24 @@ export function install(rt) { +@@ -185,14 +185,24 @@ for (const room of stale) void rt.retitleRoom(room) })() @@ -1246,7 +1259,7 @@ diff --git a/src/host/store.js b/src/host/store.js warnedReadOnly = false adopt(await readFileState()) return -@@ -235,11 +235,37 @@ export function install(rt) { +@@ -235,11 +235,37 @@ return warnReadOnly() } @@ -1284,7 +1297,7 @@ diff --git a/src/host/store.js b/src/host/store.js state.revision += 1 state.exchanges = state.exchanges.slice(-MAX_EXCHANGES) const snapshot = JSON.stringify(state, null, 2) -@@ -260,9 +260,10 @@ export function install(rt) { +@@ -260,9 +260,10 @@ ensureLock() await rename(temp, statePath) seen = stamp() @@ -1296,7 +1309,7 @@ diff --git a/src/host/store.js b/src/host/store.js throw error } }) -@@ -273,13 +273,37 @@ export function install(rt) { +@@ -273,13 +273,37 @@ }) } @@ -1336,7 +1349,7 @@ diff --git a/src/host/store.js b/src/host/store.js const isMain = id => id !== undefined && state.mainBotIds.includes(id) // Group Sessions. A member speaks in each group from a hidden Session of its own, -@@ -313,6 +313,13 @@ export function install(rt) { +@@ -313,6 +313,13 @@ } for (const [botId, sessionId] of Object.entries(room.sessions ?? {})) groupOwners.set(sessionId, { botId, roomId: room.id }) } @@ -1350,7 +1363,7 @@ diff --git a/src/host/store.js b/src/host/store.js // The agents module owns the DSH Agent session index; it installs after this. rt.indexAgents?.() } -@@ -328,6 +328,8 @@ export function install(rt) { +@@ -328,6 +328,8 @@ delete room.sessions[botId] if (room.segments !== undefined) delete room.segments[botId] groupOwners.delete(sessionId) @@ -1359,7 +1372,7 @@ diff --git a/src/host/store.js b/src/host/store.js rt.cancelApprovals?.(sessionId) // A plain archive refuses a running Session, which would go on working for a group // that no longer has it. -@@ -335,23 +335,29 @@ export function install(rt) { +@@ -335,23 +335,29 @@ } const findBot = (ref) => { if (ref === undefined) 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 @@ -1445,7 +1459,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js export const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] } export const GROUP_REF = { type: 'string', required: true, description: 'Exact group chat name from list_bots' } -@@ -18,6 +18,21 @@ const WORK_DIR_PARAM = { type: 'string', description: 'Full path of the folder t +@@ -18,6 +18,21 @@ export function install(rt) { const { ctx, state } = rt @@ -1467,7 +1481,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js const callerBot = (exec) => rt.botOf(rt.selfOf(exec.agent?.id)) const callerRoom = (exec) => { const roomId = rt.groupOwners.get(exec.agent?.id)?.roomId -@@ -27,39 +27,112 @@ export function install(rt) { +@@ -27,39 +27,112 @@ const roomLine = (room, self) => { const members = room.members.map(rt.botOf).filter(Boolean) .map(bot => `${bot.name}${bot.id === room.admin ? ' (admin)' : ''}${bot.id === self ? ' (you)' : ''}`) @@ -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 @@ // 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 @@ -1611,7 +1621,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js message: { type: 'string', required: true, description: 'Self-contained message; the other Bot cannot see this conversation' }, reply_to: { type: 'string', -@@ -104,8 +104,18 @@ export function install(rt) { +@@ -104,8 +104,18 @@ await rt.load() const sender = callerBot(exec) if (sender === undefined) return 'Only Bots on the team can use message_bot.' @@ -1632,7 +1642,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js if (target.id === sender.id) return 'You cannot message yourself.' const turn = rt.live.get(exec.agent?.id) const hop = Math.max(turn?.info?.hop ?? 0, ...(turn?.joined ?? []).map(joined => joined.info?.hop ?? 0)) -@@ -118,7 +118,11 @@ export function install(rt) { +@@ -118,7 +118,11 @@ const toUser = args.reply_to === 'user' const working = rt.live.get(chat)?.turn !== undefined const steer = args.when !== 'queue' && !toUser @@ -1645,7 +1655,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js const sent = toUser ? `Delivered to ${target.name}. It will answer the user in its own chat; nothing comes back to you, so point the user to ${target.name}'s chat instead of relaying.` : working && steer -@@ -152,8 +152,8 @@ export function install(rt) { +@@ -152,8 +152,8 @@ await rt.load() const sender = callerBot(exec) if (sender === undefined || !rt.isMain(sender.id)) refuse('Only a Main Bot can stop other Bots.') @@ -1656,7 +1666,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js if (target.id === sender.id) refuse('You cannot stop yourself.') const { running } = activeSessions(target.id) if (running.length === 0) refuse(`${target.name} is not running right now, so there is nothing to stop. Use message_bot to reach it.`) -@@ -162,21 +162,32 @@ export function install(rt) { +@@ -162,21 +162,32 @@ agent.cancel({ kind: 'hook', reason: 'ds-bot/stopped' }) } const why = String(args.reason ?? '').trim() || 'Stop what you are doing; the previous turn was ended on purpose. Acknowledge briefly.' @@ -1692,7 +1702,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js }, output, async execute(args, exec) { -@@ -186,8 +186,8 @@ export function install(rt) { +@@ -186,8 +186,8 @@ const workDir = args.work_dir ? await rt.cleanWorkDir(args.work_dir) : undefined const name = String(args.name ?? '').trim() await rt.approveFolder(exec, { path: workDir, who: { en: `the new Bot ${name}`, zh: `新 Bot ${name} ` } }) @@ -1703,7 +1713,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js } catch (error) { return `Could not create the Bot: ${error.message}` } -@@ -212,7 +212,7 @@ export function install(rt) { +@@ -212,7 +212,7 @@ const self = rt.selfOf(exec.agent?.id) const caller = rt.botOf(self) if (caller === undefined) return 'Only Bots on the team can use update_bot.' @@ -1712,11 +1722,10 @@ 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) { - }, +@@ -231,6 +231,34 @@ })) -+ ctx.tools.register(defineTool({ + ctx.tools.register(defineTool({ + name: 'archive_bot', + description: 'Main Bot only. Retire another Bot: it leaves the tree (freeing a maxChildren slot) and the default Bot list, while its chats, history and memory stay readable. It is never deleted and no parent moves; deleting a Bot stays the owner\'s own click in the UI.', + parameters: { @@ -1731,19 +1740,24 @@ 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}` + }, + })) + - ctx.tools.register(defineTool({ ++ ctx.tools.register(defineTool({ name: 'list_models', description: 'List the models a Bot can run on, as provider/model refs for create_bot and update_bot.', -@@ -251,13 +251,13 @@ export function install(rt) { + parameters: {}, +@@ -251,13 +251,13 @@ const bots = [] const unknown = [] for (const ref of nameList(refs)) { @@ -1759,7 +1773,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js const groupNames = self => visibleRooms(self).map(room => `"${room.name}"`).join(', ') || 'none' const pickRoom = (ref, self) => { const matches = rt.findRooms(ref).filter(room => visibleRooms(self).includes(room)) -@@ -283,7 +283,7 @@ export function install(rt) { +@@ -283,7 +283,7 @@ if (!rt.isMain(self)) return 'Only a Main Bot can create group chats. Ask a Main Bot instead.' const { bots, unknown } = resolveBots(args.members) if (unknown.length > 0) return `No Bot named ${unknown.join(', ')}. Team: ${teamNames()}` @@ -1768,7 +1782,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js if (admin === undefined) return `No Bot named ${args.admin}. Team: ${teamNames()}` const members = [...new Set([self, ...bots.map(bot => bot.id), admin.id])] try { -@@ -297,7 +297,7 @@ export function install(rt) { +@@ -297,7 +297,7 @@ ctx.tools.register(defineTool({ name: 'update_group', @@ -1777,7 +1791,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js parameters: { group: GROUP_REF, name: { type: 'string', description: 'New group name; an empty string names it after its members again' }, -@@ -305,6 +305,7 @@ export function install(rt) { +@@ -305,6 +305,7 @@ add_members: { type: 'array', items: { type: 'string' }, description: 'Exact names of Bots to add' }, remove_members: { type: 'array', items: { type: 'string' }, description: 'Exact names of Bots to remove' }, admin: { type: 'string', description: 'Member to make the admin; "none" leaves the group without one (Main Bots only)' }, @@ -1785,7 +1799,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js mode: MODE_PARAM, work_dir: { ...WORK_DIR_PARAM, description: `${WORK_DIR_PARAM.description}. Every member works there in this group; an empty string lets each member work in its own folder again (Main Bots only)` }, }, -@@ -325,10 +325,18 @@ export function install(rt) { +@@ -325,10 +325,18 @@ if (args.admin !== undefined && args.admin !== '') { if (/^(none|nobody|null|无|没有)$/i.test(String(args.admin).trim())) admin = null else { @@ -1805,7 +1819,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js try { const workDir = moving ? await rt.cleanWorkDir(args.work_dir) : undefined // A Bot that joins a group with a folder works there from its first turn. -@@ -345,6 +345,7 @@ export function install(rt) { +@@ -345,6 +345,7 @@ notice: args.notice, mode: args.mode, admin, @@ -1813,7 +1827,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js add: add.bots.map(bot => bot.id), remove: remove.bots.map(bot => bot.id), ...(moving ? { workDir: workDir ?? '' } : {}), -@@ -395,12 +395,11 @@ export function install(rt) { +@@ -395,12 +395,11 @@ if ((info?.hop ?? 0) >= MAX_HOPS) return 'This chain of Bot messages is already long. Stop here and report to the user instead.' const text = String(args.message ?? '').trim() if (text === '') return 'Write a message to post.' @@ -1829,7 +1843,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js } catch (failure) { return `Could not post in "${room.name}": ${failure.message}` } -@@ -445,6 +445,10 @@ export function install(rt) { +@@ -445,6 +445,10 @@ .filter(option => option.label !== '') .slice(0, 6) if (options.length === 0) return 'Give at least one option.' @@ -1840,7 +1854,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js // A hidden Bot-to-Bot turn shows none of its calls, so its card goes to the // composer dock, which takes questions without a call. const turn = rt.live.get(id)?.turn -@@ -465,6 +465,77 @@ export function install(rt) { +@@ -465,6 +465,77 @@ }, })) @@ -1918,7 +1932,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js // dsh's own question tool holds the turn open until the user answers, and every // message from another Bot or a group round queues behind it. Bot Sessions ask // through ask_user instead, so the tool is taken out of their tool list. -@@ -525,7 +525,7 @@ export function install(rt) { +@@ -525,7 +525,7 @@ name: 'save_capability', description: 'Create or update a shared MCP server, skill, or pack. Changes apply from your next message.', parameters: { @@ -1927,7 +1941,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js kind: { type: 'string', required: true, enum: ['mcp', 'skill', 'pack'] }, description: { type: 'string', description: 'What this capability provides' }, transport: { type: 'string', enum: ['stdio', 'streamable-http'], description: 'How to reach an MCP server: a local command or an HTTP URL' }, -@@ -545,7 +545,7 @@ export function install(rt) { +@@ -545,7 +545,7 @@ try { const item = await rt.capabilitySave(args, { botId: self }) const approval = item.kind === 'mcp' && item.transport === 'stdio' && !item.approved @@ -1936,7 +1950,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js : '' return `Saved ${item.name}. Bots with it attached use the change from their next message.${approval}` } catch (error) { return error.message } -@@ -587,7 +587,7 @@ export function install(rt) { +@@ -587,7 +587,7 @@ : attached ? [attached] : [] const approvals = members .filter(item => item.kind === 'mcp' && item.transport === 'stdio' && item.approved !== true) @@ -1945,7 +1959,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js .join('') return `${verb} ${args.name} ${args.bot ? `to ${result.bot}` : 'to yourself'}. Changes apply from ${result.bot}'s next message.${endpoint === 'attach' ? approvals : ''}` } catch (error) { return error.message } -@@ -595,5 +595,159 @@ export function install(rt) { +@@ -595,5 +595,159 @@ })) } diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 2ef13fd40..641b56315 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. @@ -880,7 +881,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js const ROOM_LOG_MAX = 60 const ROOM_FIRST_LOOK = 12 -@@ -31,6 +31,10 @@ const ROOM_EXTRA_TURNS = 4 +@@ -31,6 +31,10 @@ const ROOM_ADMIN_EXTRA_TURNS = 4 const ROOM_TURNS_EACH = 2 const ROOM_ADMIN_TURNS_EACH = 4 @@ -891,7 +892,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js // Logs written before rooms were versioned get sequence numbers in place. const ensureRoomLog = (room) => { -@@ -41,9 +41,67 @@ const ensureRoomLog = (room) => { +@@ -41,9 +41,67 @@ room.seen ??= {} } @@ -960,7 +961,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } // A member's own lines are already in its Session, so only the others' are news. -@@ -58,6 +58,9 @@ const lineText = (line) => { +@@ -58,6 +58,9 @@ if (line.kind === 'resumed') return `[${line.who}'s work here was cut off when DeepSeek Harness restarted; it is picking it back up and posts when done]` if (line.kind === 'cut') return `[${line.who}'s work here was cut off by a restart again, so it stopped; ask it to go on if it still matters]` if (line.kind === 'stopped') return `[The user stopped ${line.who}'s work here]` @@ -970,7 +971,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js return `${line.who}: ${line.text}` } const transcript = lines => lines.map(lineText).join('\n') -@@ -86,12 +86,33 @@ export const referenceNote = references => (references?.length +@@ -86,12 +86,33 @@ : '') const ROOM_REPLY = 'Reply with your message for the group, no name prefix: in a discussion, one or two short messages. When you are asked to do something, do it now with your tools. The group does not wait for you, and your final reply is posted here when you finish, so give the result, never a promise to do it later. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.' @@ -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 @@ return slot.name } @@ -1046,12 +1047,6 @@ diff --git a/src/host/groups.js b/src/host/groups.js // A member's group Session, created on its first turn there. A new one starts with // a fresh look at the log and a brief of the member's own chat; the brief is the // first message, so it stays fixed and the prefix stays cacheable. -- async function groupSessionFor(room, member) { -- room.sessions ??= {} -- const known = room.sessions[member.id] -- if (known !== undefined) { -- await rt.ensureCapabilities(known) -- return { id: known, brief: undefined } + // The brief is the member's owner's private chat with the Bot. In a room where + // another owner sits it stays out unless that owner shared it with this room (#53). + const briefAllowed = (room, member) => { @@ -1063,7 +1058,19 @@ diff --git a/src/host/groups.js b/src/host/groups.js + // A granted remote Bot speaks for another home; its handle stands for that owner. + else if (p.remote) owners.add(p.handle) + else owners.add(rt.botOf(p.id)?.ownerId) - } ++ } ++ owners.delete(undefined) ++ if (owners.size <= 1) return true ++ return participants.some(p => p.kind === 'bot' && p.id === member.id && p.briefShared === true) ++ } ++ + async function groupSessionFor(room, member) { +- room.sessions ??= {} +- const known = room.sessions[member.id] +- if (known !== undefined) { +- await rt.ensureCapabilities(known) +- return { id: known, brief: undefined } +- } - const id = await rt.createSession(`${member.name} · ${room.name}`, { dir: rt.workDirOf({ botId: member.id, roomId: room.id }) }) - room.sessions[member.id] = id - rt.groupOwners.set(id, { botId: member.id, roomId: room.id }) @@ -1072,12 +1079,6 @@ diff --git a/src/host/groups.js b/src/host/groups.js - if (ref !== undefined) { - await rt.selectSessionModel(id, splitRef(ref)) - .catch(error => rt.warn('ds-bot: model for %s in %s not applied: %s', member.name, room.name, error)) -+ owners.delete(undefined) -+ if (owners.size <= 1) return true -+ return participants.some(p => p.kind === 'bot' && p.id === member.id && p.briefShared === true) -+ } -+ -+ async function groupSessionFor(room, member) { + const session = await participant.open({ room, member }) + return { id: session.id, brief: session.created && briefAllowed(room, member) ? await rt.ownChatBrief(member) : undefined } + } @@ -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. @@ -1353,7 +1358,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js if (by === undefined) delete called.by else called.by = by round.slots.splice(next, 0, called) -@@ -285,23 +285,185 @@ export function install(rt) { +@@ -285,34 +285,204 @@ // Session's inbox, so it does not open another round when this one ends. Returns // the number of turns added, which the round's limit allows for. function drainRoomInbox(room, round, turns, index, agent) { @@ -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. @@ -1405,11 +1411,11 @@ diff --git a/src/host/groups.js b/src/host/groups.js - // The user spoke in this room, so Bots may start their own rounds again. - room.botRounds = 0 + // The user spoke in this room, so late replies may be answered again. -+ room.lateRounds = 0 -+ void rt.save().catch(() => {}) -+ return round.slots.length - before -+ } -+ + room.lateRounds = 0 + void rt.save().catch(() => {}) + return round.slots.length - before + } + + // One line that reaches the round while a member speaks (#1267). It is logged at once — the room + // shows it when it is sent, not at the next slot — and `notePostedLine` keeps the next relay from + // logging it a second time. Its pending copy leaves the relay's queue, because this round answers @@ -1422,8 +1428,8 @@ diff --git a/src/host/groups.js b/src/host/groups.js + if (at !== -1) rt.roomInbox.set(room.id, queued.toSpliced(at, 1)) + round.replies.push({ kind: 'user', text: line.text, time: Date.now(), authorId: line.authorId, authorKind: line.authorKind, name: line.authorName }) + // The user spoke in this room, so late replies may be answered again. - room.lateRounds = 0 - void rt.save().catch(() => {}) ++ room.lateRounds = 0 ++ void rt.save().catch(() => {}) + // The one line that ends work in flight (#1267 B): the slot is superseded, its turn is cancelled, + // and nothing it says when it stops is posted. The line stays in the Session's queue, so the head + // drain opens it as a fresh line (admin first) for the room to answer. @@ -1543,10 +1549,11 @@ diff --git a/src/host/groups.js b/src/host/groups.js + const uncapped = new Set(callees.filter(bot => rt.isAdminOf(room, bot.id)).map(bot => bot.id)) + callMembers(round, turns, index - 1, callees, `${call.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, call.by, uncapped, call.text) + } - return round.slots.length - before - } - -@@ -310,9 +310,17 @@ export function install(rt) { ++ return round.slots.length - before ++ } ++ + async function runRound(room, round, signal, agent) { + let limit = round.slots.length + ROOM_EXTRA_TURNS + (room.admin ? ROOM_ADMIN_EXTRA_TURNS : 0) const turns = new Map() // The inbox check keeps a message sent during the last speaker's turn inside // this round: it drains at the loop's head even once the slots ran out. @@ -1565,7 +1572,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js if (index >= round.slots.length) break const slot = round.slots[index] const member = rt.botOf(slot.id) -@@ -321,6 +321,20 @@ export function install(rt) { +@@ -321,6 +321,20 @@ slot.status = 'left' continue } @@ -1586,7 +1593,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js slot.status = 'speaking' round.speaking = member.id round.turns += 1 -@@ -339,8 +339,23 @@ export function install(rt) { +@@ -339,8 +339,23 @@ const prompt = roomPrompt(room, member, round) round.shown?.push(member.id) const text = session.brief === undefined ? prompt : `${session.brief}\n\n${prompt}` @@ -1611,7 +1618,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 @@ if (reply?.missed) round.replies.push({ kind: 'missed', botId: member.id, name: member.name, color: member.color, time: Date.now() }) continue } @@ -1632,7 +1639,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 @@ -1645,7 +1654,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } } -@@ -397,38 +397,52 @@ export function install(rt) { +@@ -397,38 +397,52 @@ rt.roomInbox.delete(room.id) const posts = queued.length > 0 ? queued : [{ text: '' }] const startSeq = room.seq + 1 @@ -1684,7 +1693,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. @@ -1718,7 +1727,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js speaking: null, turns: 0, replies: [], -@@ -436,6 +436,7 @@ export function install(rt) { +@@ -436,6 +436,7 @@ failed: [], started: [], } @@ -1726,7 +1735,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js for (const post of posts.filter(item => item.failed)) { const bot = rt.botOf(post.botId) const { code, status, message } = post.failed -@@ -450,17 +450,25 @@ export function install(rt) { +@@ -450,17 +450,25 @@ appendRoom(room, { who: name, botId: post.botId, kind, text: '' }) round.replies.push({ kind, botId: post.botId, name, color: bot?.color, time: Date.now() }) } @@ -1753,7 +1762,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } await rt.save() for (const post of posts) { -@@ -471,6 +471,41 @@ export function install(rt) { +@@ -471,6 +471,41 @@ }, })) @@ -1795,7 +1804,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js // Private context enters a group only through this tool, and the group sees each use. ctx.tools.register(defineTool({ name: 'read_own_chat', -@@ -539,7 +539,9 @@ export function install(rt) { +@@ -539,7 +539,9 @@ const { room, error } = rt.pickRoom(args.group, self) if (error) return error ensureRoomLog(room) @@ -1806,7 +1815,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js return lines.length === 0 ? `"${room.name}" has no messages yet.` : `Latest in "${room.name}" (oldest first):\n${transcript(lines)}` }, })) -@@ -548,46 +548,264 @@ export function install(rt) { +@@ -548,46 +548,264 @@ // the admin answers alone in admin mode, or first of everyone. What a member posts // after its round moved on is answered by the member that asked for it; work the // user asked for is left for the user to read. @@ -1857,9 +1866,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 +1974,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) { @@ -2076,7 +2086,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js .catch(error => rt.warn('ds-bot: late group reply from %s failed: %s', member.name, error)) } rt.lateRoomReply = lateRoomReply -@@ -599,9 +599,8 @@ export function install(rt) { +@@ -599,9 +599,8 @@ const room = rt.roomOf(roomId) const member = rt.botOf(botId) const sessionId = room?.sessions?.[botId] @@ -2088,7 +2098,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js await postToRoom(room, member, '', { quiet: true, stopped: true }) return true } -@@ -615,37 +615,42 @@ export function install(rt) { +@@ -615,37 +615,42 @@ const member = rt.botOf(owner?.botId) if (room === undefined || member === undefined || !room.members.includes(member.id)) return const { code, status, message } = failure @@ -2147,7 +2157,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js .catch(error => rt.warn('ds-bot: group report to %s failed: %s', post.name, error)) } -@@ -665,7 +665,7 @@ export function install(rt) { +@@ -665,7 +665,7 @@ // message from the user, a scheduled task, or a Bot's post after the last model or // tool step starts a relay. const opens = message => message.role === 'user' 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') +}) From ea104d4106d37aa19cfa631784db2dc7edf07408 Mon Sep 17 00:00:00 2001 From: Dev-3 Date: Fri, 30 Oct 2026 04:56:09 +0000 Subject: [PATCH 2/2] chore(patches): re-cut the series with generate after the rebase onto 84385f2f --- patches/ds-bot/0001-core-fixes.patch | 164 +++++++++++----------- patches/ds-bot/0002-flybot-api-host.patch | 136 +++++++++--------- patches/ds-bot/0012-room-membership.patch | 85 ++++++----- 3 files changed, 192 insertions(+), 193 deletions(-) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index d167c8faa..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 不能列出文件夹', @@ -3008,7 +3008,7 @@ diff --git a/src/host/capabilities.js b/src/host/capabilities.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,7 +3028,7 @@ 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] -@@ -154,9 +154,10 @@ +@@ -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 @@ -3041,7 +3041,7 @@ diff --git a/src/host/capabilities.js b/src/host/capabilities.js 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 => { @@ -3050,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() @@ -3059,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 ?? '') @@ -4395,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,12 @@ +@@ -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' @@ -4409,7 +4409,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js // Fixed sizes, in characters. Both MEMORY.md files ride in every request of every // part, so together they stay near 8,000 characters, a few thousand tokens. A topic is -@@ -312,7 +312,7 @@ +@@ -312,7 +312,7 @@ const teamRule = (main, writer) => (main ? '- The team memory is shared by every Bot. As a group admin you write it with remember scope "team", for what the whole team needs.' : '- The team memory is shared by every Bot. Main Bots and group admins write it; when the whole team should know something, tell a Main Bot with message_bot.') @@ -4418,7 +4418,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js const summaryRequest = ({ name, own, team, language }) => [ `Here is everything ${name} remembers: its own memory, then the team memory that every Bot shares. Each line is one entry; [[name]] is a topic.`, -@@ -327,7 +327,7 @@ +@@ -327,7 +327,7 @@ const summaryRequest = ({ name, own, team, language }) => [ ].join('\n') const ASK_SYSTEM = [ @@ -4427,7 +4427,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js 'Reply with one JSON object and nothing else: {"reply": "…", "changes": […]}', '- reply: one to three short sentences to the user. Say what you changed, or answer the question; when the entries do not say, say so. The user does not see the entry numbers, so never mention them.', '- changes: empty unless the user asks to add, correct, or remove something. Each change is one of', -@@ -338,7 +338,7 @@ +@@ -338,7 +338,7 @@ const ASK_SYSTEM = [ ].join('\n') const reviewSystem = teamWrites => [ @@ -4436,7 +4436,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js 'Reply with one JSON object and nothing else: {"changes": […]}, with an empty list when nothing should change.', `At most ${REVIEW_CHANGES_MAX} changes in one answer, the most lasting first; the rest can wait for the next review.`, 'Keep, as a new entry:', -@@ -486,7 +486,24 @@ +@@ -486,7 +486,24 @@ export function install(rt) { // Main Bots and the admins of a group also write the team's; Main Bots read and // write every Bot's. The user may do everything from the memory panel. @@ -4462,7 +4462,7 @@ 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 @@ +@@ -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) @@ -4471,7 +4471,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js return { scope: ownScope(bot.id) } } function writeDenied(scope, botId) { -@@ -632,7 +632,7 @@ +@@ -632,7 +632,7 @@ export function install(rt) { news.delete(botId) return queued(async () => { if (rt.readOnly() !== undefined) return @@ -4480,7 +4480,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js const dir = dirOf(ownScope(botId)) if (!existsSync(dir)) return const name = `${safeId(botId)}-${new Date().toISOString().replace(/[:.]/g, '-')}` -@@ -837,11 +837,13 @@ +@@ -837,11 +837,13 @@ export function install(rt) { ctx.tools.register(defineTool({ name: 'recall', @@ -4495,7 +4495,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js }, output, async execute(args, exec) { -@@ -861,6 +861,20 @@ +@@ -861,6 +861,20 @@ export function install(rt) { const known = loaded.flatMap(({ tag, files }) => files.filter(file => file.topic).map(file => `[[${file.topic}]] (${tag})`)) return `No topic [[${String(args.topic).slice(0, 60)}]] there. Topics: ${known.join(', ') || 'none yet'}.` } @@ -4516,7 +4516,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js const where = (tag, file) => `${tag} · ${file.topic === undefined ? MAIN : `[[${file.topic}]]`}` const query = String(args.query ?? '').trim().slice(0, 120) if (query === '') { -@@ -872,6 +872,21 @@ +@@ -872,6 +872,21 @@ export function install(rt) { ].join('\n') }).join('\n\n') } @@ -4538,7 +4538,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js const terms = [...new Set(squash(query).split(' ').filter(Boolean))].slice(0, QUERY_TERMS_MAX) const scored = loaded.flatMap(({ tag, files }) => allEntries(files).map((entry) => { const hay = squash(`${entry.text} ${entry.file.topic ?? ''}`) -@@ -941,6 +941,8 @@ +@@ -941,6 +941,8 @@ export function install(rt) { // desiredModel only falls back for models that are not served; so a failed call // is tried once more on the profile default. async function botShot(bot, system, text, tag) { @@ -4547,7 +4547,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js const own = await modelFor(bot) try { return await oneShot(own, system, text, tag) -@@ -1004,7 +1004,7 @@ +@@ -1004,7 +1004,7 @@ export function install(rt) { const prior = summaryFailures.get(bot.id) const failed = prior?.hash === hash && prior.model === bot.model ? prior : undefined const wanted = body.regenerate === true || (body.summarize === true && saved?.hash !== hash && failed === undefined) @@ -4556,7 +4556,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js return { botId: bot.id, entries, -@@ -1123,17 +1123,28 @@ +@@ -1123,17 +1123,28 @@ export function install(rt) { // What is new in a group, from its log: the member's Session holds the same // messages wrapped in turn prompts. @@ -4587,7 +4587,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js return { who, text: `${who === 'user' ? 'User' : who === 'you' ? 'You' : entry.who}: ${entry.text}`, seq: entry.seq } } return { -@@ -1158,7 +1158,7 @@ +@@ -1158,7 +1158,7 @@ export function install(rt) { return { texts: kept, count: kept.length } } @@ -4596,7 +4596,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js const actor = { id: bot.id, name: bot.name, session: sessionId, auto: true } const done = [] let failed -@@ -1166,7 +1166,7 @@ +@@ -1166,7 +1166,7 @@ export function install(rt) { // A Bot deleted mid-review keeps no more changes, its team ones included. if (rt.botOf(bot.id) === undefined) return 0 // Its role may change mid-review too, so each change asks again. @@ -4605,7 +4605,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js const op = String(change?.op ?? '') const topic = op === 'add' && change?.topic !== undefined ? cleanTopic(String(change.topic)) : undefined const target = numbered[Number(change?.entry) - 1] -@@ -1272,7 +1272,7 @@ +@@ -1272,7 +1272,7 @@ export function install(rt) { const badTopic = applied.find(change => change.op === 'add' && typeof change.topic === 'string' && change.topic.trim() !== '' && cleanTopic(change.topic) === undefined) if (badTopic !== undefined) throw new Error(`the model's answer names the topic "${clip(badTopic.topic, 60)}", which cannot be a topic name`) if (rt.botOf(bot.id) === undefined) return @@ -4614,7 +4614,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js if (rt.botOf(bot.id) === undefined) return // An answer at the limit may have left some out, so the same lines are read // again, a few times at most, while each pass still changes something. -@@ -1378,8 +1378,29 @@ +@@ -1378,8 +1378,29 @@ export function install(rt) { throw new Error(`Unknown endpoint ${endpoint}`) } @@ -4916,7 +4916,7 @@ 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,8 @@ +@@ -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' @@ -4925,7 +4925,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js // Long enough for DSH to finish opening its workspaces after a start. const RESUME_DELAY_MS = 3000 -@@ -51,7 +51,7 @@ +@@ -51,7 +51,7 @@ export function install(rt) { // What the last run left; read before this run notes anything. let interrupted = readNotes(file) @@ -4934,7 +4934,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js let turns = {} /** @type {Record} deliveries no turn has taken up, by Session */ let queued = {} -@@ -76,6 +76,10 @@ +@@ -76,6 +76,10 @@ export function install(rt) { await writeFile(file, snapshot, 'utf8') }).catch(error => rt.warn('ds-bot: noting running turns failed: %s', error)) } @@ -4945,7 +4945,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js const update = (id, change) => { const next = change(turns[id]) if (next === turns[id]) return -@@ -84,26 +84,33 @@ +@@ -84,26 +84,33 @@ export function install(rt) { else turns[id] = next persist() } @@ -4990,7 +4990,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js try { return await deliver(noted) } catch (error) { -@@ -137,10 +137,13 @@ +@@ -137,10 +137,13 @@ export function install(rt) { case 'turn/start': { const owner = rt.groupOwners.get(id) const botId = owner?.botId ?? rt.chatOwners.get(id) @@ -5005,7 +5005,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js if (event.data?.source?.kind !== SOURCE_KIND) break // turns.js, installed earlier, has already claimed the turn's trigger. const info = rt.live.get(id)?.info -@@ -151,7 +151,9 @@ +@@ -151,7 +151,9 @@ export function install(rt) { case 'turn/end': { // DSH stops every turn as it shuts down; that one is picked up at the next start. const reason = event.data?.reason @@ -5016,7 +5016,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js update(id, () => undefined) break } -@@ -164,7 +164,9 @@ +@@ -164,7 +164,9 @@ export function install(rt) { async function resumeTurn(sessionId, record) { const bot = rt.botOf(record.botId) @@ -5027,7 +5027,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js const text = resumeText(record) if (record.roomId !== undefined) { const room = rt.roomOf(record.roomId) -@@ -173,17 +173,17 @@ +@@ -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. if (record.resumed) return @@ -5048,7 +5048,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js } // A hard stop keeps the inbox, which the Session runs once it wakes; only what DSH -@@ -198,11 +198,50 @@ +@@ -198,11 +198,50 @@ export function install(rt) { && current(item.args) !== undefined && taken?.has(item.args.exchangeId) !== true) if (fresh.length === 0) return const target = current(fresh[0].args) @@ -5101,7 +5101,7 @@ diff --git a/src/host/resume.js b/src/host/resume.js } } -@@ -211,26 +211,50 @@ +@@ -211,26 +211,50 @@ export function install(rt) { if (rt.readOnly() !== undefined) return const { turns: noted, queued: dropped } = interrupted interrupted = { turns: {}, queued: {} } @@ -5208,7 +5208,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js const TIME = /^([01]\d|2[0-3]):[0-5]\d$/ const invalid = message => Object.assign(new Error(message), { code: 'bot/invalid' }) -@@ -28,6 +28,28 @@ +@@ -28,6 +28,28 @@ export function reminderText(text) { return lines.length === 0 ? text : lines.map(prompt => `[Scheduled task] ${prompt}`).join('\n') } @@ -5237,7 +5237,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js // The create request that gives a stored record its timing again, for a move. function requestOf(record) { const base = { title: record.title, prompt: record.prompt } -@@ -39,6 +39,28 @@ +@@ -39,6 +39,28 @@ function requestOf(record) { return null } @@ -5266,7 +5266,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js // The settings form's timing as a create request selector. function selectorOf(when) { const zone = typeof when?.timeZone === 'string' && when.timeZone !== '' ? when.timeZone : 'UTC' -@@ -59,9 +59,11 @@ +@@ -59,9 +59,11 @@ function selectorOf(when) { return { weekly: { time: `${when.time}:00`, time_zone: zone, weekdays } } } case 'every': { @@ -5281,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,543 @@ +@@ -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) @@ -5526,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). @@ -5677,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) => { diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index f76b56acd..3a3e55ac1 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -139,7 +139,7 @@ diff --git a/src/host/home-lock.js b/src/host/home-lock.js diff --git a/src/host/roster.js b/src/host/roster.js --- a/src/host/roster.js +++ b/src/host/roster.js -@@ -5,6 +5,8 @@ +@@ -5,6 +5,8 @@ import { join } from 'node:path' import { MAX_AVATAR_IMAGE, ROOM_MAX_MEMBERS, ROOM_MODES, ROOM_NAME_MAX, ROOM_NOTICE_MAX } from './constants.js' import { currentColor, isColor, unquote } from './text.js' import { nameProblem } from '../shared/mentions.js' @@ -148,7 +148,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js export function install(rt) { const { ctx, config, home, defaults, state } = rt -@@ -15,7 +15,7 @@ +@@ -15,7 +15,7 @@ export function install(rt) { if (state.workspaceId && ctx.workspaceRegistry.get(state.workspaceId)) return state.workspaceId const path = config.workspace ?? join(home, 'workspace') await mkdir(path, { recursive: true }) @@ -157,7 +157,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js state.workspaceId = workspace.id await rt.save() return workspace.id -@@ -56,18 +56,49 @@ +@@ -56,18 +56,49 @@ export function install(rt) { return `${stem} ${index}` } @@ -215,7 +215,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js const id = await createSession(cleanName, { dir }) try { state.bots[id] = { -@@ -80,45 +80,48 @@ +@@ -80,45 +80,48 @@ export function install(rt) { ...(modelRef ? { model: modelRef } : {}), ...(effortId ? { effort: effortId } : {}), ...(dir ? { workDir: dir } : {}), @@ -288,7 +288,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js // A save that another change queued meanwhile still carries this Bot; a later // one writes the team without it. void rt.save().catch(() => {}) -@@ -128,24 +128,12 @@ +@@ -128,24 +128,12 @@ export function install(rt) { } } @@ -315,7 +315,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js name: freshName(`${bot.name} copy`), role: bot.role, instructions: bot.instructions, -@@ -155,7 +155,6 @@ +@@ -155,7 +155,6 @@ export function install(rt) { effort: served ? bot.effort : undefined, // A folder that went away since leaves the copy in the team workspace. workDir: bot.workDir !== undefined && await rt.isFolder(bot.workDir) ? bot.workDir : undefined, @@ -323,7 +323,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js }) } -@@ -189,6 +189,8 @@ +@@ -189,6 +189,8 @@ export function install(rt) { if (avatar) bot.avatar = avatar else delete bot.avatar } @@ -332,7 +332,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js if (patch.model !== undefined) { if (patch.model === null || patch.model === '') delete bot.model else bot.model = await rt.resolveModel(patch.model) -@@ -212,17 +212,11 @@ +@@ -212,17 +212,11 @@ export function install(rt) { await rt.save() if (modelChanged) await rt.applyModel(bot, { force: true }) if (moved) void rt.followFolder(bot.id) @@ -351,7 +351,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js if (entry === undefined) throw new Error('Unknown conversation') if (typeof pinned === 'boolean') entry.pinned = pinned if (typeof hidden === 'boolean') entry.hidden = hidden -@@ -235,7 +235,14 @@ +@@ -235,7 +235,14 @@ export function install(rt) { // admin may change its own group but not delete it. Only a member can be admin. const roomBusy = id => [...rt.roomProgress.values()].some(round => round.roomId === id) @@ -367,7 +367,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js const cleanNotice = text => String(text ?? '').trim().slice(0, ROOM_NOTICE_MAX) function cleanRoomName(text, selfId) { -@@ -257,7 +257,7 @@ +@@ -257,7 +257,7 @@ export function install(rt) { async function followMemberNames(rooms = Object.values(state.rooms)) { for (const room of rooms) { if (room.named) continue @@ -376,7 +376,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js if (next === room.name) continue room.name = next await retitleRoom(room) -@@ -269,9 +269,24 @@ +@@ -269,9 +269,24 @@ export function install(rt) { if (members.length > ROOM_MAX_MEMBERS) throw new Error(`A group chat holds at most ${ROOM_MAX_MEMBERS} Bots`) } @@ -404,7 +404,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js const custom = cleanRoomName(roomName ?? '') // A Main Bot leads the groups it makes; a group the user makes is led by a Main // Bot member when it has one. -@@ -294,7 +294,19 @@ +@@ -294,7 +294,19 @@ export function install(rt) { createdAt: Date.now(), createdBy: createdBy ?? null, } @@ -424,7 +424,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js return state.rooms[id] } catch (error) { // The group's Session was already made: archive it instead of leaving an orphan. -@@ -306,13 +306,19 @@ +@@ -306,13 +306,19 @@ export function install(rt) { } } @@ -447,7 +447,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js let members = Array.isArray(patch.members) ? [...new Set(patch.members)] : [...room.members] for (const member of patch.add ?? []) if (!members.includes(member)) members.push(member) members = members.filter(member => !(patch.remove ?? []).includes(member) && rt.botOf(member)) -@@ -328,6 +328,14 @@ +@@ -328,6 +328,14 @@ export function install(rt) { admin = null } if (patch.mode !== undefined && !ROOM_MODES.includes(patch.mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`) @@ -462,7 +462,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js const name = patch.name === undefined ? undefined : cleanRoomName(patch.name, room.id) const notice = patch.notice === undefined ? undefined : cleanNotice(patch.notice) const moving = patch.workDir !== undefined -@@ -340,14 +340,24 @@ +@@ -340,14 +340,24 @@ export function install(rt) { const removed = room.members.filter(member => !members.includes(member)) if (added.length > 0) changes.push(`added ${names(added)}`) if (removed.length > 0) changes.push(`removed ${names(removed)}`) @@ -492,7 +492,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js if (patch.mode !== undefined && patch.mode !== room.mode) { room.mode = patch.mode changes.push(patch.mode === 'admin' ? 'plain messages now go to the admin first' : 'every member now replies in turn') -@@ -371,7 +371,13 @@ +@@ -371,7 +371,13 @@ export function install(rt) { delete room.named } } @@ -507,7 +507,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js if (room.name !== before) { changes.push(`renamed it "${room.name}"`) await retitleRoom(room) -@@ -383,8 +383,11 @@ +@@ -383,8 +383,11 @@ export function install(rt) { async function deleteRoom(id) { const room = rt.roomOf(id) if (room === undefined) throw new Error('Unknown group chat') @@ -519,7 +519,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js rt.roomInbox.delete(id) const failed = await saveForDelete() // Armed scheduled tasks refuse a plain archive and would keep delivering into -@@ -409,6 +409,10 @@ +@@ -409,6 +409,10 @@ export function install(rt) { } else if (replace !== undefined && replace !== id) { const at = others.indexOf(replace) if (at === -1) throw new Error('Only a Main Bot can be replaced') @@ -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,14 +417,38 @@ +@@ -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 @@ -573,7 +573,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js async function deleteBot(id) { const bot = rt.botOf(id) if (bot === undefined) throw new Error('Unknown Bot') -@@ -434,22 +434,42 @@ +@@ -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. const parts = new Set([id, current, ...(bot.segments ?? []).map(segment => segment.sessionId)]) @@ -616,7 +616,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js const failed = await saveForDelete() // Same as deleteRoom: stopActivity removes the Session's armed scheduled tasks. // Past parts are archived too: a part move can leave tasks on the old Session, -@@ -481,7 +481,9 @@ +@@ -481,7 +481,9 @@ export function install(rt) { if (state.mainBotIds.some(id => rt.botOf(id))) return // Only the team's first Main Bot is stored as the DeepSeek whale; other Bots get a // look from their id, and a Bot made Main Bot later keeps its own. @@ -627,7 +627,7 @@ diff --git a/src/host/roster.js b/src/host/roster.js .finally(() => { bootstrapping = null }) await bootstrapping } -@@ -489,6 +489,6 @@ +@@ -489,6 +489,6 @@ export function install(rt) { Object.assign(rt, { ensureWorkspace, createSession, createBot, duplicateBot, updateBot, setFlags, roomBusy, autoRoomName, retitleRoom, createRoom, updateRoom, deleteRoom, @@ -693,7 +693,7 @@ diff --git a/src/host/store.js b/src/host/store.js const EMPTY = JSON.stringify(state) // The other dsh host that holds state.lock while this one only reads (see home-lock.js); -@@ -50,12 +50,88 @@ +@@ -50,12 +50,88 @@ export function install(rt) { } } const readOnly = () => { @@ -783,7 +783,7 @@ diff --git a/src/host/store.js b/src/host/store.js const readFileState = async () => { let contents try { -@@ -87,10 +87,32 @@ +@@ -87,10 +87,32 @@ export function install(rt) { return undefined } if (parsed?.version === 1) { @@ -799,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) { @@ -810,14 +809,15 @@ 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 } -@@ -104,6 +104,20 @@ +@@ -104,6 +104,20 @@ export function install(rt) { // Teams saved before the DSH Agent existed carry no `agents`; corrupt values reset. if (state.agents === null || typeof state.agents !== 'object' || Array.isArray(state.agents)) state.agents = {} let migrated = !hadCapabilities @@ -838,7 +838,7 @@ diff --git a/src/host/store.js b/src/host/store.js if (state.capabilities === null || typeof state.capabilities !== 'object' || Array.isArray(state.capabilities)) { state.capabilities = { items: {} } migrated = true -@@ -112,6 +112,11 @@ +@@ -112,6 +112,11 @@ export function install(rt) { migrated = true } for (const bot of Object.values(state.bots)) { @@ -850,7 +850,7 @@ diff --git a/src/host/store.js b/src/host/store.js if (!Array.isArray(bot.capabilities)) { bot.capabilities = [] migrated = true -@@ -120,6 +120,11 @@ +@@ -120,6 +120,11 @@ export function install(rt) { bot.color = currentColor(bot.color) migrated = true } @@ -862,7 +862,7 @@ diff --git a/src/host/store.js b/src/host/store.js // LEGACY: Bots saved with a lab ran on that lab's default model; the model they // last ran on becomes their own, so dropping the lab changes nothing for them. if ('lab' in bot) { -@@ -161,12 +161,369 @@ +@@ -161,12 +161,369 @@ export function install(rt) { migrated = true } // Unnamed groups from before names followed their members may still list old names. @@ -1233,7 +1233,7 @@ diff --git a/src/host/store.js b/src/host/store.js const load = () => loaded ??= (async () => { claimLock() if (holder !== undefined) warn('ds-bot: %s', readOnly()) -@@ -185,14 +185,24 @@ +@@ -185,14 +185,24 @@ export function install(rt) { for (const room of stale) void rt.retitleRoom(room) })() @@ -1259,7 +1259,7 @@ diff --git a/src/host/store.js b/src/host/store.js warnedReadOnly = false adopt(await readFileState()) return -@@ -235,11 +235,37 @@ +@@ -235,11 +235,37 @@ export function install(rt) { return warnReadOnly() } @@ -1297,7 +1297,7 @@ diff --git a/src/host/store.js b/src/host/store.js state.revision += 1 state.exchanges = state.exchanges.slice(-MAX_EXCHANGES) const snapshot = JSON.stringify(state, null, 2) -@@ -260,9 +260,10 @@ +@@ -260,9 +260,10 @@ export function install(rt) { ensureLock() await rename(temp, statePath) seen = stamp() @@ -1309,7 +1309,7 @@ diff --git a/src/host/store.js b/src/host/store.js throw error } }) -@@ -273,13 +273,37 @@ +@@ -273,13 +273,37 @@ export function install(rt) { }) } @@ -1349,7 +1349,7 @@ diff --git a/src/host/store.js b/src/host/store.js const isMain = id => id !== undefined && state.mainBotIds.includes(id) // Group Sessions. A member speaks in each group from a hidden Session of its own, -@@ -313,6 +313,13 @@ +@@ -313,6 +313,13 @@ export function install(rt) { } for (const [botId, sessionId] of Object.entries(room.sessions ?? {})) groupOwners.set(sessionId, { botId, roomId: room.id }) } @@ -1363,7 +1363,7 @@ diff --git a/src/host/store.js b/src/host/store.js // The agents module owns the DSH Agent session index; it installs after this. rt.indexAgents?.() } -@@ -328,6 +328,8 @@ +@@ -328,6 +328,8 @@ export function install(rt) { delete room.sessions[botId] if (room.segments !== undefined) delete room.segments[botId] groupOwners.delete(sessionId) @@ -1372,7 +1372,7 @@ diff --git a/src/host/store.js b/src/host/store.js rt.cancelApprovals?.(sessionId) // A plain archive refuses a running Session, which would go on working for a group // that no longer has it. -@@ -335,23 +335,29 @@ +@@ -335,23 +335,29 @@ export function install(rt) { } const findBot = (ref) => { if (ref === undefined) return undefined @@ -1459,7 +1459,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js export const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] } export const GROUP_REF = { type: 'string', required: true, description: 'Exact group chat name from list_bots' } -@@ -18,6 +18,21 @@ +@@ -18,6 +18,21 @@ const WORK_DIR_PARAM = { type: 'string', description: 'Full path of the folder t export function install(rt) { const { ctx, state } = rt @@ -1481,7 +1481,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js const callerBot = (exec) => rt.botOf(rt.selfOf(exec.agent?.id)) const callerRoom = (exec) => { const roomId = rt.groupOwners.get(exec.agent?.id)?.roomId -@@ -27,39 +27,112 @@ +@@ -27,39 +27,112 @@ export function install(rt) { const roomLine = (room, self) => { const members = room.members.map(rt.botOf).filter(Boolean) .map(bot => `${bot.name}${bot.id === room.admin ? ' (admin)' : ''}${bot.id === self ? ' (you)' : ''}`) @@ -1601,7 +1601,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js ].join('\n') }, })) -@@ -81,12 +81,18 @@ +@@ -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) } @@ -1621,7 +1621,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js message: { type: 'string', required: true, description: 'Self-contained message; the other Bot cannot see this conversation' }, reply_to: { type: 'string', -@@ -104,8 +104,18 @@ +@@ -104,8 +104,18 @@ export function install(rt) { await rt.load() const sender = callerBot(exec) if (sender === undefined) return 'Only Bots on the team can use message_bot.' @@ -1642,7 +1642,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js if (target.id === sender.id) return 'You cannot message yourself.' const turn = rt.live.get(exec.agent?.id) const hop = Math.max(turn?.info?.hop ?? 0, ...(turn?.joined ?? []).map(joined => joined.info?.hop ?? 0)) -@@ -118,7 +118,11 @@ +@@ -118,7 +118,11 @@ export function install(rt) { const toUser = args.reply_to === 'user' const working = rt.live.get(chat)?.turn !== undefined const steer = args.when !== 'queue' && !toUser @@ -1655,7 +1655,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js const sent = toUser ? `Delivered to ${target.name}. It will answer the user in its own chat; nothing comes back to you, so point the user to ${target.name}'s chat instead of relaying.` : working && steer -@@ -152,8 +152,8 @@ +@@ -152,8 +152,8 @@ export function install(rt) { await rt.load() const sender = callerBot(exec) if (sender === undefined || !rt.isMain(sender.id)) refuse('Only a Main Bot can stop other Bots.') @@ -1666,7 +1666,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js if (target.id === sender.id) refuse('You cannot stop yourself.') const { running } = activeSessions(target.id) if (running.length === 0) refuse(`${target.name} is not running right now, so there is nothing to stop. Use message_bot to reach it.`) -@@ -162,21 +162,32 @@ +@@ -162,21 +162,32 @@ export function install(rt) { agent.cancel({ kind: 'hook', reason: 'ds-bot/stopped' }) } const why = String(args.reason ?? '').trim() || 'Stop what you are doing; the previous turn was ended on purpose. Acknowledge briefly.' @@ -1702,7 +1702,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js }, output, async execute(args, exec) { -@@ -186,8 +186,8 @@ +@@ -186,8 +186,8 @@ export function install(rt) { const workDir = args.work_dir ? await rt.cleanWorkDir(args.work_dir) : undefined const name = String(args.name ?? '').trim() await rt.approveFolder(exec, { path: workDir, who: { en: `the new Bot ${name}`, zh: `新 Bot ${name} ` } }) @@ -1713,7 +1713,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js } catch (error) { return `Could not create the Bot: ${error.message}` } -@@ -212,7 +212,7 @@ +@@ -212,7 +212,7 @@ export function install(rt) { const self = rt.selfOf(exec.agent?.id) const caller = rt.botOf(self) if (caller === undefined) return 'Only Bots on the team can use update_bot.' @@ -1722,10 +1722,11 @@ 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 { -@@ -231,6 +231,34 @@ +@@ -230,6 +230,34 @@ export function install(rt) { + }, })) - ctx.tools.register(defineTool({ ++ ctx.tools.register(defineTool({ + name: 'archive_bot', + description: 'Main Bot only. Retire another Bot: it leaves the tree (freeing a maxChildren slot) and the default Bot list, while its chats, history and memory stay readable. It is never deleted and no parent moves; deleting a Bot stays the owner\'s own click in the UI.', + parameters: { @@ -1753,11 +1754,10 @@ diff --git a/src/host/tools.js b/src/host/tools.js + }, + })) + -+ ctx.tools.register(defineTool({ + ctx.tools.register(defineTool({ name: 'list_models', description: 'List the models a Bot can run on, as provider/model refs for create_bot and update_bot.', - parameters: {}, -@@ -251,13 +251,13 @@ +@@ -251,13 +251,13 @@ export function install(rt) { const bots = [] const unknown = [] for (const ref of nameList(refs)) { @@ -1773,7 +1773,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js const groupNames = self => visibleRooms(self).map(room => `"${room.name}"`).join(', ') || 'none' const pickRoom = (ref, self) => { const matches = rt.findRooms(ref).filter(room => visibleRooms(self).includes(room)) -@@ -283,7 +283,7 @@ +@@ -283,7 +283,7 @@ export function install(rt) { if (!rt.isMain(self)) return 'Only a Main Bot can create group chats. Ask a Main Bot instead.' const { bots, unknown } = resolveBots(args.members) if (unknown.length > 0) return `No Bot named ${unknown.join(', ')}. Team: ${teamNames()}` @@ -1782,7 +1782,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js if (admin === undefined) return `No Bot named ${args.admin}. Team: ${teamNames()}` const members = [...new Set([self, ...bots.map(bot => bot.id), admin.id])] try { -@@ -297,7 +297,7 @@ +@@ -297,7 +297,7 @@ export function install(rt) { ctx.tools.register(defineTool({ name: 'update_group', @@ -1791,7 +1791,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js parameters: { group: GROUP_REF, name: { type: 'string', description: 'New group name; an empty string names it after its members again' }, -@@ -305,6 +305,7 @@ +@@ -305,6 +305,7 @@ export function install(rt) { add_members: { type: 'array', items: { type: 'string' }, description: 'Exact names of Bots to add' }, remove_members: { type: 'array', items: { type: 'string' }, description: 'Exact names of Bots to remove' }, admin: { type: 'string', description: 'Member to make the admin; "none" leaves the group without one (Main Bots only)' }, @@ -1799,7 +1799,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js mode: MODE_PARAM, work_dir: { ...WORK_DIR_PARAM, description: `${WORK_DIR_PARAM.description}. Every member works there in this group; an empty string lets each member work in its own folder again (Main Bots only)` }, }, -@@ -325,10 +325,18 @@ +@@ -325,10 +325,18 @@ export function install(rt) { if (args.admin !== undefined && args.admin !== '') { if (/^(none|nobody|null|无|没有)$/i.test(String(args.admin).trim())) admin = null else { @@ -1819,7 +1819,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js try { const workDir = moving ? await rt.cleanWorkDir(args.work_dir) : undefined // A Bot that joins a group with a folder works there from its first turn. -@@ -345,6 +345,7 @@ +@@ -345,6 +345,7 @@ export function install(rt) { notice: args.notice, mode: args.mode, admin, @@ -1827,7 +1827,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js add: add.bots.map(bot => bot.id), remove: remove.bots.map(bot => bot.id), ...(moving ? { workDir: workDir ?? '' } : {}), -@@ -395,12 +395,11 @@ +@@ -395,12 +395,11 @@ export function install(rt) { if ((info?.hop ?? 0) >= MAX_HOPS) return 'This chain of Bot messages is already long. Stop here and report to the user instead.' const text = String(args.message ?? '').trim() if (text === '') return 'Write a message to post.' @@ -1843,7 +1843,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js } catch (failure) { return `Could not post in "${room.name}": ${failure.message}` } -@@ -445,6 +445,10 @@ +@@ -445,6 +445,10 @@ export function install(rt) { .filter(option => option.label !== '') .slice(0, 6) if (options.length === 0) return 'Give at least one option.' @@ -1854,7 +1854,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js // A hidden Bot-to-Bot turn shows none of its calls, so its card goes to the // composer dock, which takes questions without a call. const turn = rt.live.get(id)?.turn -@@ -465,6 +465,77 @@ +@@ -465,6 +465,77 @@ export function install(rt) { }, })) @@ -1932,7 +1932,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js // dsh's own question tool holds the turn open until the user answers, and every // message from another Bot or a group round queues behind it. Bot Sessions ask // through ask_user instead, so the tool is taken out of their tool list. -@@ -525,7 +525,7 @@ +@@ -525,7 +525,7 @@ export function install(rt) { name: 'save_capability', description: 'Create or update a shared MCP server, skill, or pack. Changes apply from your next message.', parameters: { @@ -1941,7 +1941,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js kind: { type: 'string', required: true, enum: ['mcp', 'skill', 'pack'] }, description: { type: 'string', description: 'What this capability provides' }, transport: { type: 'string', enum: ['stdio', 'streamable-http'], description: 'How to reach an MCP server: a local command or an HTTP URL' }, -@@ -545,7 +545,7 @@ +@@ -545,7 +545,7 @@ export function install(rt) { try { const item = await rt.capabilitySave(args, { botId: self }) const approval = item.kind === 'mcp' && item.transport === 'stdio' && !item.approved @@ -1950,7 +1950,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js : '' return `Saved ${item.name}. Bots with it attached use the change from their next message.${approval}` } catch (error) { return error.message } -@@ -587,7 +587,7 @@ +@@ -587,7 +587,7 @@ export function install(rt) { : attached ? [attached] : [] const approvals = members .filter(item => item.kind === 'mcp' && item.transport === 'stdio' && item.approved !== true) @@ -1959,7 +1959,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js .join('') return `${verb} ${args.name} ${args.bot ? `to ${result.bot}` : 'to yourself'}. Changes apply from ${result.bot}'s next message.${endpoint === 'attach' ? approvals : ''}` } catch (error) { return error.message } -@@ -595,5 +595,159 @@ +@@ -595,5 +595,159 @@ export function install(rt) { })) } diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 641b56315..a546db383 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -881,7 +881,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js const ROOM_LOG_MAX = 60 const ROOM_FIRST_LOOK = 12 -@@ -31,6 +31,10 @@ +@@ -31,6 +31,10 @@ const ROOM_EXTRA_TURNS = 4 const ROOM_ADMIN_EXTRA_TURNS = 4 const ROOM_TURNS_EACH = 2 const ROOM_ADMIN_TURNS_EACH = 4 @@ -892,7 +892,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js // Logs written before rooms were versioned get sequence numbers in place. const ensureRoomLog = (room) => { -@@ -41,9 +41,67 @@ +@@ -41,9 +41,67 @@ const ensureRoomLog = (room) => { room.seen ??= {} } @@ -961,7 +961,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } // A member's own lines are already in its Session, so only the others' are news. -@@ -58,6 +58,9 @@ +@@ -58,6 +58,9 @@ const lineText = (line) => { if (line.kind === 'resumed') return `[${line.who}'s work here was cut off when DeepSeek Harness restarted; it is picking it back up and posts when done]` if (line.kind === 'cut') return `[${line.who}'s work here was cut off by a restart again, so it stopped; ask it to go on if it still matters]` if (line.kind === 'stopped') return `[The user stopped ${line.who}'s work here]` @@ -971,7 +971,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js return `${line.who}: ${line.text}` } const transcript = lines => lines.map(lineText).join('\n') -@@ -86,12 +86,33 @@ +@@ -86,12 +86,33 @@ export const referenceNote = references => (references?.length : '') const ROOM_REPLY = 'Reply with your message for the group, no name prefix: in a discussion, one or two short messages. When you are asked to do something, do it now with your tools. The group does not wait for you, and your final reply is posted here when you finish, so give the result, never a promise to do it later. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.' @@ -1005,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,247 @@ +@@ -100,162 +100,247 @@ const slotLabel = (slot) => { return slot.name } @@ -1047,6 +1047,12 @@ diff --git a/src/host/groups.js b/src/host/groups.js // A member's group Session, created on its first turn there. A new one starts with // a fresh look at the log and a brief of the member's own chat; the brief is the // first message, so it stays fixed and the prefix stays cacheable. +- async function groupSessionFor(room, member) { +- room.sessions ??= {} +- const known = room.sessions[member.id] +- if (known !== undefined) { +- await rt.ensureCapabilities(known) +- return { id: known, brief: undefined } + // The brief is the member's owner's private chat with the Bot. In a room where + // another owner sits it stays out unless that owner shared it with this room (#53). + const briefAllowed = (room, member) => { @@ -1058,19 +1064,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js + // A granted remote Bot speaks for another home; its handle stands for that owner. + else if (p.remote) owners.add(p.handle) + else owners.add(rt.botOf(p.id)?.ownerId) -+ } -+ owners.delete(undefined) -+ if (owners.size <= 1) return true -+ return participants.some(p => p.kind === 'bot' && p.id === member.id && p.briefShared === true) -+ } -+ - async function groupSessionFor(room, member) { -- room.sessions ??= {} -- const known = room.sessions[member.id] -- if (known !== undefined) { -- await rt.ensureCapabilities(known) -- return { id: known, brief: undefined } -- } + } - const id = await rt.createSession(`${member.name} · ${room.name}`, { dir: rt.workDirOf({ botId: member.id, roomId: room.id }) }) - room.sessions[member.id] = id - rt.groupOwners.set(id, { botId: member.id, roomId: room.id }) @@ -1079,6 +1073,12 @@ diff --git a/src/host/groups.js b/src/host/groups.js - if (ref !== undefined) { - await rt.selectSessionModel(id, splitRef(ref)) - .catch(error => rt.warn('ds-bot: model for %s in %s not applied: %s', member.name, room.name, error)) ++ owners.delete(undefined) ++ if (owners.size <= 1) return true ++ return participants.some(p => p.kind === 'bot' && p.id === member.id && p.briefShared === true) ++ } ++ ++ async function groupSessionFor(room, member) { + const session = await participant.open({ room, member }) + return { id: session.id, brief: session.created && briefAllowed(room, member) ? await rt.ownChatBrief(member) : undefined } + } @@ -1358,7 +1358,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js if (by === undefined) delete called.by else called.by = by round.slots.splice(next, 0, called) -@@ -285,34 +285,204 @@ +@@ -285,23 +285,185 @@ export function install(rt) { // Session's inbox, so it does not open another round when this one ends. Returns // the number of turns added, which the round's limit allows for. function drainRoomInbox(room, round, turns, index, agent) { @@ -1411,11 +1411,11 @@ diff --git a/src/host/groups.js b/src/host/groups.js - // The user spoke in this room, so Bots may start their own rounds again. - room.botRounds = 0 + // The user spoke in this room, so late replies may be answered again. - room.lateRounds = 0 - void rt.save().catch(() => {}) - return round.slots.length - before - } - ++ room.lateRounds = 0 ++ void rt.save().catch(() => {}) ++ return round.slots.length - before ++ } ++ + // One line that reaches the round while a member speaks (#1267). It is logged at once — the room + // shows it when it is sent, not at the next slot — and `notePostedLine` keeps the next relay from + // logging it a second time. Its pending copy leaves the relay's queue, because this round answers @@ -1428,8 +1428,8 @@ diff --git a/src/host/groups.js b/src/host/groups.js + if (at !== -1) rt.roomInbox.set(room.id, queued.toSpliced(at, 1)) + round.replies.push({ kind: 'user', text: line.text, time: Date.now(), authorId: line.authorId, authorKind: line.authorKind, name: line.authorName }) + // The user spoke in this room, so late replies may be answered again. -+ room.lateRounds = 0 -+ void rt.save().catch(() => {}) + room.lateRounds = 0 + void rt.save().catch(() => {}) + // The one line that ends work in flight (#1267 B): the slot is superseded, its turn is cancelled, + // and nothing it says when it stops is posted. The line stays in the Session's queue, so the head + // drain opens it as a fresh line (admin first) for the room to answer. @@ -1549,11 +1549,10 @@ diff --git a/src/host/groups.js b/src/host/groups.js + const uncapped = new Set(callees.filter(bot => rt.isAdminOf(room, bot.id)).map(bot => bot.id)) + callMembers(round, turns, index - 1, callees, `${call.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, call.by, uncapped, call.text) + } -+ return round.slots.length - before -+ } -+ - async function runRound(room, round, signal, agent) { - let limit = round.slots.length + ROOM_EXTRA_TURNS + (room.admin ? ROOM_ADMIN_EXTRA_TURNS : 0) + return round.slots.length - before + } + +@@ -310,9 +310,17 @@ export function install(rt) { const turns = new Map() // The inbox check keeps a message sent during the last speaker's turn inside // this round: it drains at the loop's head even once the slots ran out. @@ -1572,7 +1571,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js if (index >= round.slots.length) break const slot = round.slots[index] const member = rt.botOf(slot.id) -@@ -321,6 +321,20 @@ +@@ -321,6 +321,20 @@ export function install(rt) { slot.status = 'left' continue } @@ -1593,7 +1592,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js slot.status = 'speaking' round.speaking = member.id round.turns += 1 -@@ -339,8 +339,23 @@ +@@ -339,8 +339,23 @@ export function install(rt) { const prompt = roomPrompt(room, member, round) round.shown?.push(member.id) const text = session.brief === undefined ? prompt : `${session.brief}\n\n${prompt}` @@ -1618,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,18 +366,31 @@ +@@ -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 } @@ -1654,7 +1653,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } } -@@ -397,38 +397,52 @@ +@@ -397,38 +397,52 @@ export function install(rt) { rt.roomInbox.delete(room.id) const posts = queued.length > 0 ? queued : [{ text: '' }] const startSeq = room.seq + 1 @@ -1727,7 +1726,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js speaking: null, turns: 0, replies: [], -@@ -436,6 +436,7 @@ +@@ -436,6 +436,7 @@ export function install(rt) { failed: [], started: [], } @@ -1735,7 +1734,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js for (const post of posts.filter(item => item.failed)) { const bot = rt.botOf(post.botId) const { code, status, message } = post.failed -@@ -450,17 +450,25 @@ +@@ -450,17 +450,25 @@ export function install(rt) { appendRoom(room, { who: name, botId: post.botId, kind, text: '' }) round.replies.push({ kind, botId: post.botId, name, color: bot?.color, time: Date.now() }) } @@ -1762,7 +1761,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js } await rt.save() for (const post of posts) { -@@ -471,6 +471,41 @@ +@@ -471,6 +471,41 @@ export function install(rt) { }, })) @@ -1804,7 +1803,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js // Private context enters a group only through this tool, and the group sees each use. ctx.tools.register(defineTool({ name: 'read_own_chat', -@@ -539,7 +539,9 @@ +@@ -539,7 +539,9 @@ export function install(rt) { const { room, error } = rt.pickRoom(args.group, self) if (error) return error ensureRoomLog(room) @@ -1815,7 +1814,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js return lines.length === 0 ? `"${room.name}" has no messages yet.` : `Latest in "${room.name}" (oldest first):\n${transcript(lines)}` }, })) -@@ -548,46 +548,264 @@ +@@ -548,46 +548,264 @@ export function install(rt) { // the admin answers alone in admin mode, or first of everyone. What a member posts // after its round moved on is answered by the member that asked for it; work the // user asked for is left for the user to read. @@ -2086,7 +2085,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js .catch(error => rt.warn('ds-bot: late group reply from %s failed: %s', member.name, error)) } rt.lateRoomReply = lateRoomReply -@@ -599,9 +599,8 @@ +@@ -599,9 +599,8 @@ export function install(rt) { const room = rt.roomOf(roomId) const member = rt.botOf(botId) const sessionId = room?.sessions?.[botId] @@ -2098,7 +2097,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js await postToRoom(room, member, '', { quiet: true, stopped: true }) return true } -@@ -615,37 +615,42 @@ +@@ -615,37 +615,42 @@ export function install(rt) { const member = rt.botOf(owner?.botId) if (room === undefined || member === undefined || !room.members.includes(member.id)) return const { code, status, message } = failure @@ -2157,7 +2156,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js .catch(error => rt.warn('ds-bot: group report to %s failed: %s', post.name, error)) } -@@ -665,7 +665,7 @@ +@@ -665,7 +665,7 @@ export function install(rt) { // message from the user, a scheduled task, or a Bot's post after the last model or // tool step starts a relay. const opens = message => message.role === 'user'