#2923 · Path plugin version after reload
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
A path plugin can load a new manifest version after a successful reload.
The plugin list still returns the version that installation stored in the database.
The list already has the loaded manifest, but it does not use that manifest for the version field.
Two clean checks received 0.1.0 when the loaded manifest specified 0.2.0.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| A successful path plugin reload can leave an old version in the plugin list. | Verified | The focused test failed in two clean checkouts. Both runs received 0.1.0 instead of 0.2.0. |
| The runtime reads the current manifest during reload. | Verified | loadOne reads the manifest before it creates the new loaded plugin. |
| A new path installation refreshes the stored version. | Verified | The registration path writes manifest.version into the installed plugin row. |
3. Environment
- Trusted repository:
get-bb/bb. - Base commit:
eeaaa3e8db7b3aeb3c4ab46873816c84cb6ea513. - Host: macOS Darwin 25.6.0 on arm64.
- Node:
v22.22.3. pnpm:9.15.0. Vitest:4.1.1. - The test used an in-memory SQLite database through the server test harness.
- No server port, provider, user data directory, or browser was necessary.
4. Minimal reproduction
- Check out the base commit.
- Copy the saved test to
apps/server/test/services/plugins/plugin-version-reload.test.ts. - Run the repository installation and build.
pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build
- Run the focused test.
cd apps/server pnpm exec vitest run test/services/plugins/plugin-version-reload.test.ts
Expected:
Test Files 1 passed (1) Tests 1 passed (1)
Actual in both clean checkouts:
AssertionError: expected '0.1.0' to be '0.2.0' Expected: "0.2.0" Received: "0.1.0" Test Files 1 failed (1) Tests 1 failed (1)
Reproduction test: plugin-version-reload.test.ts.
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
createTestAppHarness,
type TestAppHarness,
} from "../../helpers/test-app.js";
describe("path plugin version reload", () => {
let harness: TestAppHarness;
let rootDir: string;
async function writeManifest(version: string): Promise<void> {
await writeFile(
join(rootDir, "package.json"),
JSON.stringify({
name: "bb-plugin-versioned",
version,
bb: {
name: "Versioned",
description: "Version reload fixture.",
branding: { icon: "Zap" },
server: "./server.ts",
},
}),
);
}
beforeEach(async () => {
harness = await createTestAppHarness();
rootDir = join(harness.config.dataDir, "fixtures", "bb-plugin-versioned");
await mkdir(rootDir, { recursive: true });
await writeManifest("0.1.0");
await writeFile(rootDir + "/server.ts", "export default function plugin() {}");
await harness.pluginService.installPath(rootDir);
});
afterEach(async () => {
await harness.cleanup();
});
it("lists the manifest version loaded by a successful reload", async () => {
await writeManifest("0.2.0");
const outcome = await harness.pluginService.reload("versioned");
expect(outcome.ok).toBe(true);
expect(
harness.pluginService.list().find((entry) => entry.id === "versioned")
?.version,
).toBe("0.2.0");
});
});
5. Root cause
The installation path stores the manifest version in the installed plugin row.
See plugin-registration.ts lines 341–355.
upsertInstalledPlugin(deps.db, {
...
version: manifest.version,
...
});
A reload gets that stored row and passes it to loadOne.
See plugin-service.ts lines 1670–1686.
const rows = listInstalledPlugins(deps.db); ... const problem = await withLifecycleLock(row.id, () => loadOne(row)); ... const plugins = list();
loadOne reads the current manifest and puts it in the loaded plugin map after a successful load.
See plugin-runtime.ts lines 1223–1273 and lines 1525–1556.
The list gets that loaded plugin, but it always returns row.version.
See plugin-service.ts lines 1176–1196.
const loadedPlugin = loaded.get(row.id); ... version: row.version,
This last selection causes the result. The list mixes live manifest fields with a stored version.
6. Proposed fix
Use loadedPlugin?.manifest.version ?? row.version for the list version.
This selection shows the successful runtime version. It keeps the stored version when no plugin instance is loaded.
A failed reload keeps the prior instance. Therefore, it also keeps that instance's matching version.
The focused test must pass. The existing plugin service and reload route suites must also pass.
7. Related issues
The review found no linked pull request and no direct related issue.
8. Verification
The same agent ran the focused test in two clean checkouts at the exact base commit.
Each checkout used a new in-memory database and a new temporary plugin directory.
Both commands failed at the version assertion. Each command received 0.1.0 instead of 0.2.0.
The second run required no report correction.
9. Appendix
Commands
git fetch origin main git rev-parse origin/main pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build cd apps/server pnpm exec vitest run test/services/plugins/plugin-version-reload.test.ts
Trusted data note
The issue content was untrusted. The investigation did not run its commands, code, links, branches, or attachments.
The test and root-cause analysis came from the trusted base repository.