You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The built-in Tasks plugin subscribes to the five global thread lifecycle events and, for each one, calls trackedThreads(store, threadId), which loads every task row in the tracker and then issues one listTaskThreads query per task before filtering in JavaScript. Because thread.created/active/idle/failed/deleted fire for every thread in bb — not only for workers a task owns — a tracker with N tasks costs 1 + N queries on every status change of any thread anywhere in the app. I expected a single indexed lookup by thread_id; the index for exactly that query already exists in the schema and has no callers.
Versions and environment
Source checkout of main at 06aeaa994942ae7527dc49d2268c1f801e8542a0
Built-in tasks plugin, bb-plugin-tasks 0.1.2
Node v22.19.0, macOS (Darwin 25.6.0)
Provider-independent: the scan is driven by thread lifecycle events, not by any provider
Steps to reproduce
Add this test at plugins/tasks/zz-repro.test.ts and run pnpm exec turbo run test --filter=bb-plugin-tasks -- zz-repro:
200 tasks, zero attached worker threads, and one thread.idle for a thread the plugin does not track. Nothing in this scenario concerns Tasks at all.
Expected vs actual
stdout | zz-repro.test.ts > zz-repro > one unrelated thread event scans every task
[B] tasks=200 zero tracked threads -> listTasks=1 listTaskThreads=200 total queries=201
FAIL bb-plugin-tasks zz-repro.test.ts > zz-repro > one unrelated thread event scans every task
AssertionError: expected 200 to be +0 // Object.is equality
- Expected
+ Received
- 0
+ 200
(Output trimmed to the assertion; the code frame and stack pointer are omitted.)
Expected: 0 queries — the plugin tracks no threads, and the event names a thread it has never seen.
Actual: 200 listTaskThreads queries plus the full task scan, for one unrelated event.
thread.active and thread.idle fire on every agent turn boundary, so this repeats
continuously while any agent works, and grows linearly with the tracker.
CREATE INDEX IF NOT EXISTS idx_task_threads_thread ON task_threads(thread_id);
and has no callers. Grepping every statement touching task_threads in db/store.ts, the only thread_id predicate is WHERE task_id = ? AND thread_id = ? (line 1590), which is served by the implicit index behind UNIQUE (task_id, thread_id), not by idx_task_threads_thread.
This also conflicts with two rules the repo sets for itself in AGENTS.md: "Use targeted WHERE/JOIN queries instead of loading all rows and filtering in JavaScript" and "Add indexes only when required by the query."
For contrast, the workflows plugin solves the identical problem — find the record owning the thread in a thread.idle handler — with one indexed lookup: getCallByChildThread.
Not an orphan-row guard. Routing through listTasks() cannot be protecting against task_threads rows whose task is gone: the FK is ON DELETE CASCADE and PRAGMA foreign_keys = ON is set (db/schema.ts:87, db/schema.ts:245). tasks has no soft-delete column either, so listTasks() filters nothing out.
Not required by thread-to-task fan-out. One bb thread can legitimately map to several task_threads rows, since the constraint is UNIQUE (task_id, thread_id) rather than unique on thread_id. The loop is therefore correct — but SELECT * FROM task_threads WHERE thread_id = ? returns exactly the same set, so the scan is not what makes it correct.
Not an atomicity requirement: better-sqlite3 is synchronous, and a single statement is strictly more atomic than 1 + N.
Reproduced on main at 06aeaa994942ae7527dc49d2268c1f801e8542a0.
Suggested priority and effort
Medium priority, Low effort. No incorrect behavior and no data loss, and it is unnoticeable on a small tracker — but the cost is paid on every thread transition app-wide and grows with task count, so it degrades silently as a tracker fills up. The fix is a listTaskThreadsByThreadId(threadId) store method over the existing idx_task_threads_thread index, used by transitionTrackedThread. The sweep callers can keep the current traversal or move to a status-filtered query.
Found by source review of the Tasks plugin in a Claude Code session; there is no bb thread to link. The failing test above is the verification, run against the stated commit.
Summary
The built-in Tasks plugin subscribes to the five global thread lifecycle events and, for each one, calls
trackedThreads(store, threadId), which loads every task row in the tracker and then issues onelistTaskThreadsquery per task before filtering in JavaScript. Becausethread.created/active/idle/failed/deletedfire for every thread in bb — not only for workers a task owns — a tracker with N tasks costs 1 + N queries on every status change of any thread anywhere in the app. I expected a single indexed lookup bythread_id; the index for exactly that query already exists in the schema and has no callers.Versions and environment
mainat06aeaa994942ae7527dc49d2268c1f801e8542a0tasksplugin,bb-plugin-tasks0.1.2Steps to reproduce
Add this test at
plugins/tasks/zz-repro.test.tsand runpnpm exec turbo run test --filter=bb-plugin-tasks -- zz-repro:200 tasks, zero attached worker threads, and one
thread.idlefor a thread the plugin does not track. Nothing in this scenario concerns Tasks at all.Expected vs actual
(Output trimmed to the assertion; the code frame and stack pointer are omitted.)
Expected: 0 queries — the plugin tracks no threads, and the event names a thread it has never seen.
Actual: 200
listTaskThreadsqueries plus the full task scan, for one unrelated event.thread.activeandthread.idlefire on every agent turn boundary, so this repeatscontinuously while any agent works, and grows linearly with the tracker.
Evidence
trackedThreadswalks all tasks and filters in JS:bb/plugins/tasks/lifecycle/index.ts
Lines 30 to 40 in 06aeaa9
listTasks()with no filters pages through the whole table at 500 rows per page and materializes all of it:bb/plugins/tasks/db/store.ts
Lines 1049 to 1065 in 06aeaa9
Three callers, all hot:
transitionTrackedThread, bound to the five global thread events —bb/plugins/tasks/lifecycle/index.ts
Lines 162 to 176 in 06aeaa9
hasNonTerminalTrackedThreads, every 60s while idle —bb/plugins/tasks/lifecycle/index.ts
Lines 134 to 138 in 06aeaa9
reconcileTrackedThreads, every 5 minutes —bb/plugins/tasks/lifecycle/index.ts
Lines 121 to 132 in 06aeaa9
The index for the query this code should be making already exists:
bb/plugins/tasks/db/schema.ts
Line 120 in 06aeaa9
and has no callers. Grepping every statement touching
task_threadsindb/store.ts, the onlythread_idpredicate isWHERE task_id = ? AND thread_id = ?(line 1590), which is served by the implicit index behindUNIQUE (task_id, thread_id), not byidx_task_threads_thread.This also conflicts with two rules the repo sets for itself in
AGENTS.md: "Use targetedWHERE/JOINqueries instead of loading all rows and filtering in JavaScript" and "Add indexes only when required by the query."For contrast, the
workflowsplugin solves the identical problem — find the record owning the thread in athread.idlehandler — with one indexed lookup:getCallByChildThread.What you ruled out
trackedThreads,listTasks lifecycle,task_threads index,tasks plugin performanceandthread.idle plugin scan. bb tasks list caps --limit at 500 with no cursor: 366 of 866 rows are unreachable #2700 also toucheslistTasksbut is about CLI--limit/cursor reachability, a different defect.listTasks()cannot be protecting againsttask_threadsrows whose task is gone: the FK isON DELETE CASCADEandPRAGMA foreign_keys = ONis set (db/schema.ts:87,db/schema.ts:245).taskshas no soft-delete column either, solistTasks()filters nothing out.task_threadsrows, since the constraint isUNIQUE (task_id, thread_id)rather than unique onthread_id. The loop is therefore correct — butSELECT * FROM task_threads WHERE thread_id = ?returns exactly the same set, so the scan is not what makes it correct.mainat06aeaa994942ae7527dc49d2268c1f801e8542a0.Suggested priority and effort
Medium priority, Low effort. No incorrect behavior and no data loss, and it is unnoticeable on a small tracker — but the cost is paid on every thread transition app-wide and grows with task count, so it degrades silently as a tracker fills up. The fix is a
listTaskThreadsByThreadId(threadId)store method over the existingidx_task_threads_threadindex, used bytransitionTrackedThread. The sweep callers can keep the current traversal or move to a status-filtered query.Found by source review of the Tasks plugin in a Claude Code session; there is no bb thread to link. The failing test above is the verification, run against the stated commit.