From 171bedfef6743da6156793e64aef256ac4e74a08 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Thu, 22 Oct 2026 14:42:24 +0800 Subject: [PATCH] Show how long a child's turn has been open in the tree prompt (#624) A load count could not 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 ending. The tracker now keeps each open turn's start, `openFor()` answers the age of the oldest one, and past 12 minutes the coordinator's line names it instead of only the count. - load.js: keep per-turn start times; `openFor(id, at)`; `STALLED_AFTER_MS = 12 min`; `loadLine` takes an optional third argument and renders `working (47 min, no turn ended)` / `busy (2 running, oldest open 47 min)` past the threshold. - tree.js: pass the injected clock through and expose `openForOf`. - tools.js: `list_children` adds `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 `load` stays a number for every existing reader. - A routing hint, never a gate: the plugin cannot see a Bot's inbox, so a legitimately long turn ages too. Nothing here refuses, ends or reassigns work. --- docs/subsystems/tree.md | 12 ++++++-- plugins/tree/src/load.js | 52 +++++++++++++++++++++++++++------- plugins/tree/src/tools.js | 19 ++++++++++++- plugins/tree/src/tree.js | 7 +++-- plugins/tree/test/load.test.js | 31 +++++++++++++++++++- plugins/tree/test/tree.test.js | 27 ++++++++++++++++++ 6 files changed, 131 insertions(+), 17 deletions(-) diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index 9bba32ad7..549b2efb2 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -26,7 +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; reads a stored `modelDomain`). | -| `plugins/tree/src/load.js` | Runtime load (#507): pure `createLoadTracker`/`loadWord`/`loadLine` for the open turns each Bot runs, fed from the activity feed. | +| `plugins/tree/src/load.js` | Runtime load (#507) and how long a turn has been open (#624): pure `createLoadTracker`/`loadWord`/`loadLine` for the open turns each Bot runs, fed from the activity feed. | | `plugins/tree/src/classify-model.js` | Model-based clustering (#450 item 2): opt-in classification through the sanctioned plugin model call (#518). | ## Entry points @@ -84,6 +84,14 @@ A coordinator can see which of its children are busy right now, so it sends a ne - `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. +#### How long a turn has been open (#624) + +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; `end(id)` drops the oldest one (end events may arrive in any order, only the count has to stay right). +- 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. +- **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) The rules above place a Bot by the words in its name, role label and description. When those words say nothing (a job title the small vocabulary does not hold, a brief in another language), the Bot fits no group and stays outside. `src/classify-model.js` asks the sanctioned plugin model call (#518) for one category and remembers it. @@ -105,7 +113,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. 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`. +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`. The age of an open turn (#624) is covered in both: `openFor` reads the oldest start and falls back to the newer turn once one ends, the line names the age only past the threshold and never calls an idle child stalled, and `list_children` adds `openForMs`/`stalled` for an open turn while an idle child keeps the old shape. ## Common mistakes diff --git a/plugins/tree/src/load.js b/plugins/tree/src/load.js index 1eed1dc55..911eff113 100644 --- a/plugins/tree/src/load.js +++ b/plugins/tree/src/load.js @@ -2,25 +2,47 @@ // 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 */ +// +// How long a turn has been open (#624): a count alone cannot tell a long turn from a stuck one — +// `load: 1` read identically for a 47-minute-old turn that never consumed its queue and for a +// turn three seconds in. Each open turn's start is kept, so `openFor()` answers the age of the +// oldest one and a reader can draw its own line. It is a hint for routing, never a gate: this +// plugin cannot see a Bot's inbox, so a legitimately long turn also ages. +export const STALLED_AFTER_MS = 12 * 60_000 + +const minutes = ms => Math.max(1, Math.round(ms / 60_000)) + +export function createLoadTracker({ now = Date.now } = {}) { + /** @type {Map} start times of the open turns per Bot id, oldest first */ const open = new Map() + const timesOf = id => open.get(id) ?? [] return { start(id) { if (typeof id !== 'string' || id === '') return - open.set(id, (open.get(id) ?? 0) + 1) + const times = timesOf(id) + times.push(now()) + open.set(id, times) }, 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) + const times = timesOf(id) + // Oldest first, so an end drops the oldest still-open turn and what remains is the age of the + // oldest one left. The end events may arrive in any order; only the count has to stay right. + times.shift() + if (times.length === 0) open.delete(id) + else open.set(id, times) }, of(id) { - return open.get(id) ?? 0 + return timesOf(id).length + }, + /** Age in ms of the oldest open turn, 0 when the Bot is idle. */ + openFor(id, at = now()) { + const times = timesOf(id) + if (times.length === 0) return 0 + return Math.max(0, at - times[0]) }, snapshot() { - return Object.fromEntries(open) + return Object.fromEntries([...open].map(([id, times]) => [id, times.length])) }, } } @@ -36,9 +58,19 @@ export function loadWord(count) { /** * 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). + * A child whose oldest open turn is past `STALLED_AFTER_MS` says how long it has been open, so a + * coordinator can tell "busy" from "has not ended a turn in 47 minutes" (#624). */ -export function loadLine(children, countOf) { +export function loadLine(children, countOf, openForOf = () => 0) { if (!Array.isArray(children) || children.length === 0) return undefined - const parts = children.map(child => `${child.name}: ${loadWord(countOf(child.id))}`) + const parts = children.map(child => { + const count = Number(countOf(child.id)) || 0 + const age = Number(openForOf(child.id)) || 0 + if (count <= 0 || age < STALLED_AFTER_MS) return `${child.name}: ${loadWord(count)}` + const waited = count === 1 + ? `working (${minutes(age)} min, no turn ended)` + : `busy (${count} running, oldest open ${minutes(age)} min)` + return `${child.name}: ${waited}` + }) 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 5eab22c05..17f5b79d1 100644 --- a/plugins/tree/src/tools.js +++ b/plugins/tree/src/tools.js @@ -1,5 +1,6 @@ import { MAX_NAME_LENGTH } from './limits.js' // = ds-bot roster's 40-character name truncation import { MAX_TEAM_LENGTH } from './allocate.js' +import { STALLED_AFTER_MS } from './load.js' // Model tools. The caller comes from the tool context ({ botId }), never from arguments. A Bot // creates only in its own downstream: `parent` must be the caller or one of its descendants (#447). // Visible to every tree member (#16 Option B: not Main/root-only). maxChildren and depth are @@ -82,7 +83,23 @@ 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, load: tree.loadOf(id) })), depth: tree.depthOf(nodes, botId), limits: tree.limits } + return { + children: tree.childrenOf(nodes, botId).map(({ id, name, role }) => { + // Idle keeps the old shape: no age, no flag, so a reader that only wants the count sees + // exactly what it saw before (#624). + const openForMs = tree.openForOf(id) + return { + id, + name, + role, + load: tree.loadOf(id), + ...(openForMs > 0 ? { openForMs } : {}), + ...(openForMs >= STALLED_AFTER_MS ? { stalled: true } : {}), + } + }), + 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 a3fa8e7e0..28c51dc61 100644 --- a/plugins/tree/src/tree.js +++ b/plugins/tree/src/tree.js @@ -22,7 +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() + const load = createLoadTracker({ now }) function snapshot() { const nodes = {} @@ -71,7 +71,7 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis // 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)) + const line = loadLine(childrenOf(nodes, id), childId => load.of(childId), childId => load.openFor(childId)) return line === undefined ? [] : [line] } // Prompt injection: only the tree's marked block is replaced; other text (e.g. Chief's own) stays. @@ -312,5 +312,6 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis 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, heightOf, traceOf, ensureRoot, adoptChief, findChief, migrateLegacyRoots, promptFor, createChild, allocateChild, createTeam, findBots, moveBot, findBotByName, isMember, isRoot, rootOf, isTreeEdge, onFieldChange, onBotRemoved, subscribe } + const openForOf = id => load.openFor(id) + return { tidy: tidyNow, maybeTidy, undo, dismiss, overview, noteActivity, noteTurnStart, noteTurnEnd, loadOf, openForOf, refreshPrompt, parentOf, settled, repairArchiveNames, limits, forest, adoptUnattached, snapshot, childrenOf, depthOf, heightOf, traceOf, ensureRoot, adoptChief, findChief, migrateLegacyRoots, promptFor, createChild, allocateChild, createTeam, findBots, moveBot, findBotByName, isMember, isRoot, rootOf, isTreeEdge, onFieldChange, onBotRemoved, subscribe } } diff --git a/plugins/tree/test/load.test.js b/plugins/tree/test/load.test.js index abf551c79..f5a833e8a 100644 --- a/plugins/tree/test/load.test.js +++ b/plugins/tree/test/load.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { createLoadTracker, loadLine, loadWord } from '../src/load.js' +import { createLoadTracker, loadLine, loadWord, STALLED_AFTER_MS } from '../src/load.js' test('the load tracker counts open turns and floors at zero', () => { const load = createLoadTracker() @@ -24,3 +24,32 @@ test('loadWord and loadLine read plainly, and loadLine is undefined with no chil 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/) }) + +test('#624: openFor is the age of the oldest open turn, and end clears it', () => { + let clock = 1_000_000 + const load = createLoadTracker({ now: () => clock }) + assert.equal(load.openFor('a'), 0, 'idle has no age') + load.start('a') + clock += 3 * 60_000 + load.start('a') + clock += 44 * 60_000 + assert.equal(load.of('a'), 2) + assert.equal(load.openFor('a'), 47 * 60_000, 'the oldest turn, not the newer one') + assert.equal(load.openFor('a', clock + 60_000), 48 * 60_000, 'a reader may pass its own clock') + load.end('a') + assert.equal(load.of('a'), 1) + assert.equal(load.openFor('a'), 44 * 60_000, 'the newer turn is now the oldest') + load.end('a') + assert.equal(load.openFor('a'), 0) +}) + +test('#624: loadLine names the age only past the threshold, and never calls a child idle', () => { + assert.equal(STALLED_AFTER_MS, 12 * 60_000) + const children = [{ id: 'a', name: 'Dev' }] + assert.match(loadLine(children, () => 1, () => 3 * 60_000), /Dev: working\. Send/) + assert.match(loadLine(children, () => 1, () => 47 * 60_000), /Dev: working \(47 min, no turn ended\)/) + assert.match(loadLine(children, () => 2, () => 47 * 60_000), /Dev: busy \(2 running, oldest open 47 min\)/) + assert.match(loadLine(children, () => 0, () => 47 * 60_000), /Dev: idle/, 'an idle Bot is never stalled') + // The third argument is optional, so a caller with only a count keeps the old line. + assert.match(loadLine(children, () => 1), /Dev: working\. Send/) +}) diff --git a/plugins/tree/test/tree.test.js b/plugins/tree/test/tree.test.js index 65e1209c5..100fa181e 100644 --- a/plugins/tree/test/tree.test.js +++ b/plugins/tree/test/tree.test.js @@ -1443,6 +1443,33 @@ test('#507: a child\'s running turns show as load in the coordinator\'s stored p assert.match(sp(m, m.root), /Child load right now — Work: idle/) }) +test('#624: a turn that never ends shows how long it has been open, and load stays a number', async () => { + let clock = Date.UTC(2026, 9, 22, 6) + const m = await setup({ now: () => clock }) + const work = await make(m, m.root, 'Work') + m.feed('turn.start', { botId: work, place: 'chat', sessionId: 's', turnId: 't1', turn: 1, time: 1 }) + clock += 3 * 60_000 + let item = (await m.call(m.root, 'list_children')).children.find(c => c.id === work) + assert.equal(typeof item.load, 'number', 'load keeps its type for every existing reader') + assert.equal(item.load, 1) + assert.equal(item.openForMs, 3 * 60_000) + assert.equal(item.stalled, undefined, 'three minutes is an ordinary turn') + m.tree.refreshPrompt(m.root) + assert.match(sp(m, m.root), /Work: working\./) + // 44 minutes later the turn is still open and now reads as what the issue is about. + clock += 44 * 60_000 + item = (await m.call(m.root, 'list_children')).children.find(c => c.id === work) + assert.equal(item.openForMs, 47 * 60_000) + assert.equal(item.stalled, true) + m.tree.refreshPrompt(m.root) + assert.match(sp(m, m.root), /Work: working \(47 min, no turn ended\)/) + m.feed('turn.end', { botId: work, place: 'chat', sessionId: 's', turnId: 't1', turn: 1, time: 2 }) + item = (await m.call(m.root, 'list_children')).children.find(c => c.id === work) + assert.equal(item.load, 0) + assert.equal(item.openForMs, undefined, 'idle keeps the old shape: no age, no flag') + assert.equal(item.stalled, undefined) +}) + test('#450: a merge carries the archived Bot\'s own memory into the kept Bot', async () => { const DAY = 24 * 60 * 60 * 1000 let clock = Date.UTC(2026, 9, 19, 12)