Skip to content
Merged
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/2430-tree-retired.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
2 changes: 1 addition & 1 deletion docs/subsystems/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`) 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 (<trigger>): …` 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.
Expand Down
60 changes: 57 additions & 3 deletions plugins/tree/src/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
/**
Expand Down
57 changes: 57 additions & 0 deletions plugins/tree/test/tree.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down Expand Up @@ -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')
Expand Down
Loading