#2532 · Enabled plugin agent tools can be absent from freshly dispatched project lanes
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 #2532 | Status | Evidence |
|---|---|---|
| A running plugin can own a new lane that starts without its closure tool. | Partially verified | The 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. | Unverified | The 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 verified | The 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. | Unverified | The downstream data belongs to pixexid/bb-collab#736. That data was not available. |
| Dispatch cannot require a tool or return a tool receipt. | Verified | The create schema has no required-tool field. ThreadSpawnResult contains only ThreadResponse. |
3. Environment
- bb commit:
ad79bbb5ec909524f8f281e62d860c588a86f332. origin/mainhad the same commit during this check. No later fix existed.- OS: Linux 7.0.0-29-generic, x86_64.
- Node:
v24.18.0. pnpm:9.15.0. - Plugin SDK package:
0.4.24. - The server harness used the Codex test registration. It did not start a provider process.
- The test used an in-memory SQLite database and a temporary plugin directory. It used no network port.
4. Minimal reproduction
Run these commands in a fresh clone. The source command contains the full test and needs no report-site download.
- Check out and build the tested commit.
git checkout ad79bbb5ec909524f8f281e62d860c588a86f332 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build
- 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. - 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)
- Add
requiredAgentToolIdsto thread creation and SDK spawn. - Allocate a candidate thread ID before the database insert.
- Resolve the full agent configuration with that candidate context.
- Refuse the request before insertion when any required tool is absent.
- Keep the selected plugin generation and configuration digest stable through command enqueue.
- Return resolved tool IDs and the digest as a server-resolution receipt.
- Add a daemon acceptance result with mounted tool IDs and the digest.
- 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
- #2384 covers conditional tool selection after a released thread resumes.
- #1664 contains the broader native-tool investigation. The control above belongs to that investigation.
- pixexid/bb-collab#736 tracks downstream recovery work. Its data was not available.
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
- No open pull request was linked to #2532. This report has no PR review.
- The issue omits the original identifiers, provider transcript, and downstream database.
- The route test used a controlled 250 ms delay. It proves one matching sequence.
- The test did not start a paid provider turn.
- This defect is not visual. This report has no screenshot.
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.
| Case | Queued closure tool, both runs | After gate release, both runs | Meaning |
|---|---|---|---|
| Eligible project, delayed ownership | Absent | Present in newly built command; original queued command unchanged | Bounded ordering gap reproduced |
| Eligible project, launch metadata | Present | Present | Working current first-start control |
| Project ineligible, launch metadata | Absent | Absent | Eligibility restriction honored |
| Plugin disabled, launch metadata | Absent | Absent; configuration callback never ran | Disabled 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.
| Run | Focused cases | Existing plugin-tool tests | Final result |
|---|---|---|---|
| First clean checkout, same agent | 4 passed | 21 passed | 25 passed, 2 files; 19.36 seconds Vitest duration |
| Second clean checkout, same agent | 4 passed | 21 passed | 25 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
- Creation emits thread.created after creating the row. Lifecycle delivery is fire-and-forget on setImmediate; handler completion is not a readiness barrier.
- Runtime configuration resolves the plugin selection when constructing the command. Plugin configuration receives persisted launch metadata and filters tools to the returned selection. The synthetic handler has not recorded ownership yet in the delayed case.
- The start path builds and dispatches that command. Later selection does not retroactively change the captured payload. This verifies the server boundary, not daemon mounting or provider session refresh.
- The creation contract includes pluginMetadata, and Creation passes the metadata into the row. The seeded case directly verifies this working path.
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.