Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/tree-root-follows-main.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
4 changes: 4 additions & 0 deletions docs/subsystems/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 14 additions & 2 deletions plugins/tree/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)),
Expand Down
10 changes: 10 additions & 0 deletions plugins/tree/src/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions plugins/tree/test/tree.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading