← reports

#2923 · Path plugin version after reload

Bug Low Effort: Low plugins open on GitHub 2026-09-02 · base eeaaa3e8d

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

ClaimStatusEvidence
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

4. Minimal reproduction

  1. Check out the base commit.
  2. Copy the saved test to apps/server/test/services/plugins/plugin-version-reload.test.ts.
  3. Run the repository installation and build.
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
  4. 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.