Skip to content

Show how long a child's turn has been open in the tree prompt (#624) - #692

Merged
Birfy merged 1 commit into
mainfrom
feat/624-open-turn-age
Oct 22, 2026
Merged

Birfy merged 1 commit into
mainfrom
feat/624-open-turn-age

Conversation

@Birfy

@Birfy Birfy commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

Reproduce

load was a bare count, so a stuck turn and a busy one read the same. With the tree plugin loaded and a child on load: 1:

# feed one turn.start, then read the child 47 minutes later
list_children  -> { id, name, role, load: 1 }            # no age at all
prompt         -> "Child load right now — Dev: working. Send a new part to a child that is idle or working before one that is busy."

Nothing in either answer distinguishes a turn three seconds in from one that has been open 47 minutes and never ended — which is exactly the state #624 is about.

What changed

  • plugins/tree/src/load.js — the tracker keeps each open turn's start time instead of a counter, so openFor(id, at) answers the age of the oldest open turn (0 when idle) and end(id) drops the oldest one, keeping the count right whatever order the ends arrive in. New STALLED_AFTER_MS = 12 min and loadLine(children, countOf, openForOf?): past the threshold a child reads working (47 min, no turn ended) or busy (2 running, oldest open 47 min). The third argument is optional, so a caller with only a count keeps the old line.
  • plugins/tree/src/tree.js — the injected clock reaches the tracker, and openForOf is exposed.
  • plugins/tree/src/tools.js — list_children adds openForMs only for a child with a turn open, plus stalled: true past the threshold. An idle child keeps exactly its old shape, and load stays a number for every existing reader.
  • plugins/tree/test/load.test.js, plugins/tree/test/tree.test.js — three cases: the age is the oldest start and rolls to the newer turn once one ends; the line names the age only past the threshold and never calls an idle child stalled; a turn that never ends shows its age in both list_children and the coordinator's stored prompt, and clears at turn.end.
  • docs/subsystems/tree.md — a "How long a turn has been open" subsection, the Key files line and the coverage line.

Boundary, stated plainly: this is a routing hint, never a gate. The tree plugin cannot see a Bot's inbox, so a legitimately long turn ages too, and nothing here refuses, ends or reassigns work. 12 minutes is a display threshold, not a timeout.

What I ran

cd plugins/tree && node --import ./test/resolve.mjs --test --test-concurrency=2 "test/*.test.js"
  -> # tests 100 / # pass 100 / # fail 0
cd plugins/tree && node --import ./test/resolve.mjs --test test/load.test.js test/tree.test.js
  -> 89/89
cd core && node --import ./test/harness-resolve.mjs --test --test-concurrency=4 "test/*.test.js"
  -> 1068 tests, 1041 pass, 27 fail — every one of the 27 is
     `ERR_MODULE_NOT_FOUND: Cannot find package 'esbuild'` (or 'jsdom') from a UI/client test
     file, i.e. this machine has no node_modules; none of them imports plugins/tree
node scripts/lint-plugins.mjs        -> 0 errors (29 pre-existing warnings elsewhere)
node core/scripts/check-arch-docs.mjs -> arch-docs: OK (0 changed file(s))
node .github/scripts/changelog.mjs check -> 12 fragment(s) in changelog.d/ are well formed

Red without the source change

With the age recording withdrawn (start() no longer stores now(), openFor() returns 0, loadLine ignores the third argument) while the call sites and the tests stay as they are:

not ok 3 - #624: openFor is the age of the oldest open turn, and end clears it
  the oldest turn, not the newer one: 0 !== 2820000
not ok 4 - #624: loadLine names the age only past the threshold, and never calls a child idle
  The input did not match /Dev: working \(47 min, no turn ended\)/. Input: 'Child load right now — Dev: working. …'
not ok 80 - #624: a turn that never ends shows how long it has been open, and load stays a number
  + actual - expected: + undefined - 180000
# tests 89 / # pass 86 / # fail 3

Restoring load.js makes them pass again (89/89, then 100/100 for the whole plugin).

Not run

  • npm run verify end to end: this machine hits spawnSync /bin/sh EAGAIN / esbuild … EAGAIN at the default concurrency, so the steps above were run one by one.
  • The 27 core UI/client tests (esbuild / jsdom not installed here). CI runs them on a machine with dependencies; that is what tests-ok covers.

Risk

T2 — plugins/tree/** plus a docs file; no core path, no .github/**, no security surface. The GitHub workflow's own risk-gate recomputes it on this tip.

Fixes #624

Tip SHA: 171bedf

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.
@Birfy Birfy added the agent-pr Pull request opened by an agent label Oct 22, 2026
@Birfy Birfy self-assigned this Oct 22, 2026
@Birfy

Birfy commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at 171bedf
Written-by: Reviewer

The change is small and honest about what it is: a per-Bot list of open-turn start times, openFor() answering the age of the oldest one, and the coordinator line and list_children showing it only past STALLED_AFTER_MS. load is still a number for every existing reader, loadLine's third argument is optional so the old call sites are untouched, snapshot() is unchanged, nothing persists, and the doc says in as many words that this is a routing hint and not a gate — I checked the code matches that: no path refuses, ends or reassigns work from an age.

Ran:

  • node --import ./test/resolve.mjs --test "test/*.test.js" in plugins/tree/ at this tip → 100 tests, 100 pass
  • the same command on main b5de55d → 97 tests, 97 pass: the tip adds exactly the three A Bot session stops reading its queue while still reporting load: 1 (Dev: 11 unread, zero progress) #624 cases
  • red without the source change, three ways: openFor forced to 0 → 2 fail (the openFor case and "a turn that never ends …"); loadLine ignoring the age argument → 2 fail (the threshold case and the same end-to-end one); start() recording 0 instead of now() → 3 fail (those plus the Tree: show a coordinator its children's runtime load #507 prompt/list_children case, which then reads an age of decades)
  • node judge.mjs over this PR's file list → risk-tier: T2; basis: plugins/tree/src/load.js=T2, … docs/subsystems/tree.md=T0
  • read plugins/tree/index.js:31-37 and core/src/host/flybot-feed.js:203-256 to see what the tracker is fed, and plugins/tree/src/tools.js:86-101 for the list_children shape

Not run:

  • the core suite: no file under core/ is touched, and the tip's own tests-ok job is green
  • npm run lint-plugins and arch-docs locally: no git checkout on this machine, so the branch-commit walk cannot run; both are covered by the tip's green tests-ok / package jobs
  • a live two-Bot walkthrough: one home here, so the age was exercised through the unit and host tests, not by watching a real 47-minute turn

Non-blocking:

  1. The feed already carries a turn id (flybot-feed.js:215, :256: turnId), but index.js:36-37 hands the tracker only e.botId, so end() has to drop the oldest start blind. With two turns open, the newer one ending resets the reported age of the older one, which is the direction that hides a stall. The docs and the test pin that as the intended trade ("only the count has to stay right"), so it is not a defect — but pairing start/end by turnId, with the current shift as the fallback for an unmatched end, would make the hint exact for no extra cost.
  2. list_children's description still reads "(id, name, role)". The fields are now openForMs and stalled, and the model reading the tool has nothing telling it what they mean, while docs/subsystems/tree.md explains them; one clause there would do.
  3. An end that never arrives (a Bot removed mid-turn) leaves its entry forever. Bounded by the number of Bot ids and it can never grow past the turns actually started, so it is only worth clearing in onBotRemoved if someone touches that path anyway.

T2: awaiting a maintainer approve at this tip; I am not enqueueing it.

@Birfy Birfy added the awaiting-approval PR is green and waits for the maintainer's approval (T2/T3) or a review PASS at its tip label Oct 22, 2026
@Birfy
Birfy added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit d9f9db0 Oct 22, 2026
9 checks passed
@Birfy
Birfy deleted the feat/624-open-turn-age branch October 22, 2026 07:03
@Birfy Birfy removed the awaiting-approval PR is green and waits for the maintainer's approval (T2/T3) or a review PASS at its tip label Oct 22, 2026
@Birfy Birfy removed the agent-pr Pull request opened by an agent label Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A Bot session stops reading its queue while still reporting load: 1 (Dev: 11 unread, zero progress)

1 participant