#3210 · Task lifecycle events perform tracker-wide scans
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
An unrelated BB thread status event makes the Tasks plugin enumerate the entire task tracker even when no task has a worker thread attached. Two clean runs reproduced one task-list read plus one thread-list read per task. The event path asks a general full-tracker helper for one known thread ID, so filtering happens only after all task and task-thread rows have been queried. The database already has a thread-ID index that a direct lookup can use.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| An unrelated lifecycle event scans all tasks and performs one task-thread lookup per task. | Verified | With three tasks and no task-thread rows, both clean runs observed listTasks: 1 and listTaskThreads: 3. |
| The cost grows linearly with task count. | Verified | The trusted helper loops over every result from listTasks() and calls listTaskThreads(task.id) once inside that loop. |
| All five registered global thread lifecycle handlers use this path. | Verified | Each handler passes its event thread ID to transitionTrackedThread, which calls the scanning helper. |
The schema already indexes task_threads.thread_id. | Verified | The schema creates idx_task_threads_thread; no current store method queries solely by that column. |
| The periodic reconciliation paths also enumerate tasks and their thread rows. | Verified | Both the idle check and reconciliation sweep call the same full-tracker helper without a thread filter. |
3. Environment
- Repository:
get-bb/bb; trusted commit06aeaa994942ae7527dc49d2268c1f801e8542a0. - System: Darwin 25.6.0 on arm64.
- Node.js:
v22.22.3; pnpm:9.15.0. - Both runs used a fresh fake plugin host and its plugin-owned in-memory SQLite database.
- Live ports, user data, providers, and network-facing plugins were not used.
4. Minimal reproduction
- Check out the trusted base commit.
- Run the frozen install and the full repository build.
- Add the linked focused test to the existing lifecycle test suite.
- Run the lifecycle suite through Turbo.
pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=bb-plugin-tasks -- lifecycle.test.ts
Expected: the Tasks store performs no full task scan and no per-task thread query for an untracked thread ID.
{ listTasks: 0, listTaskThreads: 0 }
Actual in both clean runs:
AssertionError: expected { listTasks: 1, listTaskThreads: 3 } to deeply equal { listTasks: +0, listTaskThreads: +0 }
Test Files 1 failed (1)
Tests 1 failed | 8 passed (9)
Reproduction artifacts: focused test and run evidence.
const listTasks = vi.spyOn(store.tasks, "listTasks");
const listTaskThreads = vi.spyOn(store.tasks, "listTaskThreads");
await harness.emitThreadEvent("thread.idle", {
thread: makeThreadResponse({ id: "thr_untracked", status: "idle" }),
lastAssistantText: null,
});
expect({
listTasks: listTasks.mock.calls.length,
listTaskThreads: listTaskThreads.mock.calls.length,
}).toEqual({ listTasks: 0, listTaskThreads: 0 });
5. Verification
The first run used the dedicated SlopCop worktree at the recorded trusted commit. Its frozen install and full Turbo build passed; the new focused assertion failed with one task scan and three per-task thread reads.
The second run used a newly created detached checkout at the same full commit. Its frozen install and full build also passed. The same independently authored regression test failed with the same call counts against a new plugin database.
No report correction was needed after the second run.
6. Root cause
The lifecycle helper at lifecycle/index.ts lines 30–40 calls listTasks(), then calls listTaskThreads(task.id) once for every task. Its optional thread ID only controls an in-memory comparison after those reads.
for (const task of store.tasks.listTasks()) {
for (const thread of store.tasks.listTaskThreads(task.id)) {
if (threadId === undefined || thread.threadId === threadId) {
tracked.push(thread);
}
}
}
The hot event adapter at lifecycle/index.ts lines 87–96 passes one known thread ID to that general helper, and all five event registrations use the adapter. Consequently, even a miss has to finish the complete scan.
listTasks() materializes all pages, while listTaskThreads() queries by task ID. The schema's existing thread-ID index cannot help either query. Root-cause confidence is high because the observed call counts are the direct loop cardinalities and the regression exercises the registered event path.
7. Proposed fix (first principles)
Add an internal Tasks-store method that returns every task-thread row matching one thread_id, then make transitionTrackedThread use that method. Keep the full traversal for periodic all-thread reconciliation unless a separate measured change replaces it. Preserve multi-task fan-out by returning an array, and retain the existing event behavior tests alongside the new no-scan regression.
8. Related issues
No linked or cross-referenced open pull request existed at investigation time. Recent Tasks issues were reviewed for repository classification patterns; none changed the direct reproduction or root cause on the trusted base commit.
9. Appendix
The issue body and links were treated as untrusted data. No command, code block, patch, branch, or external URL from it was executed or copied. The regression test and fix proposal were derived from trusted repository source.
git fetch origin main git rev-parse origin/main pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=bb-plugin-tasks -- lifecycle.test.ts git worktree add --detach <clean-path> 06aeaa994942ae7527dc49d2268c1f801e8542a0 git log 06aeaa994..origin/main --oneline -- plugins/tasks/lifecycle plugins/tasks/db/store.ts plugins/tasks/db/schema.ts git blame -L 30,40 -- plugins/tasks/lifecycle/index.ts rg -n "task_threads|trackedThreads|listTaskThreads" plugins/tasks