diff --git a/changelog.d/tree-find-load.added.md b/changelog.d/tree-find-load.added.md new file mode 100644 index 000000000..f0ed9b125 --- /dev/null +++ b/changelog.d/tree-find-load.added.md @@ -0,0 +1 @@ +The tree's `find_bots` now reports each Bot's live runtime load (how many turns it is running), the same number `list_children` shows, so a coordinator can pick a free Bot when it searches the branch. diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index c83078bc6..16d938396 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -50,7 +50,7 @@ Everything in `plugins/tree/` (entry `plugins/tree/index.js` and any helpers). 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. -- **Reuse before create.** `find_bots(query?)` (read-only, every tree Bot) lists the Bots in the caller's branch (for Chief: the whole tree) with `id`, `name`, `role`, `parent`, `depth`, `path`, `children`, `lead` and `via` (the caller's direct child on the path, whom it delegates to). With a query, only matches, best first: same role (`sameRole`), the query in the name or role, shared role words (stems: "test" finds "Tester"), the same project, the same domain. Empty query: an overview (first 100). `create_child_bot` checks the whole branch first: a Bot with the same role at any depth is returned as `{ reused, note }` and nothing is created. A Bot outside the caller's branch is not looked at (the daily tidy merges duplicates across branches). +- **Reuse before create.** `find_bots(query?)` (read-only, every tree Bot) lists the Bots in the caller's branch (for Chief: the whole tree) with `id`, `name`, `role`, `parent`, `depth`, `path`, `children`, `load` (its live open turns, like `list_children`, #508) `lead` and `via` (the caller's direct child on the path, whom it delegates to). With a query, only matches, best first: same role (`sameRole`), the query in the name or role, shared role words (stems: "test" finds "Tester"), the same project, the same domain. Empty query: an overview (first 100). `create_child_bot` checks the whole branch first: a Bot with the same role at any depth is returned as `{ reused, note }` and nothing is created. A Bot outside the caller's branch is not looked at (the daily tidy merges duplicates across branches). - **Placement-aware creation.** `create_child_bot(name, role_description, parent?)`: `parent` (id or unique name) must be the caller or a descendant, else `parent must be you or one of your descendants`. Limits, depth and the sibling-name check apply at the target, under that parent's lock. Without `parent`, a **marked group lead** in the caller's branch whose group the new Bot belongs to takes it (most specific group first, then the shallower lead, when it has room): "FlyBot Docs" goes under "FlyBot Lead". A Bot that could lead that group itself, or a namesake, never goes under it; with no such lead the caller is the parent. The result names the parent and the tree path. - **Teams: lead plus children.** `create_team(team, members, parent?)` with `members` as `"Name: role"` strings (2 to `maxChildren`): `" Lead"` (role "Lead of the group: coordinates …", `lead { key, label }` from `roles.js`) under the caller or `parent`, and the members under it: `Chief → FlyBot Lead → {Dev, Test, Maintenance, Repo}`, one slot under Chief. An existing lead of that group in the caller's branch (marked for it, named "" / " Lead", or coordinating it) is reused and marked; a member with the same role already in the branch is reused, and moved under the lead when it sits flat next to it (that frees its slot, so a team fits at a full level when it reuses a flat member). Members that do not fit are listed as not created. A team that moves or marks existing Bots is a journaled run (trigger `team`, one-click undo); a fresh team is a plain create. - **Plan card**: not built. The codebase has no chat card for a model-proposed plan (approval cards belong to social's message holds), so forward allocation proceeds and names the resulting paths; deferral recorded on #447. Automatic reorganization never asks first. diff --git a/plugins/tree/src/allocate.js b/plugins/tree/src/allocate.js index d7846a9f6..849eef18f 100644 --- a/plugins/tree/src/allocate.js +++ b/plugins/tree/src/allocate.js @@ -20,7 +20,7 @@ export const MAX_TEAM_LENGTH = MAX_NAME_LENGTH - LEAD_SUFFIX.length const MAX_FOUND = 30 const MAX_OVERVIEW = 100 -export function createAllocator({ fb, limits, snapshot, childrenOf, depthOf, traceOf, shared, createUnder, withParentLock, refreshAll, classify }) { +export function createAllocator({ fb, limits, snapshot, childrenOf, depthOf, traceOf, shared, createUnder, withParentLock, refreshAll, classify, loadOf = () => 0 }) { const { profile, subtreeOf, heightOf, fits, homesFor, createLead, markLead, move, begin, commit, exclusive } = shared const get = (id, key) => fb.getField(id, key) const profileFor = (name, role) => profileOf({ name, treeRole: role }, classify) @@ -53,7 +53,7 @@ export function createAllocator({ fb, limits, snapshot, childrenOf, depthOf, tra const lead = get(id, 'lead') return { id, name: n.name, role: n.role, depth: depthOf(nodes, id), path: traceOf(nodes, id), - parent: nodes[n.parent]?.name ?? null, children: childrenOf(nodes, id).length, + parent: nodes[n.parent]?.name ?? null, children: childrenOf(nodes, id).length, load: loadOf(id), ...(lead?.label ? { lead: lead.label } : {}), ...(via && via.id !== id ? { via: via.name } : {}), } } diff --git a/plugins/tree/src/tree.js b/plugins/tree/src/tree.js index 04cc25ba1..29d67b67c 100644 --- a/plugins/tree/src/tree.js +++ b/plugins/tree/src/tree.js @@ -76,7 +76,7 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis const findRootId = () => { const nodes = snapshot(); return Object.values(nodes).find(n => n.root)?.id ?? Object.values(nodes).find(n => n.parent === null)?.id } const organizer = createOrganizer({ fb, limits, snapshot, childrenOf, depthOf, warn, refreshAll: () => refreshAll(), writeBlock: (id, block) => writeBlock(id, block), findRootId, now, classify, tidy }) // Forward allocation (#447) on the organizer's shared primitives: find_bots, reuse, parent?, teams. - const allocator = createAllocator({ fb, limits, snapshot, childrenOf, depthOf, traceOf, shared: organizer.shared, createUnder: (parentId, name, role) => createChildNow(parentId, name, role), withParentLock: (id, fn) => withParentLock(id, fn), refreshAll: () => refreshAll(), classify }) + const allocator = createAllocator({ fb, limits, snapshot, childrenOf, depthOf, traceOf, shared: organizer.shared, createUnder: (parentId, name, role) => createChildNow(parentId, name, role), withParentLock: (id, fn) => withParentLock(id, fn), refreshAll: () => refreshAll(), classify, loadOf: id => load.of(id) }) // ---------------------------------------------------------------- root /** The Main Bot (Chief): config.rootBotId if it exists, else the Main Bot named 'Chief', else the first Main Bot. */ diff --git a/plugins/tree/test/tree.test.js b/plugins/tree/test/tree.test.js index 5c06cde7e..52b1f9b93 100644 --- a/plugins/tree/test/tree.test.js +++ b/plugins/tree/test/tree.test.js @@ -1296,7 +1296,7 @@ test('#447: create_child_bot reuses a Bot with the same role at depth 3 instead // find_bots: the root sees the whole tree; a match at depth 3 comes first. const found = await m.call(m.root, 'find_bots', { query: 'tester' }) assert.equal(found.scope, 'the whole tree') - assert.deepEqual({ ...found.bots[0], role: undefined }, { id: tester, name: 'Tester', role: undefined, depth: 3, path: 'Chief → Engineering → Tester', parent: 'Engineering', children: 0, via: 'Engineering', match: 'same role' }) + assert.deepEqual({ ...found.bots[0], role: undefined }, { id: tester, name: 'Tester', role: undefined, depth: 3, path: 'Chief → Engineering → Tester', parent: 'Engineering', children: 0, load: 0, via: 'Engineering', match: 'same role' }) assert.equal(found.bots[0].role, 'Runs the test suite and reports failures') assert.ok((await m.call(m.root, 'find_bots', { query: 'test' })).bots.some(b => b.id === tester), 'a stem of a role word matches') const all = await m.call(m.root, 'find_bots', {}) @@ -1458,3 +1458,15 @@ 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('#508 follow-up: find_bots reports each Bot\'s runtime load', async () => { + const m = await setup() + const work = await make(m, m.root, 'Work') + m.feed('turn.start', { botId: work, place: 'chat', sessionId: 's', turnId: 't1', turn: 1, time: 1 }) + m.feed('turn.start', { botId: work, place: 'chat', sessionId: 's', turnId: 't2', turn: 2, time: 2 }) + const found = await m.call(m.root, 'find_bots', {}) + assert.equal(found.bots.find(bot => bot.name === 'Work').load, 2) + // A search result carries the same field. + const one = await m.call(m.root, 'find_bots', { query: 'Work' }) + assert.equal(one.bots.find(bot => bot.name === 'Work').load, 2) +})