diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index c83078bc6..9bba32ad7 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -46,6 +46,11 @@ Everything in `plugins/tree/` (entry `plugins/tree/index.js` and any helpers). - `maxChildren` and `maxDepth` apply to **every** create path while the tree plugin is loaded (#446). A Bot made without tree fields (core `create_bot`, the Bot list) is placed by role by `src/organize.js` on `bot.created`: inside the create call when it fits a level or an existing lead, right after when a new lead has to be created first (it is marked `organizing` until then). No parent goes over `maxChildren` at any moment. `create_child_bot` keeps its limit contract (`limit reached` at a full target parent). - A newly created child starts with no person-grant (`bot.hasAnyGrant` false, even when the parent is granted). Sharing with another person still needs an independent per-Bot grant (default deny). Tree never copies grants and never publishes. +### Reorganising the tree: `move_bot` and adoption after a deletion (#635) + +- **`move_bot(bot, parent)`** (root only) moves an existing Bot with its subtree under another parent, found by id or by unique name, and refreshes the prompt of every Bot whose trace changed. It refuses a cycle (`cannot move X under its own descendant Y`), a no-op (`already a child of`), a duplicate sibling name, `maxChildren` at the target, and a subtree that would end deeper than `maxDepth` — the same invariants `create_child_bot` keeps, applied to a move. It never creates a Bot: deletion already re-attaches a removed Bot's children to its parent, and this is the sideways counterpart. +- **A deletion re-runs adoption.** `onBotRemoved` re-attaches the deleted Bot's children first, then calls `adoptUnattached` on the current root: a Bot that stayed outside the tree only because a level was full joins as soon as the deletion frees a slot, instead of waiting for the next startup. A Bot that still fits nowhere stays outside, logged, and is retried on the next pass. +- Tests: `test/tree.test.js` — `#635: move_bot moves a bot with its subtree and refreshes the prompts`, `#635: move_bot refuses cycles, overflow, duplicates and a too deep subtree`, `#635: move_bot refuses an overflowing parent and refreshes the subtree`, and `#635: a Bot left outside because the tree was full joins when a deletion frees a slot`. ### Placement rules: forward allocation (#447, owner decisions 2026-10-19 ~19:45 and 21:27) Allocation puts a new Bot in a sensible place once; the self-organization below (#446) is the safety net that keeps the tree healthy afterwards. `src/allocate.js` uses `organize.js`'s shared primitives (`organizer.shared`: role profiles, homes, `fits`, `createLead`, `markLead`, `move`, journal, the reorganization queue), so explicit allocation and automatic reorganization have one set of limit checks and one prompt / trace refresh. diff --git a/plugins/tree/README.md b/plugins/tree/README.md index f466aa3e0..3b801db9a 100644 --- a/plugins/tree/README.md +++ b/plugins/tree/README.md @@ -7,14 +7,14 @@ Recursive bot tree plugin for FlyBot (built on ds-bot / DeepSeek Harness). ### What it does - **Chief is the root.** ds-bot's Main Bot (`BotView.isMain`, i.e. `state.mainBotIds`; the one named `Chief` if there are several) is adopted as the root with `treeRole: 'root'` (no `parent`); no bot is created. Every other tree bot has `parent` (its parent's id) and `treeRole` (its role). Adoption runs at startup, a few retries later and on every `bot.created`, so a Chief created after startup is picked up. Only if the team has bots but no Main Bot is a separate `Root` created. `config.rootBotId` picks the root explicitly. - **Migration:** a separate `Root` auto-created by earlier versions (parent `null`, not a Main Bot) hands its children to Chief, loses its tree fields and tree prompt, and gets `fields['flybot-tree'].retired = true` so adoption skips it. The bot itself is **never** deleted (bot-im / the user decide). Idempotent. -- **Adoption (`adoptExisting`, default true):** every local bot with no tree fields that is not retired and not another Main Bot (e.g. existing 研究员, 数据分析师, 文案, 程序员) is placed in the tree at startup and on `bot.created` when the creator set no `parent` (core `create_bot`, the Bot list), at once, inside the create call. **Self-organization (#446, `src/organize.js`, `src/roles.js`)** obeys `maxChildren` and `maxDepth` like `create_child_bot` at every moment: the Bot goes into the group that matches its role (name, role label, description, own prompt, recent tools): its namesake, else the tree Bot that leads that group, else Chief. A full level is regrouped by role: related children move under a suitable existing member (already that group's lead, already coordinating it, or named for it / as a lead, e.g. "Kitchen Manager"), or under a new lead with a clear role ("FlyBot Lead", "Life Lead"); never under an unrelated Bot (no least-loaded fallback). A Bot that fits no group while every matching level is full stays outside, logged and tagged "outside the tree", and is retried on the next attempt. A daily tidy also groups related Bots, merges duplicates (the old Bot is archived, never deleted) and marks long-idle leaf Bots idle, archiving them after 30 days. Every change is journaled on Chief's `reorg` field and logged (`flybot-tree: reorganized (…): …`); the tree view shows the last run with Undo / Dismiss, and Chief's prompt names it. A freed slot does not pull anyone back up. Adopted bots keep their own prompt; the tree block is added. Message rules apply to them like any tree child (Chief ↔ them; they → only Chief and their own children). +- **Adoption (`adoptExisting`, default true):** every local bot with no tree fields that is not retired and not another Main Bot (e.g. existing 研究员, 数据分析师, 文案, 程序员) is placed in the tree at startup and on `bot.created` when the creator set no `parent` (core `create_bot`, the Bot list), at once, inside the create call. **Self-organization (#446, `src/organize.js`, `src/roles.js`)** obeys `maxChildren` and `maxDepth` like `create_child_bot` at every moment: the Bot goes into the group that matches its role (name, role label, description, own prompt, recent tools): its namesake, else the tree Bot that leads that group, else Chief. A full level is regrouped by role: related children move under a suitable existing member (already that group's lead, already coordinating it, or named for it / as a lead, e.g. "Kitchen Manager"), or under a new lead with a clear role ("FlyBot Lead", "Life Lead"); never under an unrelated Bot (no least-loaded fallback). A Bot that fits no group while every matching level is full stays outside, logged and tagged "outside the tree", and is retried on the next attempt. A daily tidy also groups related Bots, merges duplicates (the old Bot is archived, never deleted) and marks long-idle leaf Bots idle, archiving them after 30 days. Every change is journaled on Chief's `reorg` field and logged (`flybot-tree: reorganized (…): …`); the tree view shows the last run with Undo / Dismiss, and Chief's prompt names it. A freed slot is used again: deleting a Bot re-runs adoption, so a Bot that stayed outside only because a level was full joins as soon as there is room (#635). Adopted bots keep their own prompt; the tree block is added. Message rules apply to them like any tree child (Chief ↔ them; they → only Chief and their own children). - **Prompt injection:** the tree text sits in a marked block (`…`) inside each bot's `systemPrompt`; only that block is replaced, so Chief's own prompt text is never overwritten. Chief gets the coordinator rules for its direct children. - `bot.send` middleware `tree`: inside the tree a bot may message only its parent and direct children (`deny` otherwise). A non-root tree bot may not message local bots outside the tree; the root (Chief) may message any local bot. Local bots outside the tree may message only the root. Remote targets and outside-to-outside sends get no opinion (`undefined`, social decides). **Granted Bots (#90):** a friend's (remote) sender reaches a tree bot only if the core says it has a person-grant (`flybot.bot.hasAnyGrant`): new and adopted bots and the Chief start with none. A granted bot is reached directly at any depth, never redirected through the root; an ungranted one is denied (`tree: this bot has not been granted…`). Granted bots on both sides are not the tree's call across trees or to non-tree bots (the core's owner policy decides); inside one tree the parent/child rule is unchanged, as are `stopped` / `report` notices. A granted child's prompt tells it to answer outside senders directly, without a trace line, and to delegate only to its own children. On a core without `hasAnyGrant`, the remote-sender rule gives no opinion. - **Control notices bypass tree policy:** `role: 'stopped'` (stop_bot) and `role: 'report'` are never denied by the tree middleware, so a Main Bot can stop any tree bot. - **Deleting a bot:** its children re-attach to its parent and every moved child (and its subtree) gets a fresh prompt/trace. No child is dropped. If that parent is now over `maxChildren`, it is regrouped by role automatically (#446), the same way as a full level, until it is within the limit. Each move is journaled and logged. - **`create_child_bot` is serialized per parent:** parallel calls never exceed `maxChildren` or create duplicate-name siblings. - Delegation is level by level: coordinators delegate to direct children whose scope fits and aggregate upward; a bot with no children does the work itself. A one-line trace like `Chief → Work → Dev` is added only to answers a tree bot sends to its parent tree bot; replies to the user or to a remote (friend's, Bot Social) bot never carry it, and the root (Chief) never adds one. -- Tools (visible to every tree bot, not only Main/root — #16 Option B): `list_children()`, `find_bots(query?)`, `create_child_bot(name, role_description, parent?)` and `create_team(team, members, parent?)`. A bot creates only in its own downstream: `parent` (id or name) must be the caller or one of its descendants, and limits are checked at that parent. **Forward allocation (#447, `src/allocate.js`):** `find_bots` (read-only) lists the bots in the caller's branch (Chief: the whole tree) with role, parent, depth and path, matches first; `create_child_bot` reuses a bot with the same role anywhere in the caller's branch instead of creating a duplicate (`{ reused, note }`), and without `parent` puts the new bot under a marked group lead in the branch that its role belongs to ("FlyBot Docs" under "FlyBot Lead"), else under the caller; `create_team` makes a project team as a lead plus children (`Chief → FlyBot Lead → {Dev, Test, …}`, `members` as `"Name: role"` strings), one slot under the parent, reusing an existing lead and same-role members (a flat one moves under the lead, journaled and undoable). Allocation uses the self-organization's primitives (role profiles, leads, moves, journal); a full level, orphans (a bot whose parent is gone is reattached by role on the next repair pass) and duplicates are left to the self-organization, which never asks first. There is no plan card. Limits: depth 5 (root = 1), 8 children per node; duplicate sibling names (case/space-insensitive) are rejected; names are 1–40 characters (ds-bot's roster limit). Every new child gets the same recursive system prompt template (`src/prompt.js`). A new child starts with no person-grant even if its parent is granted; sharing it still needs an independent Sharing → Copy invite link (default deny). +- Tools (visible to every tree bot, not only Main/root — #16 Option B): `list_children()`, `find_bots(query?)`, `create_child_bot(name, role_description, parent?)`, `create_team(team, members, parent?)` and — root only — `move_bot(bot, parent)` (#635), which moves an existing Bot with its subtree under another parent after checking for cycles, `maxChildren`, `maxDepth` and duplicate sibling names, then refreshes every prompt whose trace changed. Unlike `create_child_bot` it never creates a Bot; use it to reorganise a team, and note that deleting a Bot still re-attaches its children to its parent. A bot creates only in its own downstream: `parent` (id or name) must be the caller or one of its descendants, and limits are checked at that parent. **Forward allocation (#447, `src/allocate.js`):** `find_bots` (read-only) lists the bots in the caller's branch (Chief: the whole tree) with role, parent, depth and path, matches first; `create_child_bot` reuses a bot with the same role anywhere in the caller's branch instead of creating a duplicate (`{ reused, note }`), and without `parent` puts the new bot under a marked group lead in the branch that its role belongs to ("FlyBot Docs" under "FlyBot Lead"), else under the caller; `create_team` makes a project team as a lead plus children (`Chief → FlyBot Lead → {Dev, Test, …}`, `members` as `"Name: role"` strings), one slot under the parent, reusing an existing lead and same-role members (a flat one moves under the lead, journaled and undoable). Allocation uses the self-organization's primitives (role profiles, leads, moves, journal); a full level, orphans (a bot whose parent is gone is reattached by role on the next repair pass) and duplicates are left to the self-organization, which never asks first. There is no plan card. Limits: depth 5 (root = 1), 8 children per node; duplicate sibling names (case/space-insensitive) are rejected; names are 1–40 characters (ds-bot's roster limit). Every new child gets the same recursive system prompt template (`src/prompt.js`). A new child starts with no person-grant even if its parent is granted; sharing it still needs an independent Sharing → Copy invite link (default deny). - Frontend (`client.js`, bundle id `@flybot/tree`, injects `flybot`): the tree is drawn by ds-bot's own Bot list, so this plugin's separate "Tree" sidebar section is **off by default** (`sidebarSection: true` in the client config brings it back). It always exposes the synchronous helper `ctx.flybot.tree.forest(bots)` (also `globalThis.__flybot__.tree.forest(bots)`), see **Tree data** below. It also registers `flybot.sidebar.structure` (`{ parentOf(botId) }`, parents from `forestOf` over `ctx.flybot.bots()`, refreshed on field changes and every 10 s); ds-bot's sidebar uses it to draw its Bot list as one expandable tree with Chief on top. Retired, archived and outside Bots and other Main Bots have no parent there, so ds-bot shows them directly under Chief; the entry's `nodeActions` draws a tag after such a row ("archived", "archived · merged into …", "outside the tree") and after an idle Bot ("idle"), so the sidebar and the tree model agree. A `flybot.sidebar.section` entry `flybot-tree-reorg` shows the last reorganization (what moved) with Undo and Dismiss, through the host endpoints `tree/reorg`, `tree/undo`, `tree/dismiss`; it draws nothing when there is none in the last 7 days (`reorgNotice: false` in the client config hides it). The entry also has `menuItems({ bot })`, which ds-bot adds to each Bot's right-click menu: one item, `{ id: 'copy-invite', label: 'Copy invite link' }` (with `visible`, `run`); the invite with instructions is offered in the Bot's Sharing section only, so the menu does not list the same invite twice (#173). `visible` is `social.canInvite(bot.id) === true` (false without the social plugin); `run` calls `social.createInvite(botId)` (or `flybot.call('social.createInvite', { botId })`). **Copy invite link** copies only the bare URL (`link`); `copyInvite(flybot, botId, 'text')` copies the full message `text` for other callers; a missing field is an error, never swapped for the other. The clipboard write starts inside the click via `ClipboardItem` with the text as a promise, so it keeps the click's permission while the invite is created; `writeText` then a textarea are the fallbacks. It returns "Invite link for copied", which ds-bot shows as a toast; a failure throws and shows as the error toast. Right-clicking never opens the Bot's chat. ### Config diff --git a/plugins/tree/src/prompt.js b/plugins/tree/src/prompt.js index fa37af7a2..cbfdceac5 100644 --- a/plugins/tree/src/prompt.js +++ b/plugins/tree/src/prompt.js @@ -25,7 +25,7 @@ export function systemPrompt({ name, role, trace, children, root = false, notes const head = root ? [`Bot tree: you (${name}) are also the ROOT of a recursive bot tree of child bots. For tasks you delegate into the tree, follow these tree rules.`, `Your position: ${trace}`] : [`You are ${name}, a bot in a recursive bot tree. Your role: ${role || '(general)'}`, `Your position: ${trace}`] - const tools = 'Tree tools: list_children() shows your current direct children (it may be newer than this prompt). find_bots(query) searches your whole branch (name, role, parent, depth, path). create_child_bot(name, role_description, parent?) creates one Bot under you or under one of your descendants. create_team(team, members, parent?) creates " Lead" with the members under it.' + const tools = `Tree tools: list_children() shows your current direct children (it may be newer than this prompt). find_bots(query) searches your whole branch (name, role, parent, depth, path). create_child_bot(name, role_description, parent?) creates one Bot under you or under one of your descendants. create_team(team, members, parent?) creates " Lead" with the members under it.${root ? ' move_bot(bot, parent) moves an existing Bot (with its subtree) under another parent; the root only.' : ''}` // #446: create_bot is a Main Bot tool; the tree places what it makes by role, under the same limits. const limitRule = 'The tree limits (maxChildren, maxDepth) apply to every way of creating a bot. Do not use create_bot to get around them: a bot made with create_bot is placed in this tree by its role, and when a level is full the tree regroups related bots under a lead (e.g. "FlyBot Lead") by itself.' if (!children.length) { diff --git a/plugins/tree/src/tools.js b/plugins/tree/src/tools.js index 6485a0af0..5eab22c05 100644 --- a/plugins/tree/src/tools.js +++ b/plugins/tree/src/tools.js @@ -47,6 +47,15 @@ export const FIND_BOTS_SCHEMA = Object.freeze({ }, additionalProperties: false, }) +export const MOVE_BOT_SCHEMA = Object.freeze({ + type: 'object', + properties: { + bot: { type: 'string', minLength: 1, maxLength: MAX_NAME_LENGTH, description: 'Id or name of the Bot to move (with its subtree)' }, + parent: { type: 'string', minLength: 1, maxLength: MAX_NAME_LENGTH, description: 'Id or name of the Bot that becomes the new parent' }, + }, + required: ['bot', 'parent'], + additionalProperties: false, +}) export const CREATE_TEAM_SCHEMA = Object.freeze({ type: 'object', properties: { @@ -109,5 +118,16 @@ export function registerTreeTools(fb, tree) { try { return await tree.createTeam(botId, { team: args.team, members: args.members, parent: args.parent }) } catch (e) { return fail(e) } }, }), + fb.registerTool({ + name: 'move_bot', + description: 'Root only. Move an existing Bot with its subtree under another parent. Refused when it would create a cycle, exceed maxChildren or maxDepth, or duplicate a sibling name.', + parameters: MOVE_BOT_SCHEMA, + visibleTo: botId => inTree(botId) && tree.isRoot(botId), + async execute(args) { + const bad = validateArgs(MOVE_BOT_SCHEMA, args) + if (bad) return fail(bad) + try { return tree.moveBot(args.bot, args.parent) } catch (e) { return fail(e) } + }, + }), ] } diff --git a/plugins/tree/src/tree.js b/plugins/tree/src/tree.js index 04cc25ba1..46b91a2d9 100644 --- a/plugins/tree/src/tree.js +++ b/plugins/tree/src/tree.js @@ -36,6 +36,11 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis } const childrenOf = (nodes, id) => Object.values(nodes).filter(n => n.parent === id) const depthOf = (nodes, id) => { let d = 0; for (let x = id, seen = new Set(); x && nodes[x] && !seen.has(x); x = nodes[x].parent) { seen.add(x); d++ } return d } + // Height of the subtree rooted at id: 0 for a leaf. + const heightOf = (nodes, id) => { + const kids = childrenOf(nodes, id) + return kids.length ? 1 + Math.max(...kids.map(k => heightOf(nodes, k))) : 0 + } const traceOf = (nodes, id) => { const p = []; for (let x = id; x && nodes[x] && p.length < 64; x = nodes[x].parent) p.unshift(nodes[x].name); return p.join(' → ') } const promptFor = (id, nodes = snapshot()) => { @@ -203,6 +208,50 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis return { id, name: clean, depth: depthOf(nodes, parentId) + 1 } } + /** Resolves a bot by id or name (trimmed, case-insensitive). Throws when missing or ambiguous. */ + function findBotByName(ref) { + const raw = String(ref ?? '').trim() + if (!raw) throw new Error('bot name required') + const byId = fb.listBots().find(b => b.id === raw) + if (byId) return byId + const clean = raw.toLowerCase() + const hits = fb.listBots().filter(b => String(b.name ?? '').trim().toLowerCase() === clean) + if (hits.length === 0) throw new Error(`no bot named "${raw}"`) + if (hits.length > 1) throw new Error(`more than one bot is named "${raw}"`) + return hits[0] + } + + /** + * Moves an existing bot (with its subtree) under a new parent — the model-facing counterpart of + * deletion, which already re-attaches children upwards. Refused on a cycle, maxChildren overflow, + * duplicate sibling name, or a subtree that would end deeper than maxDepth. The new parent, the old + * parent and the moved subtree get a fresh prompt (their trace changed). + */ + function moveBot(botRef, parentRef) { + const nodes = snapshot() + const bot = findBotByName(botRef) + const parent = findBotByName(parentRef) + if (!nodes[bot.id]) throw new Error(`${bot.name} is not part of the tree`) + if (!nodes[parent.id]) throw new Error(`${parent.name} is not part of the tree`) + if (bot.id === parent.id) throw new Error('a bot cannot be its own parent') + const from = parentOf(bot.id) ?? null + if (from === parent.id) throw new Error(`${bot.name} is already a child of ${parent.name}`) + for (let at = parent.id, seen = new Set(); at != null && nodes[at] && !seen.has(at); at = nodes[at].parent) { + seen.add(at) + if (at === bot.id) throw new Error(`cannot move ${bot.name} under its own descendant ${parent.name}`) + } + const siblings = childrenOf(nodes, parent.id).filter(s => s.id !== bot.id) + if (siblings.length >= limits.maxChildren) throw new Error(`limit reached: ${parent.name} already has ${siblings.length} children (maxChildren=${limits.maxChildren})`) + if (siblings.some(s => String(s.name).trim().toLowerCase() === String(bot.name).trim().toLowerCase())) { + throw new Error(`a child named "${bot.name}" already exists under ${parent.name}`) + } + const bottom = depthOf(nodes, parent.id) + 1 + heightOf(nodes, bot.id) + if (bottom > limits.maxDepth) throw new Error(`limit reached: ${bot.name} would end at depth ${bottom} (maxDepth=${limits.maxDepth})`) + fb.setField(bot.id, 'parent', parent.id) + refreshAll() + 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) const parentOf = id => fb.getField(id, 'parent') ?? null @@ -243,6 +292,13 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis organizer.repair() refreshAll() emit({ botId, key: 'removed' }) + // The deletion freed a slot: a Bot that stayed outside the tree only because a level was full + // joins now, instead of waiting for the next startup (#635). + const chief = findChief() + if (!chief) return + const { adopted, pending } = adoptUnattached(chief.id) + for (const at of pending) at.catch(error => warn('flybot-tree: adoption after a deletion failed:', error?.message ?? error)) + if (adopted.length) refreshAll() } const subscribe = fn => { listeners.add(fn); return () => listeners.delete(fn) } @@ -251,5 +307,5 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis const noteTurnStart = id => load.start(id) const noteTurnEnd = id => load.end(id) const loadOf = id => load.of(id) - return { tidy: tidyNow, maybeTidy, undo, dismiss, overview, noteActivity, noteTurnStart, noteTurnEnd, loadOf, refreshPrompt, parentOf, settled, repairArchiveNames, limits, forest, adoptUnattached, snapshot, childrenOf, depthOf, traceOf, ensureRoot, adoptChief, findChief, migrateLegacyRoots, promptFor, createChild, allocateChild, createTeam, findBots, isMember, isRoot, rootOf, isTreeEdge, onFieldChange, onBotRemoved, subscribe } + return { tidy: tidyNow, maybeTidy, undo, dismiss, overview, noteActivity, noteTurnStart, noteTurnEnd, loadOf, refreshPrompt, parentOf, settled, repairArchiveNames, limits, forest, adoptUnattached, snapshot, childrenOf, depthOf, heightOf, traceOf, ensureRoot, adoptChief, findChief, migrateLegacyRoots, promptFor, createChild, allocateChild, createTeam, findBots, moveBot, findBotByName, isMember, isRoot, rootOf, isTreeEdge, onFieldChange, onBotRemoved, subscribe } } diff --git a/plugins/tree/test/tree.test.js b/plugins/tree/test/tree.test.js index 5c06cde7e..089a5da14 100644 --- a/plugins/tree/test/tree.test.js +++ b/plugins/tree/test/tree.test.js @@ -1458,3 +1458,66 @@ test('#450: a merge carries the archived Bot\'s own memory into the kept Bot', a assert.ok(moved, JSON.stringify(kept)) assert.equal(moved.meta.source, 'merged') }) + +test('#635: move_bot moves a bot with its subtree and refreshes the prompts', async () => { + const m = await setup() + const a = await make(m, m.root, 'A') + const b = await make(m, m.root, 'B') + const c = await make(m, a, 'C') + const r = await m.call(m.root, 'move_bot', { bot: 'C', parent: 'B' }) + assert.equal(r.error, undefined, JSON.stringify(r)) + assert.equal(f(m, c, 'parent'), b) + assert.match(sp(m, c), /Your position: Chief → B → C/) + assert.match(sp(m, b), /- C: C stuff/) + assert.match(sp(m, a), /You have no children/) +}) + +test('#635: move_bot refuses cycles, overflow, duplicates and a too deep subtree', async () => { + const m = await setup({ maxChildren: 2 }) + const a = await make(m, m.root, 'A') + const b = await make(m, m.root, 'B') + await make(m, a, 'C') + assert.match((await m.call(m.root, 'move_bot', { bot: 'A', parent: 'C' })).error, /descendant/) + assert.match((await m.call(m.root, 'move_bot', { bot: 'A', parent: 'A' })).error, /its own parent/) + assert.match((await m.call(m.root, 'move_bot', { bot: 'A', parent: 'Chief' })).error, /already a child/) + const x1 = await make(m, a, 'X') + const x2 = await make(m, b, 'X') + assert.ok(x1 && x2) + assert.match((await m.call(m.root, 'move_bot', { bot: 'X', parent: 'B' })).error, /more than one bot/) + assert.match((await m.call(m.root, 'move_bot', { bot: x1, parent: b })).error, /already exists/) +}) + +test('#635: a Bot left outside because the tree was full joins when a deletion frees a slot', async () => { + const m = await setup({ maxChildren: 1 }) + const a = await make(m, m.root, 'A') + const outside = await outsider(m, 'Wanderer') // root full: stays outside, no tree fields + await m.tree.settled() + assert.equal(f(m, outside, 'parent'), undefined, 'the full root keeps it out') + m.removeBot(a) // frees the slot; no restart, no 'bot.created' + await m.tree.settled() + assert.equal(f(m, outside, 'parent'), m.root, 'the freed slot is used right away') + assert.match(sp(m, outside), /recursive bot tree/i) +}) + +test('#635: move_bot refuses an overflowing parent and refreshes the subtree', async () => { + const m = await setup({ maxChildren: 2 }) + const a = await make(m, m.root, 'A') + const b = await make(m, m.root, 'B') + const c = await make(m, a, 'C') + const d = await make(m, c, 'D') + assert.match((await m.call(m.root, 'move_bot', { bot: 'C', parent: 'Chief' })).error, /maxChildren=2/) + const ok = await m.call(m.root, 'move_bot', { bot: 'C', parent: 'B' }) + assert.equal(ok.error, undefined, JSON.stringify(ok)) + assert.equal(ok.name, 'C') + assert.equal(ok.to, b) + assert.equal(f(m, d, 'parent'), c) + assert.match(sp(m, d), /Your position: Chief → B → C → D/) +}) + +test('#635: move_bot is visible to the root only', async () => { + const m = await setup() + const a = await make(m, m.root, 'A') + await assert.rejects(m.call(a, 'move_bot', { bot: 'A', parent: m.root }), /not visible/) +}) + +