From 34966f7590cee16cc1489f70f72e16133f5068ac Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:58:54 +0800 Subject: [PATCH] Move the tree root when the Main Bot changes roster.setMain changes mainBotIds only; the tree adopts a root on bot.created and its startup timers, so the previous Chief stayed root and the current Main was not adopted (and could become a second root). Mark the root the tree adopted and demote exactly that one when the Main changes, adopting the current Main at the next turn boundary (setMain fires no event the plugin sees); the previous root is placed like any other Bot. Fixes #748 --- changelog.d/tree-root-follows-main.fixed.md | 1 + docs/subsystems/tree.md | 4 ++++ plugins/tree/index.js | 16 ++++++++++++++-- plugins/tree/src/tree.js | 10 ++++++++++ plugins/tree/test/tree.test.js | 12 ++++++++++++ 5 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 changelog.d/tree-root-follows-main.fixed.md diff --git a/changelog.d/tree-root-follows-main.fixed.md b/changelog.d/tree-root-follows-main.fixed.md new file mode 100644 index 000000000..d7fbb038d --- /dev/null +++ b/changelog.d/tree-root-follows-main.fixed.md @@ -0,0 +1 @@ +Handing the Main role to another Bot now moves the tree's root with it on the next turn: exactly one root remains, the previous Chief is demoted and placed in the tree, and the new Main gets the tree tools instead of waiting for a restart ([#748](https://github.com/ThinkFlowLab/FlyBot/issues/748)). diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index 69f2af53b..56d31bbc4 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -46,6 +46,10 @@ 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. +## The root follows the Main Bot + +`adoptChief` marks the current Main as the root and records it as the tree's adopted root; when the Main changes it clears that record, `treeRole` and `parent` on the previously adopted root (setting it back to an unattached member, which adoption then places by role), so the adopted root follows the Main and a second root another flow created is left alone. A turn boundary checks this before the prompts are written, because `roster.setMain` fires no event the plugin sees. + ### 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. diff --git a/plugins/tree/index.js b/plugins/tree/index.js index 4dc695c9f..33c8b218f 100644 --- a/plugins/tree/index.js +++ b/plugins/tree/index.js @@ -22,6 +22,18 @@ export function apply(ctx, config = {}) { const parent = tree.parentOf(id) if (typeof parent === 'string' && tree.isMember(parent)) tree.refreshPrompt(parent) } + // A Main Bot change (roster.setMain) fires no event this plugin sees: when the current Main is not + // the root, adopt it before the turn's prompts are written, so a coordinator never works under an + // old root and the tree never has two. + const reconcileRoot = () => { + try { + const chief = tree.findChief() + if (chief === undefined || tree.snapshot()[chief.id]?.root === true) return + void tree.ensureRoot().catch(error => warn('flybot-tree: root not updated after a Main Bot change:', error?.message ?? error)) + } catch (error) { + warn('flybot-tree: root not updated after a Main Bot change:', error?.message ?? error) + } + } const disposers = [ fb.useSend('tree', treeSendMiddleware(tree, { bypassSocial: config.bypassSocial === true, isRemote: fb.isRemote, hasAnyGrant: fb.hasAnyGrant })), ...registerTreeTools(fb, tree), @@ -35,8 +47,8 @@ export function apply(ctx, config = {}) { // reads stays current (writeBlock only rewrites the block when the line really changed). // The feed names the turn it is about (turnId); dropping it made an end close whichever turn was // oldest, so with two open and the later one ending first, openFor reported the newer age (#696). - fb.onFeed('turn.start', e => { tree.noteTurnStart(e?.botId, e?.turnId); refreshParentPrompt(e?.botId) }), - fb.onFeed('turn.end', e => { tree.noteTurnEnd(e?.botId, e?.turnId); refreshParentPrompt(e?.botId) }), + fb.onFeed('turn.start', e => { tree.noteTurnStart(e?.botId, e?.turnId); reconcileRoot(); refreshParentPrompt(e?.botId) }), + fb.onFeed('turn.end', e => { tree.noteTurnEnd(e?.botId, e?.turnId); reconcileRoot(); refreshParentPrompt(e?.botId) }), // The tree view: what the last reorganization did, one-click undo, dismiss. fb.handleApi('tree/reorg', () => tree.overview(), { read: true }), fb.handleApi('tree/undo', payload => tree.undo(typeof payload?.runId === 'string' ? payload.runId : undefined)), diff --git a/plugins/tree/src/tree.js b/plugins/tree/src/tree.js index a67f79b3b..6feb0b953 100644 --- a/plugins/tree/src/tree.js +++ b/plugins/tree/src/tree.js @@ -119,6 +119,16 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis function adoptChief() { const chief = findChief() if (!chief) return undefined + // The Main role can move (roster.setMain): the tree marks the root it adopted, so exactly that + // one is demoted when the Main changes (a second root another flow created is left alone) and + // adoption places it like any other Bot instead of leaving two roots. + for (const bot of fb.listBots()) { + if (bot.id === chief.id || fb.getField(bot.id, 'adoptedRoot') !== true) continue + fb.setField(bot.id, 'adoptedRoot', undefined) + fb.setField(bot.id, 'treeRole', undefined) + fb.setField(bot.id, 'parent', undefined) + } + if (fb.getField(chief.id, 'adoptedRoot') !== true) fb.setField(chief.id, 'adoptedRoot', true) if (fb.getField(chief.id, 'parent') !== undefined) fb.setField(chief.id, 'parent', null) if (fb.getField(chief.id, 'treeRole') !== ROOT_ROLE) fb.setField(chief.id, 'treeRole', ROOT_ROLE) migrateLegacyRoots(chief.id) diff --git a/plugins/tree/test/tree.test.js b/plugins/tree/test/tree.test.js index 3e10e4dc2..5f941092a 100644 --- a/plugins/tree/test/tree.test.js +++ b/plugins/tree/test/tree.test.js @@ -81,6 +81,18 @@ 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 new Main Bot takes the root on the next turn, and the old root is demoted', async () => { + const m = await setup() + await make(m, m.root, 'Work') + const chief2 = m.addBot({ name: 'Chief2', isMain: true }) + m.bots.get(m.root).isMain = false + m.feed('turn.start', { botId: chief2, place: 'chat', sessionId: 's', turnId: 't', turn: 1, time: 1 }) + const roots = [...m.bots.values()].filter(bot => bot.fields['flybot-tree']?.treeRole === 'root') + assert.deepEqual(roots.map(bot => bot.name), ['Chief2'], 'exactly one root, the current Main') + assert.notEqual(f(m, m.root, 'treeRole'), 'root') + assert.equal(m.tree.isRoot(m.root), false, 'the previous root is placed like any other Bot') +}) + test('tools: configurable limits and hidden from non-tree bots', async () => { const m = await setup({ maxDepth: 2, maxChildren: 1 }) const a = await make(m, m.root, 'A')