From 0c819a276e45226e98a115f52bc3bfd9a3a29c2b Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:49:13 +0800 Subject: [PATCH] Tree load: refresh the coordinator prompt on a child's turn boundary (#508 review) The Child-load line was baked into a coordinator's prompt only when the tree was rewritten, so it could go stale while list_children stayed current. Now a child's turn.start / turn.end refreshes its parent's prompt (the marked block only; writeBlock skips an unchanged block), and the docs and test assert the stored prompt, not a live recompute. --- changelog.d/tree-load.added.md | 1 + docs/subsystems/tree.md | 11 ++++++-- plugins/tree/index.js | 11 ++++++++ plugins/tree/src/load.js | 44 ++++++++++++++++++++++++++++++++ plugins/tree/src/tools.js | 2 +- plugins/tree/src/tree.js | 15 +++++++++-- plugins/tree/test/load.test.js | 26 +++++++++++++++++++ plugins/tree/test/mock-flybot.js | 2 +- plugins/tree/test/tree.test.js | 14 ++++++++++ 9 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 changelog.d/tree-load.added.md create mode 100644 plugins/tree/src/load.js create mode 100644 plugins/tree/test/load.test.js diff --git a/changelog.d/tree-load.added.md b/changelog.d/tree-load.added.md new file mode 100644 index 000000000..0dc0ce518 --- /dev/null +++ b/changelog.d/tree-load.added.md @@ -0,0 +1 @@ +A tree coordinator can now see how busy its children are. Each Bot's running turns are counted from the activity feed, shown as one line in the coordinator's prompt (`Child load right now — Work: busy (2 running), Travel: idle`) and a `load` count on each entry of `list_children`, so a new part goes to a free child instead of a busy one. Nothing is stored; the counts start from zero on each start. diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index 2a07f80d2..874f3ca8a 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -26,6 +26,7 @@ Everything in `plugins/tree/` (entry `plugins/tree/index.js` and any helpers). | `plugins/tree/src/organize.js` | Self-organization (#446): placement by role, role groups and leads, repair (over-full parents, orphans), daily tidy (merge, idle, archive), journal and undo. Exposes the shared primitives (`shared`) allocation uses. | | `plugins/tree/src/allocate.js` | Forward allocation (#447): `find_bots`, reuse before create, `create_child_bot(parent?)` placement, `create_team` (lead plus children), on `organize.js`'s shared primitives. | | `plugins/tree/src/roles.js` | Role profiles and similarity (name, role label, description, own prompt, recent tools; deterministic, `classify` hook). | +| `plugins/tree/src/load.js` | Runtime load (#507): pure `createLoadTracker`/`loadWord`/`loadLine` for the open turns each Bot runs, fed from the activity feed. | ## Entry points @@ -70,7 +71,13 @@ The tree organizes itself by role instead of leaving Bots outside it or a parent - **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. - **No silent pull-up**: freeing a slot moves nobody up; the next new Bot takes it. Activity is metadata only (when, which tool names), never message text. -### Optional Tree section toggle +### Runtime load (#507) + +A coordinator can see which of its children are busy right now, so it sends a new part to a free one instead of stacking work on a busy one. + +- `src/load.js` (pure) counts the open turns per Bot from the core activity feed (`turn.start` / `turn.end`, ids and times only). `index.js` subscribes both events; nothing is persisted (a restart starts from zero). +- The coordinator's prompt gains one line — `Child load right now — Work: busy (2 running), Travel: idle.` — and `list_children` carries a `load` count per child. A child's turn boundary rewrites its parent's prompt through `refreshPrompt` (the marked block only, and `writeBlock` skips an unchanged block), so the line stays current; `list_children` reads the tracker live. + - The Bot list itself uses the core `.bt-tree-toggle` (see [core-client](core-client.md)). When `sidebarSection: true` brings this plugin's separate Tree section back, `.flybot-tree-toggle` matches that control: 28×28 hit target, 16px inner `.flybot-tree-caret`, hover, focus ring, and rotation of the glyph only. Leaf spacers (`.flybot-tree-dot`) keep the same 28px width. `aria-expanded` and Expand/Collapse `{name}` labels stay on the button. - The right-click menu entry from `menuItems({ bot })` is a single **Copy invite link** (bare URL, shown when `social.canInvite(bot.id)`). The invite with instructions is offered only in the Bot's Sharing section, so the menu does not list the same invite twice (#173). @@ -81,7 +88,7 @@ The plugin's discarded `.catch(() => {})` calls carry a one-line reason comment ## Tests -Covered by the plugin's own tests under `plugins/tree/` / core test suite as wired. The `#447` tests cover reuse of a same-role Bot at depth 3 and `find_bots` (scope, fields, ranking), `create_child_bot(parent)` refusing a non-descendant (sibling, ancestor, outsider, unknown) and checking limits at the target under its lock, `create_team` (Chief → FlyBot Lead → four members, one slot; reuse of the lead and members; a flat member's slot at a full level, journal and undo), later Bots joining their group's lead (only down leads of groups they belong to), and orphan reattachment; `test/host.test.js` runs `find_bots`, `create_team` and `create_child_bot(parent)` on the real host. The `#446` tests in `test/tree.test.js` cover role grouping (Life Lead, FlyBot Lead), existing leads, no unrelated fallback, the cap at every step, the daily tidy (merge, idle, archive), journal, undo and the client notice; `test/host.test.js` runs `create_bot` and the `tree/reorg` / `tree/undo` endpoints on the real host. `#16 Option B` in `test/tree.test.js` (and the matching host test) asserts a non-Main tree Bot can create a child under itself, that `maxChildren`/depth still refuse, and that the child is not auto-granted. +Covered by the plugin's own tests under `plugins/tree/` / core test suite as wired. The `#447` tests cover reuse of a same-role Bot at depth 3 and `find_bots` (scope, fields, ranking), `create_child_bot(parent)` refusing a non-descendant (sibling, ancestor, outsider, unknown) and checking limits at the target under its lock, `create_team` (Chief → FlyBot Lead → four members, one slot; reuse of the lead and members; a flat member's slot at a full level, journal and undo), later Bots joining their group's lead (only down leads of groups they belong to), and orphan reattachment; `test/host.test.js` runs `find_bots`, `create_team` and `create_child_bot(parent)` on the real host. The `#446` tests in `test/tree.test.js` cover role grouping (Life Lead, FlyBot Lead), existing leads, no unrelated fallback, the cap at every step, the daily tidy (merge, idle, archive), journal, undo and the client notice; `test/host.test.js` runs `create_bot` and the `tree/reorg` / `tree/undo` endpoints on the real host. `#16 Option B` in `test/tree.test.js` (and the matching host test) asserts a non-Main tree Bot can create a child under itself, that `maxChildren`/depth still refuse, and that the child is not auto-granted. Runtime load (#507) is covered by `test/load.test.js` (pure) and a `test/tree.test.js` case: a child's running turns show in the coordinator's stored prompt and `list_children`, and drop to zero at `turn.end`. ## Common mistakes diff --git a/plugins/tree/index.js b/plugins/tree/index.js index 7443724c9..a14757bad 100644 --- a/plugins/tree/index.js +++ b/plugins/tree/index.js @@ -15,6 +15,12 @@ export function apply(ctx, config = {}) { const logger = typeof ctx.logger === 'function' ? ctx.logger('flybot-tree') : ctx.logger const warn = (...args) => (logger?.warn ?? console.warn)(...args) const tree = createTree(fb, { limits: resolveLimits(config), rootName: config.rootName, rootBotId: config.rootBotId, adoptExisting: config.adoptExisting !== false, warn, classify: config.classify, tidy: config.tidy, now: typeof config.now === 'function' ? config.now : undefined }) + // A child's turn boundary changes its load; rewrite its parent's prompt so the Child-load line a + // coordinator reads is current (#507). writeBlock skips an unchanged block, so nothing churns. + const refreshParentPrompt = (id) => { + const parent = tree.parentOf(id) + if (typeof parent === 'string' && tree.isMember(parent)) tree.refreshPrompt(parent) + } const disposers = [ fb.useSend('tree', treeSendMiddleware(tree, { bypassSocial: config.bypassSocial === true, isRemote: fb.isRemote, hasAnyGrant: fb.hasAnyGrant })), ...registerTreeTools(fb, tree), @@ -23,6 +29,11 @@ export function apply(ctx, config = {}) { // Recent activity (when, which tools) feeds idle detection and role grouping (#446). fb.onFeed('turn.start', e => tree.noteActivity(e?.botId)), fb.onFeed('tool.start', e => tree.noteActivity(e?.botId, e?.name)), + // Runtime load: how many turns each Bot runs now, so a coordinator routes to a free child (#507). + // A change is also written into the child's parent prompt, so the Child-load line a coordinator + // reads stays current (writeBlock only rewrites the block when the line really changed). + fb.onFeed('turn.start', e => { tree.noteTurnStart(e?.botId); refreshParentPrompt(e?.botId) }), + fb.onFeed('turn.end', e => { tree.noteTurnEnd(e?.botId); 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/load.js b/plugins/tree/src/load.js new file mode 100644 index 000000000..1eed1dc55 --- /dev/null +++ b/plugins/tree/src/load.js @@ -0,0 +1,44 @@ +// Runtime load of tree Bots (#507): how many turns each Bot is running right now, so a +// coordinator can route a new part to a child that is free instead of stacking work on a busy +// one. The core's activity feed (turn.start / turn.end) is the only input; it carries ids and +// times, never text. Pure and process-local: nothing here is persisted. +export function createLoadTracker() { + /** @type {Map} open turns per Bot id */ + const open = new Map() + return { + start(id) { + if (typeof id !== 'string' || id === '') return + open.set(id, (open.get(id) ?? 0) + 1) + }, + end(id) { + if (typeof id !== 'string' || id === '') return + const next = (open.get(id) ?? 0) - 1 + if (next <= 0) open.delete(id) + else open.set(id, next) + }, + of(id) { + return open.get(id) ?? 0 + }, + snapshot() { + return Object.fromEntries(open) + }, + } +} + +/** How a load count reads to the model: idle, working, or busy with the running count. */ +export function loadWord(count) { + const n = Number(count) || 0 + if (n <= 0) return 'idle' + if (n === 1) return 'working' + return `busy (${n} running)` +} + +/** + * One prompt line for a coordinator: which of its direct children are free and which are busy, + * so it can send a part to an idle one. Undefined when the Bot has no children (nothing to route). + */ +export function loadLine(children, countOf) { + if (!Array.isArray(children) || children.length === 0) return undefined + const parts = children.map(child => `${child.name}: ${loadWord(countOf(child.id))}`) + return `Child load right now — ${parts.join(', ')}. Send a new part to a child that is idle or working before one that is busy.` +} diff --git a/plugins/tree/src/tools.js b/plugins/tree/src/tools.js index 74b0cf1ae..6485a0af0 100644 --- a/plugins/tree/src/tools.js +++ b/plugins/tree/src/tools.js @@ -73,7 +73,7 @@ export function registerTreeTools(fb, tree) { if (bad) return fail(bad) const nodes = tree.snapshot() if (!nodes[botId]) return fail('this bot is not part of the tree') - return { children: tree.childrenOf(nodes, botId).map(({ id, name, role }) => ({ id, name, role })), depth: tree.depthOf(nodes, botId), limits: tree.limits } + return { children: tree.childrenOf(nodes, botId).map(({ id, name, role }) => ({ id, name, role, load: tree.loadOf(id) })), depth: tree.depthOf(nodes, botId), limits: tree.limits } }, }), fb.registerTool({ diff --git a/plugins/tree/src/tree.js b/plugins/tree/src/tree.js index bc2ff27dc..04cc25ba1 100644 --- a/plugins/tree/src/tree.js +++ b/plugins/tree/src/tree.js @@ -12,6 +12,7 @@ import { systemPrompt, withTreeBlock, hasTreeBlock } from './prompt.js' import { forestOf } from './forest.js' import { createOrganizer } from './organize.js' import { createAllocator } from './allocate.js' +import { createLoadTracker, loadLine } from './load.js' export { forestOf } export const TREE_KEYS = new Set(['parent', 'treeRole']) @@ -21,6 +22,7 @@ const ROOT_LABEL = 'Main bot and root of the tree' export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExisting = true, warn = () => {}, classify, tidy, now = Date.now } = {}) { const listeners = new Set() + const load = createLoadTracker() function snapshot() { const nodes = {} @@ -38,7 +40,7 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis const promptFor = (id, nodes = snapshot()) => { const n = nodes[id] - return n && systemPrompt({ name: n.name, role: n.role, trace: traceOf(nodes, id), children: childrenOf(nodes, id), root: n.root, notes: notesFor(id, n), limits }) + return n && systemPrompt({ name: n.name, role: n.role, trace: traceOf(nodes, id), children: childrenOf(nodes, id), root: n.root, notes: [...notesFor(id, n), ...loadNotes(id, nodes)], limits }) } const stamp = at => { const d = new Date(at); const p = v => String(v).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}` } // The Main Bot hears what the last reorganization did (the owner sees it in the tree view); a kept @@ -56,6 +58,12 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis if (Array.isArray(merged) && merged.length) notes.push(`You took over the role of ${merged.map(m => m.name).join(', ')} (merged into you; the old Bot is archived with its own chat history).`) return notes } + // The runtime load of a coordinator's direct children (#507), as one prompt line, so it can + // send a new part to a free child instead of a busy one. + function loadNotes(id, nodes) { + const line = loadLine(childrenOf(nodes, id), childId => load.of(childId)) + return line === undefined ? [] : [line] + } // Prompt injection: only the tree's marked block is replaced; other text (e.g. Chief's own) stays. const writeBlock = (id, block) => { const current = fb.getSystemPrompt(id) @@ -240,5 +248,8 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis const { tidy: tidyNow, maybeTidy, undo, dismiss, overview, noteActivity, idle: settled, repairArchiveNames } = organizer const { findBots, createChild: allocateChild, createTeam } = allocator - return { tidy: tidyNow, maybeTidy, undo, dismiss, overview, noteActivity, 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 } + 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 } } diff --git a/plugins/tree/test/load.test.js b/plugins/tree/test/load.test.js new file mode 100644 index 000000000..abf551c79 --- /dev/null +++ b/plugins/tree/test/load.test.js @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createLoadTracker, loadLine, loadWord } from '../src/load.js' + +test('the load tracker counts open turns and floors at zero', () => { + const load = createLoadTracker() + assert.equal(load.of('a'), 0) + load.start('a'); load.start('a') + assert.equal(load.of('a'), 2) + assert.deepEqual(load.snapshot(), { a: 2 }) + load.end('a') + assert.equal(load.of('a'), 1) + load.end('a'); load.end('a') + assert.equal(load.of('a'), 0, 'an extra end never goes negative') + load.start(''); load.end(undefined) + assert.deepEqual(load.snapshot(), {}) +}) + +test('loadWord and loadLine read plainly, and loadLine is undefined with no children', () => { + assert.equal(loadWord(0), 'idle') + assert.equal(loadWord(1), 'working') + assert.equal(loadWord(3), 'busy (3 running)') + assert.equal(loadLine([], () => 0), undefined) + const line = loadLine([{ id: 'a', name: 'Work' }, { id: 'b', name: 'Travel' }], id => (id === 'a' ? 2 : 0)) + assert.match(line, /Work: busy \(2 running\), Travel: idle/) +}) diff --git a/plugins/tree/test/mock-flybot.js b/plugins/tree/test/mock-flybot.js index 86292b94d..5e23b1d1a 100644 --- a/plugins/tree/test/mock-flybot.js +++ b/plugins/tree/test/mock-flybot.js @@ -21,7 +21,7 @@ export function toParameterMap(p) { export function createMockFlybot() { const bots = new Map() // id -> { id, name, systemPrompt, fields: { [plugin]: {...} } } - const listeners = { 'change': new Set(), 'bot.created': new Set(), 'bot.removed': new Set(), 'bot.grants': new Set(), 'turn.start': new Set(), 'tool.start': new Set() } + const listeners = { 'change': new Set(), 'bot.created': new Set(), 'bot.removed': new Set(), 'bot.grants': new Set(), 'turn.start': new Set(), 'turn.end': new Set(), 'tool.start': new Set() } const apis = new Map() const middlewares = [] const tools = new Map() diff --git a/plugins/tree/test/tree.test.js b/plugins/tree/test/tree.test.js index e50824a7b..6aa176578 100644 --- a/plugins/tree/test/tree.test.js +++ b/plugins/tree/test/tree.test.js @@ -1428,3 +1428,17 @@ test('#447: without parent, a new Bot goes only down leads of groups it belongs // Asked from inside FlyBot Lead, its dev group takes a dev Bot. assert.equal(f(m, (await child(m, team.team.lead.id, 'Style Checker', 'checks code style')).created.id, 'parent'), quality) }) + +test('#507: a child\'s running turns show as load in the coordinator\'s stored prompt and list_children', 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 }) + // The coordinator's prompt is rewritten at the child's turn boundary, not only read live. + assert.match(sp(m, m.root), /Child load right now — Work: busy \(2 running\)/) + assert.deepEqual((await m.call(m.root, 'list_children')).children.map(c => [c.name, c.load]), [['Work', 2]]) + m.feed('turn.end', { botId: work, place: 'chat', sessionId: 's', turnId: 't1', turn: 1, time: 3 }) + m.feed('turn.end', { botId: work, place: 'chat', sessionId: 's', turnId: 't2', turn: 2, time: 4 }) + assert.equal(m.tree.loadOf(work), 0) + assert.match(sp(m, m.root), /Child load right now — Work: idle/) +})