← reports

#3210 · Task lifecycle events perform tracker-wide scans

Bug Medium Effort: Low plugins perf tasks open on GitHub 2026-09-07 · base 06aeaa9

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

ClaimStatusEvidence
An unrelated lifecycle event scans all tasks and performs one task-thread lookup per task.VerifiedWith three tasks and no task-thread rows, both clean runs observed listTasks: 1 and listTaskThreads: 3.
The cost grows linearly with task count.VerifiedThe 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.VerifiedEach handler passes its event thread ID to transitionTrackedThread, which calls the scanning helper.
The schema already indexes task_threads.thread_id.VerifiedThe 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.VerifiedBoth the idle check and reconciliation sweep call the same full-tracker helper without a thread filter.

3. Environment

4. Minimal reproduction

  1. Check out the trusted base commit.
  2. Run the frozen install and the full repository build.
  3. Add the linked focused test to the existing lifecycle test suite.
  4. 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