Skip to content

Batch board refresh reads and run live pollers only while watched - #2

Open
ymichael wants to merge 1 commit into
aaronphifer:mainfrom
ymichael:perf/batched-board-refresh
Open

ymichael wants to merge 1 commit into
aaronphifer:mainfrom
ymichael:perf/batched-board-refresh

Conversation

@ymichael

@ymichael ymichael commented Nov 7, 2026

Copy link
Copy Markdown

Human comments

What was wrong

Each board refresh did its BB reads one at a time. The tree walk made one threads.list({ parentThreadId }) call per thread, plus an extra probe at the depth limit. Each thread's timeline was then read in a serial loop, and a second serial loop made up to four more reads per thread (model, output, events, queued messages). On a 24-thread tree that came to 69 requests with at most 2 in flight, repeated on every invalidation. The board was also marked partial whenever it reached 24 threads, even when nothing was left out.

The watcher looked up unseen threads with one threads.get each and only matched direct children of known threads, so a grandchild created between refreshes was missed. The Redteam and Fleet pollers ran on 90-second leases, so they kept running after the board closed. The global page also had a 20-second timer in the browser.

Two of the APIs the plugin uses have since been retired in BB. threadPanelAction is gone from SDK typings in favour of experimental_tab. BB 0.49 also removed the timeline goal field, so the board's goal line is blank on 0.49.

What changed

  • Tree read (lib/collect.ts): one bb.sdk.threads.experimental_listDescendants call plus one batched threads.list({ threadIds }) row read replace the per-node walk. A new placeTree applies the same depth and count caps and keeps the same sibling order. partial is now true only when a reachable thread was actually left out. The 1-second tree cache is kept.
  • Per-thread reads: each thread's timeline, model, output, events and queue reads run together, six threads at a time, using the plugin's existing mapWithConcurrency.
  • Watcher (lib/watch.ts, server.ts): threads the board hasn't seen are matched to their root with experimental_listAncestors. Lookups made in the same tick are batched into one call of up to 200 ids. This covers grandchildren of threads the board hasn't seen yet, not just direct children.
  • Live updates (hooks/useBoard.ts, hooks/useGlobalDashboard.ts, contract/rpc.ts): the board and global page read their snapshots with experimental_useRpcQuery. The contract tags them board, board:<rootThreadId> and global-dashboard, and the watcher calls bb.rpc.experimental_invalidate. The host handles refetching, race guards and reconnects. The agent-board-changed realtime signal is still published for the Timeline dialog and the global page's health line. Dismissing attention items now invalidates the global tag on the server.
  • Pollers (lib/redteam-poll.ts, lib/fleet-poll.ts, server.ts): the watcher, Redteam and Fleet pollers and the global page's 20-second discovery refresh now start through bb.rpc.experimental_whileWatched. They stop when the last client leaves or hides the tab, which replaces the 90-second leases and the browser timer.
  • Tab (app.tsx): app.slots.experimental_tab({ context: { kind: "thread" }, availability: "action", ... }) replaces threadPanelAction. The id agent-board is unchanged, so saved tabs keep working.
  • Goal (lib/bb-source.ts): the goal is read from the timeline's provider-codex/goal extension state, falling back to the old goal field on older BB. The contract's goal status now also accepts blocked and usageLimited.
  • Compatibility: engines.bb is >=0.47 and engines.bbPluginSdk is >=0.6.73, the first versions with all of these APIs. Development types are now SDK 0.6.132; under that SDK, TimelineResponse is narrowed to the full-page result. README, PLUGIN_OVERVIEW, CONTRIBUTING and docs/architecture.md are updated to match.

After a release, the Community marketplace description ("Requires BB 0.40+; BB 0.46.0 recommended") will need updating to BB 0.47+. To reach existing installs under the catalog's ^0.7.0 range, the release should be a 0.7.x tag (for example v0.7.1). Hosts older than BB 0.47 will refuse the update as incompatible and stay on 0.7.0.

scripts/verify-package.mjs still expects artifacts built with BB 0.46.0. It fails when built with any newer BB, with or without this change, so I left that pin for your next release.

How you verified

  • npm run typecheck is clean and npm test passes (480 tests). New and updated tests cover:
    • tree placement: exactly-at-cap trees aren't partial, sibling order is kept, threads below an archived parent are left out, the depth cap applies
    • the goal from extension state and from the old field
    • ancestor lookups batched into one call, including a grandchild below an unseen parent
    • board and global watches ending when their watched read ends
    • tag invalidation and fresh refresh in the app
    • the 20-second discovery refresh starting and stopping with the watched read
  • bb plugin build succeeds with no retired-API warnings.
  • Measured on a local BB 0.49 dev server with a 24-thread tree (one root, 4 children, 12 grandchildren, 7 great-grandchildren), 20 refreshes each with caches cleared, main vs this branch:
main this branch
Requests per refresh 69 54
Tree-walk list calls 17 1 descendants + 1 batched rows
Max requests in flight 2 13
Refresh time through the HTTP SDK, median / p90 129 / 156 ms 24 / 26 ms
board_snapshot RPC with fresh: true, median / p90 60 / 203 ms 27 / 50 ms
partial for the exactly-24-thread tree true false

The cards and root summary were identical between the two versions; only partial changed. The remaining 48 requests are per-thread timeline and model reads, which BB has no batched API for.

  • Checked by hand in the BB app:
    • an already-open Agent Board tab restored after the update
    • the "showing a truncated tree" note no longer appears for the full tree
    • renaming a child, and adding a child plus a grandchild from the CLI, updated the open board without a reload; archiving them removed them again
    • the global page kept refreshing while open

AGENT GENERATED

A board refresh walked the thread tree with one threads.list call per
node, read every thread's timeline one at a time, then made up to four
more reads per thread in a second serial loop. On a 24-thread tree that
was 69 requests with at most 2 in flight.

- Read the tree with one experimental_listDescendants call plus one
  batched threads.list({ threadIds }) row read, and run each thread's
  timeline, model, output, event and queue reads in parallel (six
  threads at a time). partial is now only set when a thread was left out.
- Match threads the board has not seen (for example a grandchild created
  between refreshes) to their root with one batched
  experimental_listAncestors read per tick instead of a threads.get per
  thread that only matched direct children.
- Read board and global snapshots with experimental_useRpcQuery and
  invalidate their tags from the watcher; the host handles refetch,
  races and reconnects.
- Drive the watcher, Redteam and Fleet pollers and the global page's
  20-second discovery refresh from bb.rpc.experimental_whileWatched
  instead of 90-second leases, so they stop when the view closes.
- Register the board with app.slots.experimental_tab; threadPanelAction
  is retired.
- Read the Codex goal from the timeline's extension state (BB 0.49
  removed the timeline goal field), falling back to the old field.
- Require BB 0.47 / plugin SDK 0.6.73 for these APIs; develop against
  SDK 0.6.132.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant