diff --git a/changelog.d/2430-tree-retired.fixed.md b/changelog.d/2430-tree-retired.fixed.md new file mode 100644 index 000000000..e06a3d4c5 --- /dev/null +++ b/changelog.d/2430-tree-retired.fixed.md @@ -0,0 +1 @@ +The tree model now treats a Bot retired with `archive_bot` as out of the tree everywhere: it no longer occupies its parent's `maxChildren` slot, and its children move up to its own parent instead of being stranded under a Bot that cannot be messaged ([#2430](https://github.com/ThinkFlowLab/FlyBot/issues/2430)). diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index 4f1d632d3..303c03837 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -77,7 +77,7 @@ The tree organizes itself by role instead of leaving Bots outside it or a parent - **Repair**: a parent over `maxChildren` (after a deletion re-attached a deleted Bot's children) is regrouped the same way, at once when the lead exists, else in the queue. - **Daily tidy** (checked hourly, runs once a day; the first check after install only records the time; `dailyTidy: false` turns it off): (1) leaf Bots with no activity for `tidy.idleDays` (14) get `idle` (any turn or tool call clears it) and are **archived** after `tidy.archiveIdleDays` (30) more days; Bots with children, granted Bots and the root are never idle; (2) duplicates: two Bots with essentially the same role (same name once generic words are dropped and no conflicting description, or near-identical descriptions in one domain), both at least `tidy.mergeGraceDays` (1) in the tree, are **merged**: the newer one's children move to the kept Bot, which lists it in `mergedFrom` and is told in its prompt; the old one is archived; (3) project and domain groups of three or more (or two with an existing lead) on every level, never a group of a whole level without a lead. - **Archive, never delete**: an archived Bot leaves the tree (`archived { at, reason, parent, treeRole, into? }`, tree fields and block removed) but keeps its chat, memory and grants. It is **hidden from the default Bot list** (`bot.hidden` / structure `include`; Search still finds it) (#452), and the sidebar's `flybot-tree-archived` section is the way back to its chat (#810): collapsed by default, header `Archived (N)`, drawn only while N > 0, one row per archived Bot with the same tag `statusOf()` gives (`archived`, `archived · merged into `) opening the chat through `actions.openSession`. `include()` still returns false, so the list and `botTree()` never see it. Deleting it stays the owner's own click. That section is registered at `order: 88` and the reorganization notice below at `order: 89`; a slot renders ascending, so the archived Bots sit **above** the notice. On a merge (#450 item 1), the archived Bot's own memory is carried into the kept Bot through `flybot.memory` (at most 20 entries, tagged `source: merged`), so a merge loses nothing; without the host's memory seam the merge still stands. -- **Retire from the core (#638):** the Main Bot's `archive_bot` writes `fields['flybot-tree'].retired = true` (never `archived`, which is the tidy's own record) and sets `bot.hidden`, so the Bot leaves the tree and the default list the way an archived one does while its history stays readable; adoption skips a retired record and `forestOf` no longer counts it, which frees its `maxChildren` slot. The tree panel (`treeOf`) skips a retired Bot the same way (#1793), and the `flybot-tree-archived` section lists it with the `retired` tag so its chat stays reachable; a record that also carries `archived` keeps the archived tag. +- **Retire from the core (#638):** the Main Bot's `archive_bot` writes `fields['flybot-tree'].retired = true` (never `archived`, which is the tidy's own record) and sets `bot.hidden`, so the Bot leaves the tree and the default list the way an archived one does while its history stays readable; adoption skips a retired record and `forestOf` no longer counts it, and since #2430 the model itself does too: `snapshot()` and `isMember()` leave the record out (so it occupies no `maxChildren` slot, no `list_children` row and no prompt trace), and the field change that retires it hands its children to its own parent — or to the current Main for a retired root — with every moved Bot's prompt refreshed, instead of stranding them under a Bot the host refuses to deliver to. The tree panel (`treeOf`) skips a retired Bot the same way (#1793), and the `flybot-tree-archived` section lists it with the `retired` tag so its chat stays reachable; a record that also carries `archived` keeps the archived tag. - **Move, never rename-to-归档 + recreate** (#452): grouping always reparents (`parent`); it never renames a Bot to `归档-…` / `Archive-…` and never creates a replacement with the old name. A one-shot repair strips those prefixes when the bare name is free, or archives+hides the prefixed Bot when a live Bot already has that name. - **Journal and undo**: every move, new lead, lead mark, merge, archive and idle mark of a run is an action in the root's `reorg { runs (last 5), dismissed, dailyAt }` and a `flybot-tree: reorganized (): …` log line. The tree view's notice (sidebar section `flybot-tree-reorg`, `reorgNotice: false` hides it) shows the last run with **Undo** and **Dismiss** (`tree/reorg`, `tree/undo`, `tree/dismiss`); a read-only window (`roster.readOnly`, trimmed) keeps the card and everything it reports but draws neither button, because both are writes the host refuses (#2220). Undo reverses the newest run: moves go back where the old parent has room and the Bot was not moved again, archived Bots come back, a lead the run created is archived once empty; what it cannot restore is listed as kept. Restored Bots are pinned for `tidy.pinDays` (14) so the daily tidy leaves them alone. - **The owner is told afterwards**, no plan card first: the notice in the tree view, and the Main Bot's prompt names the last run (within 7 days, not undone) so Chief can say what moved. diff --git a/plugins/tree/src/tree.js b/plugins/tree/src/tree.js index f83293274..2b05b0155 100644 --- a/plugins/tree/src/tree.js +++ b/plugins/tree/src/tree.js @@ -24,9 +24,14 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis const listeners = new Set() const load = createLoadTracker({ now }) + // archive_bot retires a Bot (core roster writes fields['flybot-tree'].retired = true): it is out + // of the tree, so the slot is free again and the default list hides it. Its children are handed + // to its own parent by detachRetired below, the way deletion already does (#2430). + const isRetired = id => fb.getField(id, 'retired') === true function snapshot() { const nodes = {} for (const b of fb.listBots()) { + if (isRetired(b.id)) continue const role = fb.getField(b.id, 'treeRole') const parent = fb.getField(b.id, 'parent') if (role === undefined && parent === undefined) continue @@ -267,8 +272,8 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis return { moved: bot.id, name: bot.name, from, to: parent.id } } - // A tree member has treeRole (or parent; the root's parent is null/absent). - const isMember = id => typeof id === 'string' && (fb.getField(id, 'treeRole') !== undefined || fb.getField(id, 'parent') !== undefined) + // A tree member has treeRole (or parent; the root's parent is null/absent) and is not retired. + const isMember = id => typeof id === 'string' && !isRetired(id) && (fb.getField(id, 'treeRole') !== undefined || fb.getField(id, 'parent') !== undefined) const parentOf = id => fb.getField(id, 'parent') ?? null const isRoot = id => isMember(id) && (fb.getField(id, 'treeRole') === ROOT_ROLE || fb.getField(id, 'parent') == null) // The root of the tree a member is in (itself for a root); undefined for a non-member. A cycle stops at the walk's end. @@ -290,8 +295,57 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis } const emit = change => { for (const fn of listeners) fn(change) } + /** + * A retired Bot leaves the tree (archive_bot writes `retired: true`). Its children move up to its + * own parent, the way onBotRemoved does for deletion: left under a retired Bot they would be a + * dangling subtree the root cannot list and cannot message (#2430). A retired root hands its + * children to the current Main, so `parent: null` cannot turn them into roots. + * + * The new parent is resolved before any parent is written. adoptChief() repairs and refreshes the + * tree, so running it while the children are still orphans records a role-based re-attachment this + * move then overwrites (undo answers "moved again since") and can leave a level over maxChildren + * until the second repair. Resolve first, assign once, repair once. + */ + function detachRetired(botId) { + if (typeof botId !== 'string') return + const up = fb.getField(botId, 'parent') ?? null + const kids = fb.listBots().filter(b => b.id !== botId && fb.getField(b.id, 'parent') === botId && !isRetired(b.id)) + if (kids.length === 0) { organizer.repair(); refreshAll(); return } + if (up !== null) { + for (const child of kids) fb.setField(child.id, 'parent', up) + organizer.repair() + refreshAll() + return + } + const chief = findChief() + if (!chief) { + // No Main to adopt (the core keeps one; this is an imported/edge state). ensureRoot() runs + // before anything moves: only then is there a Root to hand the children to, and none of them is + // read as the root that already exists — which used to leave one pointing at itself. + void ensureRoot().then((root) => { + if (typeof root !== 'string') return + for (const child of kids) fb.setField(child.id, 'parent', root) + organizer.repair() + refreshAll() + }).catch(error => warn('flybot-tree: could not re-root after %s was retired: %s', botId, error?.message ?? error)) + return + } + // A retired root's children become Chief's children. Chief's own record is adopted inside + // adoptChief(); assigning it here too would point it at itself for that instant. + for (const child of kids) if (child.id !== chief.id) fb.setField(child.id, 'parent', chief.id) + if (!adoptChief()) { organizer.repair(); refreshAll() } + } function onFieldChange(change) { - if (!change || !TREE_KEYS.has(change.key)) return + if (!change) return + // Retirement changes membership without touching parent/treeRole, so it is handled here rather + // than ignored with the other fields the tree does not own (#2430). + if (change.key === 'retired') { + if (change.value === true && change.prev !== true) detachRetired(change.botId) + else refreshAll() + emit(change) + return + } + if (!TREE_KEYS.has(change.key)) return emit(change) } /** diff --git a/plugins/tree/test/tree.test.js b/plugins/tree/test/tree.test.js index 9f3fa5662..c691593db 100644 --- a/plugins/tree/test/tree.test.js +++ b/plugins/tree/test/tree.test.js @@ -19,6 +19,8 @@ const f = (m, id, k) => m.bots.get(id)?.fields['flybot-tree']?.[k] const sp = (m, id) => m.bots.get(id)?.systemPrompt const outsider = (m, name = 'Outsider') => m.flybot.bot.create({ name }) const make = async (m, parent, name) => (await m.call(parent, 'create_child_bot', { name, role_description: `${name} stuff` })).created?.id +// What archive_bot does to the tree fields (core/src/host/roster.js): retire, hide, keep history. +const retire = (m, id) => m.flybot.scope('flybot-tree').fields.set(id, 'retired', true) test('manifest follows dsh plugin conventions', () => { const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url))) @@ -82,6 +84,61 @@ test('removing a former root re-attaches its children to the current root', asyn assert.deepEqual((await m.call(chief2, 'list_children')).children.map(c => c.name).sort(), ['Life', 'Work']) }) +test('a retired Bot leaves the tree and its children re-attach to its parent', async () => { + const m = await setup() + const work = await make(m, m.root, 'Work') + const dev = await make(m, work, 'Dev') + retire(m, work) + assert.equal(m.tree.snapshot()[work], undefined, 'a retired Bot is not a tree node') + assert.equal(m.tree.isMember(work), false, 'a retired Bot is not a tree member') + assert.deepEqual((await m.call(m.root, 'list_children')).children.map(c => c.name), ['Dev']) + assert.equal(f(m, dev, 'parent'), m.root, 'the child moved up instead of staying under a retired Bot') + assert.match(sp(m, dev), /Chief → Dev/) + assert.doesNotMatch(sp(m, dev), /Work/) + assert.match(sp(m, m.root), /- Dev: Dev stuff/, 'the root lists the moved child') + assert.doesNotMatch(sp(m, m.root), /- Work: Work stuff/, 'the root no longer lists the retired Bot') +}) + +test('retiring a Bot keeps its grandchildren under their own parent', async () => { + const m = await setup() + const work = await make(m, m.root, 'Work') + const dev = await make(m, work, 'Dev') + const junior = await make(m, dev, 'Junior') + retire(m, work) + assert.equal(f(m, dev, 'parent'), m.root) + assert.equal(f(m, junior, 'parent'), dev, 'a grandchild keeps its parent') + assert.match(sp(m, junior), /Chief → Dev → Junior/) +}) + +test('a retired root with no Main adopts a fresh Root instead of pointing a child at itself', async () => { + const m = await setup() + const work = await make(m, m.root, 'Work') + const life = await make(m, m.root, 'Life') + // Synthetic state: the core keeps at least one Main, so this is the imported/edge case. + m.bots.get(m.root).isMain = false + retire(m, m.root) + await new Promise(resolve => setTimeout(resolve, 0)) + const roots = Object.entries(m.tree.snapshot()).filter(([, n]) => n.root) + assert.equal(roots.length, 1, 'exactly one root') + const [rootId] = roots[0] + assert.notEqual(rootId, m.root) + for (const [id, node] of Object.entries(m.tree.snapshot())) { + assert.notEqual(node.parent, id, `${node.name} is not its own parent`) + } + for (const id of [work, life]) { + assert.equal(f(m, id, 'parent'), rootId, 'the orphan went under the new Root') + } +}) + +test('retiring a child frees a maxChildren slot', async () => { + const m = await setup({ maxChildren: 1 }) + const a = await make(m, m.root, 'A') + const refused = await m.call(m.root, 'create_child_bot', { name: 'B', role_description: 'b' }) + assert.match(refused.error, /maxChildren=1/) + retire(m, a) + assert.ok(await make(m, m.root, 'B'), 'the freed slot is reusable') +}) + test('a rename made in the core reaches the tree prompts on the next turn', async () => { const m = await setup() const work = await make(m, m.root, 'Work')