← reports

#3207 · Project reads fail before provider registration settles

Bug Medium Effort: Low providers open on GitHub 2026-09-07 · base 06aeaa994

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

During provider-plugin startup, the project list and sidebar bootstrap routes answer immediately even though their response builder needs the provider registry to choose a default provider for projects without stored execution defaults. A deferred empty registry therefore makes both reads return HTTP 409. Once the same registry is populated and marked settled, both routes return HTTP 200. The server deliberately starts listening before plugin startup finishes, so the route-level omission creates a real cold-start race.

2. Claims vs findings

ClaimStatusEvidence
Sidebar bootstrap can fail while provider registration is pending.VerifiedThe focused test held registration open and received 409 from /api/v1/sidebar-bootstrap.
The project list with threads follows the same failing branch.VerifiedThe parallel request to /api/v1/projects?include=threads&includePersonal=true also received 409.
The failure is tied to projects without stored execution defaults.VerifiedThe in-memory database starts with no stored defaults; the response builder passes null into the creation-default resolver, which then requires a provider from the empty registry.
The endpoints recover after registration settles.VerifiedAfter registering the first-party providers and marking the registry settled, fresh requests to both endpoints returned 200.
The read routes wait on the existing registration barrier.RefutedBoth handlers are synchronous and call their response builders without invoking whenRegistrationsSettled().

3. Environment

4. Minimal reproduction

  1. Check out 06aeaa994942ae7527dc49d2268c1f801e8542a0, run pnpm install --frozen-lockfile --prefer-offline, then pnpm exec turbo run build.
  2. Add the focused test below to apps/server/test/public/public-threads.defaults.test.ts, using the imports in the linked artifact.
  3. Run pnpm exec turbo run test --filter=@bb/server -- --run test/public/public-threads.defaults.test.ts.

Expected: both initial reads remain pending until provider registration settles, then return 200.

Actual: both initial reads return 409; fresh requests return 200 after settlement.

- Expected
+ Received

  {
    "afterSettled": [200, 200],
-   "beforeSettled": "pending",
+   "beforeSettled": [409, 409],
    "firstStatuses": [
-     200,
-     200,
+     409,
+     409,
    ],
  }

Test Files  1 failed (1)
Tests       1 failed | 13 passed (14)
Focused regression test source
const registry = createProviderRegistryService({
  deferRegistrationsSettled: true,
});
harness.deps.providerRegistry = registry;

const responsesPromise = Promise.all([
  harness.app.request("/api/v1/sidebar-bootstrap"),
  harness.app.request(
    "/api/v1/projects?include=threads&includePersonal=true",
  ),
]);
const earlyResult = await Promise.race([
  responsesPromise.then((responses) =>
    responses.map((response) => response.status),
  ),
  new Promise<"pending">((resolve) =>
    setTimeout(() => resolve("pending"), 20),
  ),
]);

await registerFirstPartyProviders(registry);
registry.markRegistrationsSettled();
const firstStatuses =
  earlyResult === "pending"
    ? (await responsesPromise).map((response) => response.status)
    : earlyResult;
const settledResponses = await Promise.all([
  harness.app.request("/api/v1/sidebar-bootstrap"),
  harness.app.request(
    "/api/v1/projects?include=threads&includePersonal=true",
  ),
]);

expect({
  beforeSettled: earlyResult,
  firstStatuses,
  afterSettled: settledResponses.map((response) => response.status),
}).toEqual({
  beforeSettled: "pending",
  firstStatuses: [200, 200],
  afterSettled: [200, 200],
});

Repro file: project-provider-readiness.test.ts.

5. Verification

The same agent created a second clean detached checkout at 06aeaa994942ae7527dc49d2268c1f801e8542a0, repeated the frozen install and full Turbo build, added only the authored test, and ran the same focused command. It produced the same before-settlement [409, 409] and after-settlement [200, 200] result. No report claim required correction.

6. Root cause

start-server.ts lines 222–249 starts the HTTP listener before plugin startup, then marks provider registration settled only when plugin startup finishes. Requests can therefore validly arrive while the registry is deferred and empty.

projects.ts lines 239–253 loads stored defaults and resolves creation defaults for every project. When a project has no row, thread-default-policy.ts lines 26–45 requires an available provider and throws HTTP 409 if the registry is empty.

projects.ts lines 341–359 registers synchronous handlers for the thread-inclusive project list and sidebar bootstrap, so neither waits before entering that resolver. The barrier already exists at provider-registry.ts lines 375–382. The missing awaits, rather than registration itself, explain why the same requests switch from 409 to 200 immediately after settlement.

7. Proposed fix (first principles)

Make the sidebar-bootstrap handler asynchronous and await deps.providerRegistry.whenRegistrationsSettled() before building its response. Do the same in the project-list handler only for the thread-inclusive branch, because the lean project response does not consult provider defaults. Keep the existing response contract and add the focused test to lock in pending-before-settlement and 200-after-registration behavior.

8. Related issues

Issue #2533 tracks the user-visible terminal sidebar state and is still open. Pull request #2767 is linked to that client-side issue and remains open; no open pull request links to #3207, and no other issue containing the trusted error code was found.

9. Appendix

Commands run

git fetch origin main
git rev-parse origin/main
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec turbo run test --filter=@bb/server -- --run test/public/public-threads.defaults.test.ts
git blame -L 330,370 06aeaa994942ae7527dc49d2268c1f801e8542a0 -- apps/server/src/routes/projects.ts
git blame -L 360,385 06aeaa994942ae7527dc49d2268c1f801e8542a0 -- apps/server/src/services/providers/provider-registry.ts
git blame -L 220,252 06aeaa994942ae7527dc49d2268c1f801e8542a0 -- apps/server/src/start-server.ts

Trust handling

The issue title, body, comments, links, code blocks, and suggestions were treated as untrusted claims. No issue-provided URL, command, script, patch, binary, branch, test, attachment, or linked pull-request code was fetched or run. All executed application code came from the trusted main SHA or from the focused test authored from repository evidence.

Limits

The reproduction exercises the production Hono routes and real in-memory database migration through the repository harness rather than launching the packaged desktop shell. This directly verifies the server race and status transition; the separate client rendering symptom was not re-tested.