← reports

#2532 · Enabled plugin agent tools can be absent from freshly dispatched project lanes

Bug Priority: High Effort: Unset threads plugins open on GitHub 2026-08-27 · base ad79bbb5ec90

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: medium

1. TL;DR

BB cannot require an agent tool when a plugin creates a thread. The route-level test created a hidden plugin thread and inspected its queued start command. That command omitted the required closure tool. The plugin recognized ownership after dispatch, so a later configuration resolution could select the tool. The original private lane and provider transcript were not available.

The test proves the contract gap and one sequence that matches the report. It does not prove the reporter's exact sequence.

2. Claims vs findings

Claim from issue #2532StatusEvidence
A running plugin can own a new lane that starts without its closure tool.Partially verifiedThe real create route queued thread.start without required_closure. The plugin then recognized the thread as its lane.
The same tool reached a lane in another project on the same host.UnverifiedThe issue omits the project, thread, host, and account identifiers. It gives no public control log.
The lane completed, but a follow-up turn lacked the tool.Partially verifiedThe daemon does not apply resumeContext.dynamicTools to an existing runtime. The original provider transcript was not available.
A missing closure tool can leave a permanent plugin row.UnverifiedThe downstream data belongs to pixexid/bb-collab#736. That data was not available.
Dispatch cannot require a tool or return a tool receipt.VerifiedThe create schema has no required-tool field. ThreadSpawnResult contains only ThreadResponse.

3. Environment

4. Minimal reproduction

Run these commands in a fresh clone. The source command contains the full test and needs no report-site download.

  1. Check out and build the tested commit.
    git checkout ad79bbb5ec909524f8f281e62d860c588a86f332
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
  2. Create the route-level test from the embedded bytes.
    mkdir -p apps/server/test/services/plugins
    printf '%s' 'aW1wb3J0IHsgbWtkaXIsIG1rZHRlbXAsIHJtLCB3cml0ZUZpbGUgfSBmcm9tICJub2RlOmZzL3Byb21pc2VzIjsKaW1wb3J0IHsgdG1wZGlyIH0gZnJvbSAibm9kZTpvcyI7CmltcG9ydCB7IGpvaW4gfSBmcm9tICJub2RlOnBhdGgiOwppbXBvcnQgeyBleHBlY3QsIGl0LCB2aSB9IGZyb20gInZpdGVzdCI7CmltcG9ydCB7IGNyZWF0ZVRocmVhZEZyb21SZXF1ZXN0IH0gZnJvbSAiLi4vLi4vLi4vc3JjL3NlcnZpY2VzL3RocmVhZHMvdGhyZWFkLWNyZWF0ZS5qcyI7CmltcG9ydCB7IHdhaXRGb3JRdWV1ZWRDb21tYW5kIH0gZnJvbSAiLi4vLi4vaGVscGVycy9jb21tYW5kcy5qcyI7CmltcG9ydCB7IHRleHRJbnB1dCB9IGZyb20gIi4uLy4uL2hlbHBlcnMvcHJvbXB0LWlucHV0LmpzIjsKaW1wb3J0IHsKICBzZWVkRW52aXJvbm1lbnQsCiAgc2VlZEhvc3RTZXNzaW9uLAogIHNlZWRQcm9qZWN0V2l0aFNvdXJjZSwKfSBmcm9tICIuLi8uLi9oZWxwZXJzL3NlZWQuanMiOwppbXBvcnQgeyBjcmVhdGVUZXN0QXBwSGFybmVzcyB9IGZyb20gIi4uLy4uL2hlbHBlcnMvdGVzdC1hcHAuanMiOwoKaXQoIm1vdW50cyBhIHBsdWdpbiBjbG9zdXJlIHRvb2wgYmVmb3JlIGEgbmV3IG93bmVkIGxhbmUgc3RhcnRzIiwgYXN5bmMgKCkgPT4gewogIGNvbnN0IGhhcm5lc3MgPSBhd2FpdCBjcmVhdGVUZXN0QXBwSGFybmVzcygpOwogIGNvbnN0IHdvcmtEaXIgPSBhd2FpdCBta2R0ZW1wKGpvaW4odG1wZGlyKCksICJiYi0yNTMyLXJlcHJvLSIpKTsKICBjb25zdCBvd25lZFRocmVhZElkcyA9IG5ldyBTZXQ8c3RyaW5nPigpOwogIChnbG9iYWxUaGlzIGFzIFJlY29yZDxzdHJpbmcsIHVua25vd24+KS5fX2lzc3VlMjUzMk93bmVkVGhyZWFkSWRzID0KICAgIG93bmVkVGhyZWFkSWRzOwoKICB0cnkgewogICAgY29uc3Qgcm9vdERpciA9IGpvaW4od29ya0RpciwgImJiLXBsdWdpbi1sYW5lLW93bmVyIik7CiAgICBhd2FpdCBta2Rpcihyb290RGlyLCB7IHJlY3Vyc2l2ZTogdHJ1ZSB9KTsKICAgIGF3YWl0IHdyaXRlRmlsZSgKICAgICAgam9pbihyb290RGlyLCAicGFja2FnZS5qc29uIiksCiAgICAgIEpTT04uc3RyaW5naWZ5KHsKICAgICAgICBuYW1lOiAiYmItcGx1Z2luLWxhbmUtb3duZXIiLAogICAgICAgIHZlcnNpb246ICIwLjEuMCIsCiAgICAgICAgYmI6IHsKICAgICAgICAgIG5hbWU6ICJMYW5lIG93bmVyIiwKICAgICAgICAgIGRlc2NyaXB0aW9uOiAiSXNzdWUgMjUzMiByZXByb2R1Y3Rpb24gZml4dHVyZS4iLAogICAgICAgICAgYnJhbmRpbmc6IHsgaWNvbjogIlphcCIgfSwKICAgICAgICAgIHNlcnZlcjogIi4vc2VydmVyLnRzIiwKICAgICAgICB9LAogICAgICB9KSwKICAgICk7CiAgICBhd2FpdCB3cml0ZUZpbGUoCiAgICAgIGpvaW4ocm9vdERpciwgInNlcnZlci50cyIpLAogICAgICBgZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gcGx1Z2luKGJiOiBhbnkpIHsKICAgICAgICBiYi5hZ2VudHMucmVnaXN0ZXJUb29sKHsKICAgICAgICAgIG5hbWU6ICJyZXF1aXJlZF9jbG9zdXJlIiwKICAgICAgICAgIGRlc2NyaXB0aW9uOiAiQ2xvc2UgdGhlIGR1cmFibGUgbGFuZSBhdHRlbXB0IiwKICAgICAgICAgIHBhcmFtZXRlcnM6IHsgdHlwZTogIm9iamVjdCIgfSwKICAgICAgICAgIGV4ZWN1dGU6ICgpID0+ICJjbG9zZWQiLAogICAgICAgIH0pOwogICAgICAgIGJiLmV2ZW50cy5vbigidGhyZWFkLmNyZWF0ZWQiLCBhc3luYyAoeyB0aHJlYWQgfTogYW55KSA9PiB7CiAgICAgICAgICBpZiAodGhyZWFkLm9yaWdpblBsdWdpbklkICE9PSBiYi5wbHVnaW5JZCkgcmV0dXJuOwogICAgICAgICAgYXdhaXQgbmV3IFByb21pc2UoKHJlc29sdmUpID0+IHNldFRpbWVvdXQocmVzb2x2ZSwgMjUwKSk7CiAgICAgICAgICAoZ2xvYmFsVGhpcyBhcyBhbnkpLl9faXNzdWUyNTMyT3duZWRUaHJlYWRJZHMuYWRkKHRocmVhZC5pZCk7CiAgICAgICAgfSk7CiAgICAgICAgYmIuYWdlbnRzLmNvbmZpZ3VyZSgoY29udGV4dDogYW55KSA9PiAoewogICAgICAgICAgdG9vbHM6IChnbG9iYWxUaGlzIGFzIGFueSkuX19pc3N1ZTI1MzJPd25lZFRocmVhZElkcy5oYXMoY29udGV4dC50aHJlYWQuaWQpCiAgICAgICAgICAgID8gWyJyZXF1aXJlZF9jbG9zdXJlIl0KICAgICAgICAgICAgOiBbXSwKICAgICAgICAgIHNraWxsczogW10sCiAgICAgICAgfSkpOwogICAgICB9YCwKICAgICk7CiAgICBjb25zdCBlbnRyeSA9IGF3YWl0IGhhcm5lc3MucGx1Z2luU2VydmljZS5pbnN0YWxsUGF0aChyb290RGlyKTsKICAgIGV4cGVjdChlbnRyeS5zdGF0dXMpLnRvQmUoInJ1bm5pbmciKTsKCiAgICBjb25zdCB7IGhvc3QgfSA9IHNlZWRIb3N0U2Vzc2lvbihoYXJuZXNzLmRlcHMsIHsKICAgICAgaWQ6ICJob3N0LWlzc3VlLTI1MzIiLAogICAgfSk7CiAgICBjb25zdCB3b3Jrc3BhY2VQYXRoID0gam9pbihoYXJuZXNzLmNvbmZpZy5kYXRhRGlyLCAiaXNzdWUtMjUzMi13b3Jrc3BhY2UiKTsKICAgIGNvbnN0IHsgcHJvamVjdCB9ID0gc2VlZFByb2plY3RXaXRoU291cmNlKGhhcm5lc3MuZGVwcywgewogICAgICBob3N0SWQ6IGhvc3QuaWQsCiAgICAgIHBhdGg6IHdvcmtzcGFjZVBhdGgsCiAgICB9KTsKICAgIGNvbnN0IGVudmlyb25tZW50ID0gc2VlZEVudmlyb25tZW50KGhhcm5lc3MuZGVwcywgewogICAgICBob3N0SWQ6IGhvc3QuaWQsCiAgICAgIHByb2plY3RJZDogcHJvamVjdC5pZCwKICAgICAgcGF0aDogd29ya3NwYWNlUGF0aCwKICAgIH0pOwoKICAgIGNvbnN0IHRocmVhZCA9IGF3YWl0IGNyZWF0ZVRocmVhZEZyb21SZXF1ZXN0KGhhcm5lc3MuZGVwcywgewogICAgICBlbnZpcm9ubWVudDogeyB0eXBlOiAicmV1c2UiLCBlbnZpcm9ubWVudElkOiBlbnZpcm9ubWVudC5pZCB9LAogICAgICBpbnB1dDogdGV4dElucHV0KCJTdGFydCB0aGUgbWFuYWdlZCBsYW5lIiksCiAgICAgIG9yaWdpbjogInBsdWdpbiIsCiAgICAgIG9yaWdpblBsdWdpbklkOiAibGFuZS1vd25lciIsCiAgICAgIHByb2plY3RJZDogcHJvamVjdC5pZCwKICAgICAgcHJvdmlkZXJJZDogImNvZGV4IiwKICAgICAgc3RhcnRlZE9uQmVoYWxmT2Y6IG51bGwsCiAgICAgIHZpc2liaWxpdHk6ICJoaWRkZW4iLAogICAgfSk7CiAgICBjb25zdCBxdWV1ZWRTdGFydCA9IGF3YWl0IHdhaXRGb3JRdWV1ZWRDb21tYW5kKAogICAgICBoYXJuZXNzLAogICAgICAoeyBjb21tYW5kIH0pID0+CiAgICAgICAgY29tbWFuZC50eXBlID09PSAidGhyZWFkLnN0YXJ0IiAmJiBjb21tYW5kLnRocmVhZElkID09PSB0aHJlYWQuaWQsCiAgICApOwogICAgaWYgKHF1ZXVlZFN0YXJ0LmNvbW1hbmQudHlwZSAhPT0gInRocmVhZC5zdGFydCIpIHsKICAgICAgdGhyb3cgbmV3IEVycm9yKCJFeHBlY3RlZCBhIHRocmVhZC5zdGFydCBjb21tYW5kIik7CiAgICB9CiAgICBjb25zdCBzdGFydER5bmFtaWNUb29sTmFtZXMgPSBxdWV1ZWRTdGFydC5jb21tYW5kLmR5bmFtaWNUb29scy5tYXAoCiAgICAgICh0b29sKSA9PiB0b29sLm5hbWUsCiAgICApOwogICAgYXdhaXQgdmkud2FpdEZvcigoKSA9PiBleHBlY3Qob3duZWRUaHJlYWRJZHMuaGFzKHRocmVhZC5pZCkpLnRvQmUodHJ1ZSkpOwogICAgY29uc29sZS5sb2coCiAgICAgIEpTT04uc3RyaW5naWZ5KHsKICAgICAgICBzdGFydER5bmFtaWNUb29sTmFtZXMsCiAgICAgICAgb3duZXJzaGlwT2JzZXJ2ZWRBZnRlckRpc3BhdGNoOiBvd25lZFRocmVhZElkcy5oYXModGhyZWFkLmlkKSwKICAgICAgfSksCiAgICApOwoKICAgIGV4cGVjdChzdGFydER5bmFtaWNUb29sTmFtZXMpLnRvQ29udGFpbigicmVxdWlyZWRfY2xvc3VyZSIpOwogIH0gZmluYWxseSB7CiAgICBkZWxldGUgKGdsb2JhbFRoaXMgYXMgUmVjb3JkPHN0cmluZywgdW5rbm93bj4pLl9faXNzdWUyNTMyT3duZWRUaHJlYWRJZHM7CiAgICBhd2FpdCBoYXJuZXNzLnBsdWdpblNlcnZpY2Uuc3RvcCgpOwogICAgYXdhaXQgaGFybmVzcy5jbGVhbnVwKCk7CiAgICBhd2FpdCBybSh3b3JrRGlyLCB7IHJlY3Vyc2l2ZTogdHJ1ZSwgZm9yY2U6IHRydWUgfSk7CiAgfQp9KTsK' | base64 --decode >   apps/server/test/services/plugins/issue-2532.repro.test.ts
    sha256sum apps/server/test/services/plugins/issue-2532.repro.test.ts

    The hash must equal beef50a5ea82ac1cac800a8c88db3161323e174059ba3e197fd6b355d6b13c8f.

  3. Run the focused server test.
    pnpm exec turbo run test --filter=@bb/server --   --run test/services/plugins/issue-2532.repro.test.ts

Expected: The queued start tool set contains required_closure. The test passes.

Actual:

{"startDynamicToolNames":["update_environment_directory"],"ownershipObservedAfterDispatch":true}
AssertionError: expected [ 'update_environment_directory' ] to include 'required_closure'
Test Files  1 failed (1)
Tests       1 failed (1)

Repro files: test source and failing output.

Repro test

import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { createThreadFromRequest } from "../../../src/services/threads/thread-create.js";
import { waitForQueuedCommand } from "../../helpers/commands.js";
import { textInput } from "../../helpers/prompt-input.js";
import {
  seedEnvironment,
  seedHostSession,
  seedProjectWithSource,
} from "../../helpers/seed.js";
import { createTestAppHarness } from "../../helpers/test-app.js";

it("mounts a plugin closure tool before a new owned lane starts", async () => {
  const harness = await createTestAppHarness();
  const workDir = await mkdtemp(join(tmpdir(), "bb-2532-repro-"));
  const ownedThreadIds = new Set<string>();
  (globalThis as Record<string, unknown>).__issue2532OwnedThreadIds =
    ownedThreadIds;

  try {
    const rootDir = join(workDir, "bb-plugin-lane-owner");
    await mkdir(rootDir, { recursive: true });
    await writeFile(
      join(rootDir, "package.json"),
      JSON.stringify({
        name: "bb-plugin-lane-owner",
        version: "0.1.0",
        bb: {
          name: "Lane owner",
          description: "Issue 2532 reproduction fixture.",
          branding: { icon: "Zap" },
          server: "./server.ts",
        },
      }),
    );
    await writeFile(
      join(rootDir, "server.ts"),
      `export default function plugin(bb: any) {
        bb.agents.registerTool({
          name: "required_closure",
          description: "Close the durable lane attempt",
          parameters: { type: "object" },
          execute: () => "closed",
        });
        bb.events.on("thread.created", async ({ thread }: any) => {
          if (thread.originPluginId !== bb.pluginId) return;
          await new Promise((resolve) => setTimeout(resolve, 250));
          (globalThis as any).__issue2532OwnedThreadIds.add(thread.id);
        });
        bb.agents.configure((context: any) => ({
          tools: (globalThis as any).__issue2532OwnedThreadIds.has(context.thread.id)
            ? ["required_closure"]
            : [],
          skills: [],
        }));
      }`,
    );
    const entry = await harness.pluginService.installPath(rootDir);
    expect(entry.status).toBe("running");

    const { host } = seedHostSession(harness.deps, {
      id: "host-issue-2532",
    });
    const workspacePath = join(harness.config.dataDir, "issue-2532-workspace");
    const { project } = seedProjectWithSource(harness.deps, {
      hostId: host.id,
      path: workspacePath,
    });
    const environment = seedEnvironment(harness.deps, {
      hostId: host.id,
      projectId: project.id,
      path: workspacePath,
    });

    const thread = await createThreadFromRequest(harness.deps, {
      environment: { type: "reuse", environmentId: environment.id },
      input: textInput("Start the managed lane"),
      origin: "plugin",
      originPluginId: "lane-owner",
      projectId: project.id,
      providerId: "codex",
      startedOnBehalfOf: null,
      visibility: "hidden",
    });
    const queuedStart = await waitForQueuedCommand(
      harness,
      ({ command }) =>
        command.type === "thread.start" && command.threadId === thread.id,
    );
    if (queuedStart.command.type !== "thread.start") {
      throw new Error("Expected a thread.start command");
    }
    const startDynamicToolNames = queuedStart.command.dynamicTools.map(
      (tool) => tool.name,
    );
    await vi.waitFor(() => expect(ownedThreadIds.has(thread.id)).toBe(true));
    console.log(
      JSON.stringify({
        startDynamicToolNames,
        ownershipObservedAfterDispatch: ownedThreadIds.has(thread.id),
      }),
    );

    expect(startDynamicToolNames).toContain("required_closure");
  } finally {
    delete (globalThis as Record<string, unknown>).__issue2532OwnedThreadIds;
    await harness.pluginService.stop();
    await harness.cleanup();
    await rm(workDir, { recursive: true, force: true });
  }
});

Investigation control

This control did not come from issue #2532. It checks unconditional plugin tool selection.

pnpm exec turbo run test --filter=@bb/server --   --run test/services/plugins/plugin-agent-tools.test.ts   -t 'thread.start dynamicTools include plugin tools'
✓ thread.start dynamicTools include plugin tools with per-tool instructions
Test Files  1 passed (1)
Tests       1 passed | 18 skipped (19)

Control log: positive-control.txt.

5. Root cause

The server inserts the thread row and then emits thread.created. See thread-create-helpers.ts lines 158–186.

const thread = createThread(...);
emitPluginThreadCreated(thread);
return thread;

The plugin runtime runs lifecycle handlers on the next macrotask. The handler cannot block or reject creation. See plugin-runtime.ts lines 846–874.

if (!hasThreadEventHandlers(event)) return;
setImmediate(() => {
  // The plugin handler runs later.
});

The start command resolves conditional tools from current plugin state. See thread-runtime-config.ts lines 210–256. The test delayed ownership recognition for 250 ms. The create route queued the start before that state changed.

The dispatch contract causes the deeper problem. The create schema has no required-tool field. See createThreadRequestSchema. The SDK returns only a thread. See ThreadSpawnResult.

export type ThreadSpawnResult = ThreadResponse;

Follow-up session boundary

The server includes new tools in turn.submit.resumeContext. The daemon applies them only when it must resume a missing runtime. See command-handlers/thread.ts lines 192–222.

if (entry.runtime.hasThread(command.threadId)) {
  return;
}
await entry.runtime.resumeThread({
  dynamicTools: resumeContext.dynamicTools,
});

For an existing runtime, runSubmittedTurn passes instructions but does not pass dynamic tools. See command-handlers/thread.ts lines 361–375. Therefore, a live runtime keeps its original tool set.

The code and SDK text differ for instructions. The code passes new instructions to runTurn. The SDK says a live session keeps its original instructions. See PluginAgents.configure. This report does not claim that the provider applies the new instructions.

6. Proposed fix (first principles)

  1. Add requiredAgentToolIds to thread creation and SDK spawn.
  2. Allocate a candidate thread ID before the database insert.
  3. Resolve the full agent configuration with that candidate context.
  4. Refuse the request before insertion when any required tool is absent.
  5. Keep the selected plugin generation and configuration digest stable through command enqueue.
  6. Return resolved tool IDs and the digest as a server-resolution receipt.
  7. Add a daemon acceptance result with mounted tool IDs and the digest.
  8. Await that daemon result before the SDK reports mounted tools.

The server receipt proves only server resolution. It does not prove daemon mounting.

Today, requestThreadStartOnce starts runLiveHostCommand without an await. See thread-lifecycle.ts lines 1134–1179. A mount receipt requires an awaited daemon result or a separate accepted event.

Add tests for plugin disable, unload, project conditions, two projects, a changed generation, and daemon receipt mismatch.

7. Related issues

8. Appendix

Commands

gh issue view 2532 -R get-bb/bb --comments --json number,title,body,comments,labels,state
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec turbo run test --filter=@bb/server -- --run test/services/plugins/issue-2532.repro.test.ts
pnpm exec turbo run test --filter=@bb/server -- --run test/services/plugins/plugin-agent-tools.test.ts -t 'thread.start dynamicTools include plugin tools'
git fetch origin main
git rev-parse HEAD
git rev-parse origin/main
sha256sum /tmp/bb-reports/issues/2532/repro/issue-2532.repro.test.ts apps/server/test/services/plugins/issue-2532.repro.test.ts

Limits

9. Verification

The verifier found that the former public download returned HTTP 404. This revision replaced it with embedded bytes and a checked SHA-256 hash.

The revision agent ran the real createThreadFromRequest route. It inspected the queued thread.start.dynamicTools list and saved the real failing output.

The revision agent also reran the unconditional selection control. It passed. The report now separates that control from issue #2532 claims.

The fix now separates a server-resolution receipt from a daemon mount receipt. The root cause now separates tool behavior from the instruction contract conflict.

September 30, 2026 — current-main verification

Current-main verification. The August report and its historical artifacts above are preserved. Current issue metadata is open native Bug, Priority High, Effort Medium, with partial-repro. All comments, linked PR metadata and public SlopCop activity were refreshed; no open linked PR or overlapping public work was found. Private job state is unavailable.

Verdict: PARTIALLY REPRODUCED. Confidence: high for the bounded server-side ordering mechanism; medium for explaining the original incident. The actual creation route queues a start without the synthetic closure tool when the plugin recognizes ownership only after dispatch. Releasing the bounded ownership gate makes subsequent command construction include the tool; the already queued payload stays unchanged. However, current launch-time pluginMetadata selects the tool on the first start, before the lifecycle handler recognizes ownership. This positive control narrows the historical finding: delayed event-driven recognition is unsafe as a first-start tool precondition, but missing tools are not inevitable for current plugins.

Environment and method

Fetched trusted get-bb/bb origin/main: d7a6d74e87f55b80243667c67f68644b4737e77a. Linux 6.18.44 x86_64, Node 22.19.0, pnpm 9.15.0, Plugin SDK 0.6.6. The same agent personally ran the identical fixture in a second clean checkout at the same SHA. Each checkout had its own frozen install; each case used a fresh temporary plugin/workspace and isolated SQLite database initialized from the repository's migrated test template. The normal server build passed in each checkout: 5 tasks, 4 cache hits and an executed server build. The test task passed 9 tasks with no cache hits in each final run.

The fixture calls the actual createThreadFromRequest route service, installs a synthetic local plugin through the actual plugin service, and captures the thread.start RPC using the repository's test host. The fixture has no listening sockets, live bb runtime, daemon, provider process or external service. The existing Codex registration supplies test metadata only. The lifecycle handler waits for a test-owned release marker, bounded by 5 seconds; the test releases it after observing the queued command. This establishes ordering without claiming that 250 ms or any measured incident delay occurred. Cleanup releases the gate, stops the plugin and disposes the harness.

The initial default-store install failed before package installation because its default pnpm store location was unavailable. The existing writable workspace store resolved that setup issue; both subsequent frozen installs and normal builds succeeded. That initial failure is retained locally and is not behavioral evidence. No dependency or production code changed.

Expected and actual results

The issue's desired contract would guarantee the closure tool before dispatch or explicitly reject a dispatch that requires it. Current configuration instead selects only what the plugin returns at command construction time; no required-tool request was supplied because the current creation schema provides no such field. These are observation tests, not a claim that a failing regression test has passed.

CaseQueued closure tool, both runsAfter gate release, both runsMeaning
Eligible project, delayed ownershipAbsentPresent in newly built command; original queued command unchangedBounded ordering gap reproduced
Eligible project, launch metadataPresentPresentWorking current first-start control
Project ineligible, launch metadataAbsentAbsentEligibility restriction honored
Plugin disabled, launch metadataAbsentAbsent; configuration callback never ranDisabled tools do not leak

All four cases created a thread. The ineligible and disabled cases intentionally request no required capability, so their successful creation alone is not a separate defect. Two synthetic projects are seeded to check exclusion; this does not reproduce the reporter's two real lanes.

RunFocused casesExisting plugin-tool testsFinal result
First clean checkout, same agent4 passed21 passed25 passed, 2 files; 19.36 seconds Vitest duration
Second clean checkout, same agent4 passed21 passed25 passed, 2 files; 19.58 seconds Vitest duration

The first checkout additionally passed an initial four-case run before adding the existing tests to the command. Both final runs produced identical JSON observations. Full test and install/build logs remain local, outside the public reports repository.

Root cause and current control

Proposed fix and next test

For a plugin that can determine ownership at creation, seed validated launch metadata and base first-start tool selection on that metadata instead of a later lifecycle notification. Current code already supports this approach and the positive control verifies it. If the product needs a hard capability guarantee, consider an explicit required-tool preflight and a server-resolved capability receipt, with atomic refusal when required tools are absent. A server receipt must not claim provider mounting. Next test: bound configuration changes between selection and dispatch, then separately verify a daemon/provider acknowledgment with synthetic protocol fixtures. No production fix is proposed as already verified.

Exact repeatable steps and fixture

The commands below use the toolchain and writable package store used here; elsewhere, make Node 22 and the repository-pinned pnpm 9.15.0 available and choose a writable store. Both recorded checkouts were locally cloned without hardlinks from the trusted fetched repository, then detached at this SHA; the origin clones below reproduce the same tracked source. Test source SHA-256: 7c13cc61d9b85f634fe39707bb64fca616e731fbd9e8e443b2032955e869af71. All test inputs are synthetic, authored from trusted repository tests and SDK source, not copied from issue code or historical reproduction scripts.

export PATH=/workspace/.cloud-tools/node_modules/.bin:$PATH
node --version
pnpm --version
WORK=$(mktemp -d)
STORE=/workspace/.pnpm-store
for RUN in run-a run-b; do
  git clone https://github.com/get-bb/bb.git "$WORK/$RUN"
  git -C "$WORK/$RUN" checkout --detach d7a6d74e87f55b80243667c67f68644b4737e77a
done
cat > "$WORK/issue-2532.test.ts" <<'TEST'
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { expect, it } from "vitest";
import { getThread } from "@bb/db";
import { encodeClientTurnRequestIdNumber } from "@bb/domain";
import { createThreadFromRequest } from "../../src/services/threads/thread-create.js";
import { buildExecutionOptions, buildThreadStartCommand } from "../../src/services/threads/thread-commands.js";
import { waitForQueuedCommand } from "../helpers/commands.js";
import { textInput } from "../helpers/prompt-input.js";
import { seedEnvironment, seedHostSession, seedProjectWithSource } from "../helpers/seed.js";
import { createTestAppHarness } from "../helpers/test-app.js";

const cases = ["delayed", "seeded", "ineligible", "disabled"] as const;

it.each(cases)("issue 2532 actual creation route: %s", async (mode) => {
  const harness = await createTestAppHarness();
  const work = await mkdtemp(join(tmpdir(), "issue-2532-"));
  const release = join(work, "release");
  const entered = join(work, "entered");
  const ready = join(work, "ready");
  const observations = join(work, "observations");
  try {
    const { host } = seedHostSession(harness.deps);
    const workspace = join(work, "workspace");
    await mkdir(workspace);
    const { project } = seedProjectWithSource(harness.deps, { hostId: host.id, path: workspace });
    const { project: otherProject } = seedProjectWithSource(harness.deps, { hostId: host.id, path: join(work, "other") });
    const environment = seedEnvironment(harness.deps, { hostId: host.id, projectId: project.id, path: workspace });
    const root = join(work, "bb-plugin-synthetic-2532");
    await mkdir(root);
    await writeFile(join(root, "package.json"), JSON.stringify({ name: "bb-plugin-synthetic-2532", version: "0.1.0", bb: { name: "Synthetic tools", description: "Isolated test", branding: { icon: "Zap" }, server: "./server.ts" } }));
    await writeFile(join(root, "server.ts"), `
      import { existsSync, writeFileSync, appendFileSync } from "node:fs";
      import { setTimeout as delay } from "node:timers/promises";
      export default function plugin(bb: any) {
        const owned = new Set<string>();
        bb.agents.registerTool({ name: "synthetic_closure", description: "Synthetic closure", parameters: { type: "object" }, execute: () => "unused" });
        bb.events.on("thread.created", async ({ thread }: any) => {
          writeFileSync(${JSON.stringify(entered)}, "entered");
          const deadline = Date.now() + 5000;
          while (!existsSync(${JSON.stringify(release)}) && Date.now() < deadline) await delay(10);
          if (!existsSync(${JSON.stringify(release)})) throw new Error("Synthetic ownership gate timed out");
          owned.add(thread.id);
          writeFileSync(${JSON.stringify(ready)}, "ready");
        });
        bb.agents.configure((context: any) => {
          const eligible = context.project?.id === ${JSON.stringify(mode === "ineligible" ? otherProject.id : project.id)};
          const recognized = owned.has(context.thread.id);
          const seeded = context.pluginMetadata.enableClosure === true;
          appendFileSync(${JSON.stringify(observations)}, JSON.stringify({ eligible, recognized, seeded }) + "\\n");
          return { tools: eligible && (recognized || seeded) ? ["synthetic_closure"] : [], skills: [] };
        });
      }
    `);
    const installed = await harness.pluginService.installPath(root);
    expect(installed.status).toBe("running");
    if (mode === "disabled") await harness.pluginService.setEnabled(installed.id, false);
    const thread = await createThreadFromRequest(harness.deps, {
      environment: { type: "host", hostId: host.id, workspace: { type: "unmanaged", path: workspace } },
      input: textInput("Synthetic dispatch"), origin: "plugin", originPluginId: installed.id,
      ...(mode === "delayed" ? {} : { pluginMetadata: { enableClosure: true } }),
      projectId: project.id, providerId: "codex", startedOnBehalfOf: null,
    });
    const queued = await waitForQueuedCommand(harness, ({ command }) => command.type === "thread.start" && command.threadId === thread.id);
    if (queued.command.type !== "thread.start") throw new Error("Expected start command");
    const queuedBefore = queued.command.dynamicTools.some((tool) => tool.name === "synthetic_closure");
    expect(queuedBefore).toBe(mode === "seeded");
    expect(getThread(harness.db, thread.id)).toBeDefined();
    await writeFile(release, "release");
    if (mode !== "disabled") {
      await expect.poll(() => existsSync(ready), { timeout: 2000 }).toBe(true);
      expect(existsSync(entered)).toBe(true);
    }
    const stored = getThread(harness.db, thread.id);
    if (!stored) throw new Error("Thread missing");
    const execution = await buildExecutionOptions(harness.deps, { model: "gpt-5" }, { threadId: thread.id });
    const later = await buildThreadStartCommand(harness.deps, {
      environment, execution, fork: null, permissionEscalation: "ask", input: textInput("Synthetic control"),
      projectId: project.id, providerId: "codex", requestId: encodeClientTurnRequestIdNumber({ value: 999 }), syncGeneratedTitle: false, thread: stored,
    });
    const resolvedAfter = later.dynamicTools.some((tool) => tool.name === "synthetic_closure");
    expect(resolvedAfter).toBe(mode === "delayed" || mode === "seeded");
    expect(queued.command.dynamicTools.some((tool) => tool.name === "synthetic_closure")).toBe(queuedBefore);
    const seen = existsSync(observations) ? (await readFile(observations, "utf8")).trim().split("\n").map((line) => JSON.parse(line)) : [];
    if (mode === "delayed") {
      expect(seen[0]).toEqual({ eligible: true, recognized: false, seeded: false });
      expect(seen.at(-1)).toEqual({ eligible: true, recognized: true, seeded: false });
    }
    if (mode === "disabled") expect(seen).toEqual([]);
    await writeFile(join(process.cwd(), `issue-2532-${mode}.json`), JSON.stringify({ mode, threadCreated: true, queuedBefore, resolvedAfter, queuedUnchanged: true, observations: seen, providerStarted: false, listeningPorts: 0 }, null, 2) + "\n");
  } finally {
    await writeFile(release, "release");
    await delay(30);
    await harness.pluginService.stop();
    await harness.cleanup();
    await rm(work, { recursive: true, force: true });
  }
}, 20000);
TEST
for RUN in run-a run-b; do
  cd "$WORK/$RUN"
  pnpm install --frozen-lockfile --store-dir "$STORE"
  pnpm exec turbo run build --filter=@bb/server
  cp "$WORK/issue-2532.test.ts" apps/server/test/threads/issue-2532.test.ts
  pnpm exec turbo run test --filter=@bb/server -- --run test/threads/issue-2532.test.ts test/services/plugins/plugin-agent-tools.test.ts
  cat apps/server/issue-2532-delayed.json apps/server/issue-2532-seeded.json apps/server/issue-2532-ineligible.json apps/server/issue-2532-disabled.json
done

Exact observations in both final runs

[
  {
    "mode": "delayed",
    "threadCreated": true,
    "queuedBefore": false,
    "resolvedAfter": true,
    "queuedUnchanged": true,
    "observations": [
      {
        "eligible": true,
        "recognized": false,
        "seeded": false
      },
      {
        "eligible": true,
        "recognized": true,
        "seeded": false
      }
    ],
    "providerStarted": false,
    "listeningPorts": 0
  },
  {
    "mode": "seeded",
    "threadCreated": true,
    "queuedBefore": true,
    "resolvedAfter": true,
    "queuedUnchanged": true,
    "observations": [
      {
        "eligible": true,
        "recognized": false,
        "seeded": true
      },
      {
        "eligible": true,
        "recognized": true,
        "seeded": true
      }
    ],
    "providerStarted": false,
    "listeningPorts": 0
  },
  {
    "mode": "ineligible",
    "threadCreated": true,
    "queuedBefore": false,
    "resolvedAfter": false,
    "queuedUnchanged": true,
    "observations": [
      {
        "eligible": false,
        "recognized": false,
        "seeded": true
      },
      {
        "eligible": false,
        "recognized": true,
        "seeded": true
      }
    ],
    "providerStarted": false,
    "listeningPorts": 0
  },
  {
    "mode": "disabled",
    "threadCreated": true,
    "queuedBefore": false,
    "resolvedAfter": false,
    "queuedUnchanged": true,
    "observations": [],
    "providerStarted": false,
    "listeningPorts": 0
  }
]

Scope and evidence limits

The historical report and issue content, comments, links and attachments were treated as untrusted evidence only. No issue-supplied command, code, patch, external linked source or PR branch was executed or fetched. No subagent was used. No real provider mounting, follow-up session refresh, permanent downstream row, original private lane, UI rendering or original incident timing was tested. The existing historical claims about daemon behavior are retained as history, not reverified by this addendum. No visual claim or screenshot is added. The current observed payload omission is reproduced; the full user journey remains only partially reproduced.