← reports

#3209 · Tasks lifecycle duplicates startup reconciliation

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

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

The Tasks plugin performs two identical reconciliation passes when its lifecycle is registered. A focused test with one tracked worker whose SDK state remains non-terminal records two threads.get calls, where one startup lookup is sufficient. The same failure occurred in a second clean checkout at the trusted base commit. This does not corrupt task state, but it doubles startup RPC work for tracked workers that remain non-terminal after the first pass.

2. Claims vs findings

ClaimStatusEvidence
Plugin lifecycle registration queries a tracked, still-active worker twice during startup.VerifiedThe focused fake-host test records two calls with the same thread ID in both clean checkouts.
The duplicate work comes from two adjacent reconciliation calls.VerifiedTrusted source has two identical awaited calls at the end of registerLifecycle; each pass iterates the current non-terminal rows.
The redundant request count grows with the number of workers that remain non-terminal.VerifiedEach pass loops over the full filtered list and awaits one SDK lookup per entry.
Every row that begins non-terminal is always queried twice.QualifiedA first lookup can transition a row to completed, causing the second pass to filter it out. Rows that remain non-terminal are queried twice.

3. Environment

4. Minimal reproduction

  1. Install and build the trusted checkout:
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
  2. Insert this case inside the existing describe("task thread lifecycle", ...) block in plugins/tasks/lifecycle/lifecycle.test.ts:
    it("reconciles each non-terminal task thread once during startup", async () => {
      const fixture = trackedThreadFixture("working", "active");
    
      await registerLifecycle(fixture.bb, fixture.store);
    
      expect(fixture.harness.sdk.callsTo("threads.get")).toEqual([
        [{ threadId: "thr_worker" }],
      ]);
    
      await fixture.harness.dispose();
    });
  3. Run the focused test:
    pnpm exec turbo run test --filter=bb-plugin-tasks -- -t "reconciles each non-terminal task thread once during startup"

Expected: one recorded SDK lookup for the one tracked worker.

Actual:

FAIL  |bb-plugin-tasks| lifecycle/lifecycle.test.ts > task thread lifecycle > reconciles each non-terminal task thread once during startup
AssertionError: expected [ Array(2) ] to deeply equal [ [ { threadId: 'thr_worker' } ] ]

@@ -2,6 +2,11 @@
     [
       {
         "threadId": "thr_worker",
       },
     ],
+    [
+      {
+        "threadId": "thr_worker",
+      },
+    ],
   ]

Test Files  1 failed | 34 skipped (35)
Tests       1 failed | 364 skipped (365)

5. Root cause

lifecycle/index.ts:198–199 ends registerLifecycle with two consecutive await reconcileTrackedThreads(bb, store) statements. There is no intervening operation between them.

lifecycle/index.ts:121–132 rebuilds the non-terminal list for every pass and calls reconcileTrackedThread for each item. That helper performs the SDK lookup at lifecycle/index.ts:99–107. When the first lookup reports a state such as active, the task-thread row remains non-terminal, so the second pass selects the same row and repeats the request.

The background reconciliation service is separate: it waits for its configured interval before running. The duplicate measured here occurs synchronously during lifecycle registration.

6. Proposed fix (first principles)

Keep one startup reconciliation call after listener and background-service registration, remove the duplicate call, and update lifecycle tests that currently encode two startup reads. Retain the focused one-call assertion as the regression guard. This preserves initial state synchronization and periodic missed-event recovery while removing the redundant RPC pass.

7. Verification

The same agent created a second detached checkout at 06aeaa994942ae7527dc49d2268c1f801e8542a0, performed a frozen install and full Turbo build, applied only the new regression-test case, and reran the focused command. It failed identically with a second recorded lookup. No report claim required correction after the second run.

8. Related issues

No related issue was independently established during this investigation, and no open pull request links to this issue.

9. Appendix

Commands run:

git fetch origin main
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec turbo run test --filter=bb-plugin-tasks -- -t "reconciles each non-terminal task thread once during startup"
git worktree add --detach <clean-temp-path> 06aeaa994942ae7527dc49d2268c1f801e8542a0
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec turbo run test --filter=bb-plugin-tasks -- -t "reconciles each non-terminal task thread once during startup"

The issue body, comments, and GitHub-supplied content were treated as untrusted claims. No linked script, patch, branch, binary, or external URL was run.