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/733-list-children-fields.changed.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/subsystems/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion plugins/tree/src/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
25 changes: 25 additions & 0 deletions plugins/tree/test/tree.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Loading