Skip to content

Tasks plugin reconciles every tracked worker thread twice on each plugin load #3209

Description

@nawatt-works

Summary

registerLifecycle in the built-in Tasks plugin ends with two back-to-back await reconcileTrackedThreads(bb, store) calls. Every plugin load — server start, enabling the plugin, a plugin reload — therefore reconciles every non-terminal tracked worker thread twice and issues two bb.sdk.threads.get calls per tracked thread where one is enough. Nothing changes between the two sweeps that would make the second one necessary; I expected a single reconcile pass on load.

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)
  • No provider involved: the duplicate runs during plugin startup, before any agent work

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:

import {
  createFakePluginHost,
  makeThreadResponse,
} from "@get-bb/plugin-sdk/testing";
import { describe, expect, it } from "vitest";
import { createStore } from "./api";
import { registerLifecycle } from "./lifecycle";

describe("zz-repro", () => {
  it("registerLifecycle reconciles every tracked thread twice", async () => {
    const { bb, harness } = createFakePluginHost({ pluginId: "tasks" });
    const base = createStore(bb);
    const project = base.tasks.createProject({
      name: "Repro",
      prefix: "REP",
      color: "blue",
    });
    const task = base.tasks.createTask({
      projectId: project.id,
      title: "Tracked",
    });
    base.tasks.upsertTaskThread({
      taskId: task.id,
      threadId: "thr_repro1",
      presetName: "preset",
      title: "worker",
      liveStatus: "working",
    });
    harness.sdk.stub("threads.get", () =>
      makeThreadResponse({ id: "thr_repro1", status: "active" }),
    );

    await registerLifecycle(bb, base);

    console.log(
      `[A] threads.get calls for 1 tracked thread = ${harness.sdk.callsTo("threads.get").length}`,
    );
    expect(harness.sdk.callsTo("threads.get").length).toBe(1);
  });
});

One task, one attached worker thread in a non-terminal state, nothing else.

Expected vs actual

stdout | zz-repro.test.ts > zz-repro > registerLifecycle reconciles every tracked thread twice
[A] threads.get calls for 1 tracked thread = 2

 FAIL   bb-plugin-tasks  zz-repro.test.ts > zz-repro > registerLifecycle reconciles every tracked thread twice
AssertionError: expected 2 to be 1 // Object.is equality

- Expected
+ Received

- 1
+ 2

(Output trimmed to the assertion; the code frame and stack pointer are omitted.)

Expected: one bb.sdk.threads.get per tracked non-terminal thread per plugin load.
Actual: two. The count scales with the number of tracked non-terminal threads, so a
tracker with 40 in-flight workers issues 80 threads.get calls on every load instead of 40.

Evidence

The two calls are adjacent, at the end of registerLifecycle:

await reconcileTrackedThreads(bb, store);
await reconcileTrackedThreads(bb, store);

reconcileTrackedThreads iterates every non-terminal tracked thread and awaits
bb.sdk.threads.get for each one:

async function reconcileTrackedThreads(
bb: BbPluginApi,
store: TasksApiStore,
): Promise<void> {
const nonTerminalThreads = trackedThreads(store).filter(
(thread) => !TERMINAL_LIVE_STATUSES.has(thread.liveStatus),
);
for (const trackedThread of nonTerminalThreads) {
await reconcileTrackedThread(bb, store, trackedThread);
}
}

The measurement above is the count from harness.sdk.callsTo("threads.get"), which
records every bb.sdk call the plugin makes through the fake host.

What you ruled out

  • Not a duplicate: searching open and closed issues for trackedThreads, reconcileTrackedThreads, tasks reconcile and tasks plugin performance returns nothing about this code path.
  • Not deliberate retry or settling logic: there is no await, delay, state mutation, or comment between the two calls, and reconcileTrackedThreads reads the same rows both times.
  • Not idempotence-driven: transitionThread already returns early when the live status is unchanged, so the second sweep does no useful work — it only repeats the network/RPC calls.
  • Reproduced on main at 06aeaa994942ae7527dc49d2268c1f801e8542a0.

Suggested priority and effort

Low priority, Low effort — wasted startup RPC only, no incorrect state, no data loss; the fix is deleting one line. Worth pairing with the trackedThreads scan issue since both live in the same file.

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.

AGENT GENERATED

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    confirmed-reproBug reproduced again from a clean trusted checkout; see linked reportperfpluginsPlugin SDK, runtime, marketplacetasksBuilt-in plugin: tasks

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions