diff --git a/changelog.d/733-list-children-fields.changed.md b/changelog.d/733-list-children-fields.changed.md new file mode 100644 index 000000000..eee7b8cde --- /dev/null +++ b/changelog.d/733-list-children-fields.changed.md @@ -0,0 +1 @@ +`list_children` now describes the routing fields it already answers ([#733](https://github.com/ThinkFlowLab/FlyBot/issues/733)): `load` for every child, and `openForMs`/`stalled` for a child with an open turn, so a coordinator can send new work to an idle child instead of learning the shape from the implementation. diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index 69f2af53b..a9c06a64a 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -89,7 +89,7 @@ A coordinator can see which of its children are busy right now, so it sends a ne A count alone cannot tell a long turn from a stuck one: `load: 1` read the same for a turn three seconds in and for one that had been open 47 minutes without consuming its queue. - The tracker keeps each open turn's start time, so `openFor(id)` answers the age of the **oldest** open turn and `0` when the Bot is idle. An end closes **its own** turn: `index.js` hands the feed's `turnId` to `end(id, turnId)`, because dropping whichever turn was oldest left the *newer* turn's age behind when a later turn ended first (#696). No match — an id never seen, or none at all — still drops exactly one turn, the oldest, so the count cannot drift. -- Past `STALLED_AFTER_MS` (12 minutes) the coordinator's line says the age instead of just the count — `Work: working (47 min, no turn ended)`, or `Work: busy (2 running, oldest open 47 min)`. `list_children` gains `openForMs` (and `stalled: true` past the threshold) only for a child with a turn open, so an idle child keeps exactly its old shape and every existing reader of `load` keeps a number. +- Past `STALLED_AFTER_MS` (12 minutes) the coordinator's line says the age instead of just the count — `Work: working (47 min, no turn ended)`, or `Work: busy (2 running, oldest open 47 min)`. `list_children` gains `openForMs` (and `stalled: true` past the threshold) only for a child with a turn open, so an idle child keeps exactly its old shape and every existing reader of `load` keeps a number. The tool's description names those fields too (#733): a model reads the description, not this code, and a coordinator that never learns `stalled` exists cannot route around it. - **A hint for routing, never a gate.** The tree plugin cannot see a Bot's inbox, so a legitimately long turn ages too; nothing here refuses, ends or reassigns work. `loadLine`'s third argument is optional, so a caller that only has a count keeps the old line. ### Model-based clustering (#450 item 2) diff --git a/plugins/tree/src/tools.js b/plugins/tree/src/tools.js index 17f5b79d1..f4250e9a8 100644 --- a/plugins/tree/src/tools.js +++ b/plugins/tree/src/tools.js @@ -75,7 +75,7 @@ export function registerTreeTools(fb, tree) { return [ fb.registerTool({ name: 'list_children', - description: 'List your direct child bots (id, name, role). These and your parent are the only bots you can message.', + description: 'List your direct child bots (id, name, role, and load: how many turns each is running now). A child with an open turn also carries openForMs, the age of its oldest open turn, and stalled: true once that turn has been open past the stall threshold. These and your parent are the only bots you can message; send new work to an idle child first.', parameters: LIST_CHILDREN_SCHEMA, visibleTo: inTree, async execute(args, { botId }) { diff --git a/plugins/tree/test/tree.test.js b/plugins/tree/test/tree.test.js index 3e10e4dc2..d92a6e8d9 100644 --- a/plugins/tree/test/tree.test.js +++ b/plugins/tree/test/tree.test.js @@ -1486,6 +1486,31 @@ test('#624: a turn that never ends shows how long it has been open, and load sta assert.equal(item.stalled, undefined) }) +// #733: a model routes on the description, not on the code, so the description must name the +// fields the tool answers — and the answer must still carry them. +test('#733: list_children describes the load fields it returns', async () => { + let clock = Date.UTC(2026, 9, 22, 6) + const m = await setup({ now: () => clock }) + const { description } = m.tools.get('list_children') + for (const field of ['load', 'openForMs', 'stalled']) assert.match(description, new RegExp(`\\b${field}\\b`)) + // `stalled` is not a synonym for busy: the description says what makes it true. + assert.match(description, /stalled: true once[^.]*stall threshold/) + const work = await make(m, m.root, 'Work') + const writer = await make(m, m.root, 'Writer') + m.feed('turn.start', { botId: work, place: 'chat', sessionId: 's', turnId: 't1', turn: 1, time: 1 }) + clock += 47 * 60_000 + const children = (await m.call(m.root, 'list_children')).children + const busy = children.find(c => c.id === work) + assert.equal(busy.load, 1) + assert.equal(busy.openForMs, 47 * 60_000) + assert.equal(busy.stalled, true) + // An idle child keeps the old shape, so the description promises the fields only with a turn open. + const idle = children.find(c => c.id === writer) + assert.equal(idle.load, 0) + assert.equal('openForMs' in idle, false) + assert.equal('stalled' in idle, false) +}) + test('#696: two turns open, the later one ends, and the age left is the older turn\'s', async () => { let clock = Date.UTC(2026, 9, 22, 7) const m = await setup({ now: () => clock })