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/2800-tree-job-timeout-config.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The tree plugin's `jobTimeoutMs` config key now bounds a queued reorganization job: the value was read by the organizer but never passed to it from the plugin config, so every job waited on the ten-minute default however the key was set. A value that is not a finite number above 0 still keeps that default. See [#2758](https://github.com/ThinkFlowLab/FlyBot/issues/2758).
2 changes: 1 addition & 1 deletion docs/subsystems/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ The tree organizes itself by role instead of leaving Bots outside it or a parent
- **Placement** of a Bot outside the tree: its namesake, else the tree Bot that leads a group it belongs to (most specific key first, then the shallower lead; an area only through a lead made for it, e.g. "Work Lead"), else the root. At the first of those with room it is placed. A full level is **regrouped**: the most specific group of two or more children (the newcomer counted) moves under a suitable existing member, or under a new lead. Inside a full lead of its own group with no finer group, the member closest to its role takes it (all members share the group). There is **no "least-loaded child" fallback**: a Bot that fits no group while every level it matches is full stays outside, a warning names it and the sidebar tags it "outside the tree"; the next attempt (any `bot.created`, startup) tries again.
- **Leads.** A suitable existing lead is a member already marked as that group's lead, already coordinating that group (it has children and the key, project and domain only), or named for it ("Life", "FlyBot Lead") or as a lead ("Kitchen Manager", 负责人). It gets `lead { key, label }`. Only when none fits a new lead is created with a clear role (`<Label> Lead`, role "Lead of the <Label> group: coordinates …"); its members move under it before it joins the level. A lead's own group key, and broad areas below any lead, are ignored inside it, so there is no second "Dev Lead" under "Dev Lead".
- **Repair**: a parent over `maxChildren` (after a deletion re-attached a deleted Bot's children) is regrouped the same way, at once when the lead exists, else in the queue.
- **One queue, bounded per job (#2751):** placement, repair and tidy run one at a time on the reorganization queue. A job whose host call never answers is abandoned after 10 minutes (`flybot-tree: reorganization job <id> was abandoned after …`): the next job still runs and `settled()` still resolves, and an `exclusive()` job's promise rejects with `abandoned: true` and its job id instead of hanging. The abandoned work cannot be cancelled, so it may still write afterwards.
- **One queue, bounded per job (#2751):** placement, repair and tidy run one at a time on the reorganization queue. A job whose host call never answers is abandoned after 10 minutes (`flybot-tree: reorganization job <id> was abandoned after …`): the next job still runs and `settled()` still resolves, and an `exclusive()` job's promise rejects with `abandoned: true` and its job id instead of hanging. The deadline is the plugin's `jobTimeoutMs` config key — a finite number of milliseconds above 0; `0`, a negative number, a numeric string and `NaN` all keep the ten-minute default. The abandoned work cannot be cancelled, so it may still write afterwards.
- **Daily tidy** (checked hourly, runs once a day; the first check after install only records the time; `dailyTidy: false` turns it off): (1) leaf Bots with no activity for `tidy.idleDays` (14) get `idle` (any turn or tool call clears it) and are **archived** after `tidy.archiveIdleDays` (30) more days; Bots with children, granted Bots and the root are never idle; (2) duplicates: two Bots with essentially the same role (same name once generic words are dropped and no conflicting description, or near-identical descriptions in one domain), both at least `tidy.mergeGraceDays` (1) in the tree, are **merged**: the newer one's children move to the kept Bot, which lists it in `mergedFrom` and is told in its prompt; the old one is archived; (3) project and domain groups of three or more (or two with an existing lead) on every level, never a group of a whole level without a lead.
- **Archive, never delete**: an archived Bot leaves the tree (`archived { at, reason, parent, treeRole, into? }`, tree fields and block removed) but keeps its chat, memory and grants. It is **hidden from the default Bot list** (`bot.hidden` / structure `include`; Search still finds it) (#452), and the sidebar's `flybot-tree-archived` section is the way back to its chat (#810): collapsed by default, header `Archived (N)`, drawn only while N > 0, one row per archived Bot with the same tag `statusOf()` gives (`archived`, `archived · merged into <name>`) opening the chat through `actions.openSession`. `include()` still returns false, so the list and `botTree()` never see it. Deleting it stays the owner's own click. That section is registered at `order: 88` and the reorganization notice below at `order: 89`; a slot renders ascending, so the archived Bots sit **above** the notice. On a merge (#450 item 1), the archived Bot's own memory is carried into the kept Bot through `flybot.memory` (at most 20 entries, tagged `source: merged`), so a merge loses nothing; without the host's memory seam the merge still stands.
- **Retire from the core (#638):** the Main Bot's `archive_bot` writes `fields['flybot-tree'].retired = true` (never `archived`, which is the tidy's own record) and sets `bot.hidden`, so the Bot leaves the tree and the default list the way an archived one does while its history stays readable; adoption skips a retired record and `forestOf` no longer counts it, and since #2430 the model itself does too: `snapshot()` and `isMember()` leave the record out (so it occupies no `maxChildren` slot, no `list_children` row and no prompt trace), and the field change that retires it hands its children to its own parent — or to the current Main for a retired root — with every moved Bot's prompt refreshed, instead of stranding them under a Bot the host refuses to deliver to. The tree panel (`treeOf`) skips a retired Bot the same way (#1793), and the `flybot-tree-archived` section lists it with the `retired` tag so its chat stays reachable; a record that also carries `archived` keeps the archived tag.
Expand Down
2 changes: 1 addition & 1 deletion plugins/tree/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function apply(ctx, config = {}) {
// Cordis gives ctx.logger as a factory; a host may give a ready logger object instead.
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, burstGrouping: config.burstGrouping, now: typeof config.now === 'function' ? config.now : undefined })
const tree = createTree(fb, { limits: resolveLimits(config), rootName: config.rootName, rootBotId: config.rootBotId, adoptExisting: config.adoptExisting !== false, warn, classify: config.classify, tidy: config.tidy, burstGrouping: config.burstGrouping, jobTimeoutMs: config.jobTimeoutMs, now: typeof config.now === 'function' ? config.now : undefined })
// A child's turn boundary rewrites its parent's prompt, so a rename or role edit reaches the
// coordinator's child list. The block carries no load (#895: list_children reads that live), so
// writeBlock finds it unchanged and nothing churns.
Expand Down
4 changes: 2 additions & 2 deletions plugins/tree/src/tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const ROOT_ROLE = 'root'
const LEGACY_ROOT_ROLE = 'Top-level coordinator for everything.'
const ROOT_LABEL = 'Main bot and root of the tree'

export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExisting = true, warn = () => {}, classify, tidy, burstGrouping, now = Date.now } = {}) {
export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExisting = true, warn = () => {}, classify, tidy, burstGrouping, jobTimeoutMs, now = Date.now } = {}) {
const listeners = new Set()
const load = createLoadTracker({ now })

Expand Down Expand Up @@ -84,7 +84,7 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis
// After a placement or repair any trace or child list may have changed; writeBlock skips unchanged prompts.
const refreshAll = () => { const nodes = snapshot(); for (const id of Object.keys(nodes)) refreshPrompt(id, nodes) }
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 })
const organizer = createOrganizer({ fb, limits, snapshot, childrenOf, depthOf, warn, refreshAll: () => refreshAll(), writeBlock: (id, block) => writeBlock(id, block), findRootId, now, classify, tidy, jobTimeoutMs })
// 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, burst: burstGrouping !== false })

Expand Down
75 changes: 75 additions & 0 deletions plugins/tree/test/job-timeout-config.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// #2758: `jobTimeoutMs` is a plugin config key — the deadline one reorganization job may wait on
// the host before the queue gives up on it (#2751). The bound itself lives in src/organize.js; what
// this file pins is the wire from `apply(ctx, { jobTimeoutMs })` down to it, because the value is
// silent when it is dropped: the only difference is 30ms against the ten-minute default.
//
// `create_team` is the queueing path a caller actually uses: it runs its work through the
// organizer's `exclusive()` queue, and the lead it creates is the host call that hangs here. The
// deadline the queue reports is the configured value, so the warning text tells the two apart. A
// dropped value would leave the suite waiting ten minutes, so every timer below is capped.
import assert from 'node:assert/strict'
import { test } from 'node:test'
import * as plugin from '../index.js'
import { createMockFlybot } from './mock-flybot.js'

const DEFAULT_JOB_TIMEOUT_MS = 10 * 60 * 1000

// A host that never answers, a caller that queues a team, and the warnings the plugin logged.
// Timers are capped at 250ms for the duration of the call: the ten-minute default then fires
// inside the test instead of parking the suite, and its warning still names 600000ms.
async function hangTeamCreate(config) {
const m = createMockFlybot()
m.addBot({ name: 'Chief', isMain: true, systemPrompt: 'Chief own prompt' })
const warnings = []
// The adapter keeps the API object `flybot.scope('flybot-tree')` hands it at apply time, so the
// hanging host call is injected into that scope before the plugin is built.
let hostCalls = 0
const scope = m.flybot.scope.bind(m.flybot)
m.flybot.scope = name => {
const api = scope(name)
if (name === 'flybot-tree') api.bot = { ...api.bot, create: () => { hostCalls++; return new Promise(() => {}) } }
return api
}
const { tree } = plugin.apply(
{ flybot: m.flybot, logger: { warn: (...args) => warnings.push(args.map(String).join(' ')) } },
{ adoptExisting: false, ...config },
)
const root = await tree.ensureRoot()

const realSetTimeout = globalThis.setTimeout
globalThis.setTimeout = (fn, ms, ...rest) => realSetTimeout(fn, Math.min(ms, 250), ...rest)
let result
try {
result = await m.call(root, 'create_team', { team: 'FlyBot', members: ['Dev: writes the FlyBot code', 'Test: runs the FlyBot tests'] })
} finally {
globalThis.setTimeout = realSetTimeout
}
return { hostCalls, warnings, result, tree, m }
}

test('#2758 jobTimeoutMs from the plugin config bounds a queued create_team job', { timeout: 20_000 }, async () => {
const { hostCalls, warnings, result } = await hangTeamCreate({ jobTimeoutMs: 30 })

assert.equal(hostCalls, 1, 'create_team reached the host to create "FlyBot Lead"')

const warned = warnings.join('\n')
assert.match(
warned,
/abandoned after 30ms/,
`jobTimeoutMs in the plugin config was ignored; warnings: ${warned || '(none)'}`,
)
assert.match(String(result?.error ?? ''), /abandoned after 30ms/, 'the give-up reaches the tool result')
})

test('#2758 jobTimeoutMs only accepts a finite positive number; anything else falls back to ten minutes', { timeout: 30_000 }, async () => {
for (const ignored of [0, -5, '30', Number.NaN, Infinity, null]) {
const { hostCalls, warnings } = await hangTeamCreate({ jobTimeoutMs: ignored })
assert.equal(hostCalls, 1, `the team path still ran with jobTimeoutMs=${String(ignored)}`)
const warned = warnings.join('\n')
assert.match(
warned,
new RegExp(`abandoned after ${DEFAULT_JOB_TIMEOUT_MS}ms`),
`jobTimeoutMs=${String(ignored)} must fall back to the default deadline; warnings: ${warned || '(none)'}`,
)
}
})
Loading