Skip to content

Fix env-daemon CI flakes - #1

Merged
ymichael merged 2 commits into
mainfrom
codex/env-daemon-ci-green
Mar 18, 2026
Merged

ymichael merged 2 commits into
mainfrom
codex/env-daemon-ci-green

Conversation

@ymichael

Copy link
Copy Markdown
Collaborator

Summary

  • bind env-daemon RPCs to the intended provider child instead of the mutable active child
  • stub provider model listing in the env-daemon runtime test so CI does not require a real codex CLI

Validation

  • pnpm exec turbo run typecheck --filter=@bb/environment-daemon
  • pnpm exec turbo run test --filter=@bb/environment-daemon

@ymichael
ymichael merged commit a229058 into main Mar 18, 2026
1 check passed
@ymichael
ymichael deleted the codex/env-daemon-ci-green branch March 18, 2026 02:49
SawyerHood pushed a commit that referenced this pull request Jul 30, 2026
## The problem

When you open a thread through bb connect, your browser has to talk to
your laptop through a long pipe that runs out to Cloudflare and back.
Every single question your browser asks down that pipe takes about **95
milliseconds** to get an answer, no matter how small the answer is.
That's just how long the trip takes.

So if you ask two questions one after the other, you wait ~190 ms. If
you ask them at the same time, you wait ~95 ms. The number of questions
matters way more than how big the answers are.

Opening a thread used to ask more questions than it needed to, and it
asked some of them in a line when they could have been asked all at
once.

## What this changes

**1. Stop waiting in line.** Loading a thread needs two things: the
thread itself, and its timeline. Neither one needs the other. But the
code asked for the thread, waited for the whole answer to come back, and
only then asked for the timeline. Now both go out at the same time.

**2. Stop asking twice.** Right after loading the thread, the app
immediately asked for the same thread again. Now it skips the second ask
if the first one just answered (within 5 seconds).

**3. Stop asking for things nobody looked at.** There's a "Parent"
dropdown in the thread sidebar. The app fetched the list of possible
parent threads on every thread open — even though most people never open
that dropdown. Now it only fetches when you actually click it. You'll
see a brief "Loading threads…" the first time, and a retry option if it
fails.

**4. Let answers be zipped.** The pipe had a rule that said "don't send
me compressed data." That rule made your laptop unzip everything before
pushing it through the pipe, so we shipped big uncompressed answers when
small zipped ones would do. That rule is gone.

## Did it actually work?

Yes for #1, barely for #4. I tested this against the real production
Connect — real Cloudflare, real tunnel, not a simulation.

**Asking both questions at once (change #1):**

| | how long |
|---|---:|
| Before (one after the other) | **255 ms** |
| After (both at once) | **141 ms** |

That's **114 ms faster, about 45%**. This is the change that matters.

**Zipping the answers (change #4):** barely moves the needle, ~0–10 ms.

The reason is boring: the answers are already small. Earlier work capped
how much timeline gets sent at once, so even the biggest threads on my
machine only produce 15–61 KB. Zipping shrinks that by 3–4×, which
sounds great, but the pipe moves data fast enough (~18 MB/s) that 40 KB
was never the problem. The 95 ms trip was.

Zipping still earns its place in two spots, though:
- On a slow connection (say hotel wifi at 10 Mbps), that same 40 KB *is*
worth ~35 ms per request.
- It turns the app's precompressed asset files back on. Those had been
silently disabled over Connect this whole time by the "don't send me
compressed data" rule, and they're much bigger than any API response.

**Bottom line: don't expect to feel the zipping on fast internet. Do
expect thread opens to feel snappier.**

## Things that will look different

- **A new "Parent: None" row.** If you have a project with only one
thread, the Parent row used to be hidden — the app knew there was
nothing to pick. Now it can't know that without asking, and asking is
the thing we're trying to avoid. So the row is always there for root
threads.
- **The Parent dropdown can be tabbed to** in a couple of cases where it
previously couldn't.
- **Re-opening a thread you've seen before** now asks "what's new since
X?" instead of "give me everything," and falls back to asking for
everything if the server can't answer that.

## The part to be careful about

To let zipped data through the pipe, I rewrote the piece of code that
forwards requests, swapping `fetch` for Node's lower-level HTTP client.
This touches **every** thing that goes through the tunnel — including
any dev server you've shared with `bb connect expose <port>`, not just
BB itself. It's the riskiest part of this PR.

Things that could have broken, and why they didn't:

- **Garbled responses.** Raw headers now get forwarded as-is, which is
dangerous if a "this data is chunked" header rides along with data
that's already been un-chunked. The relay already throws those headers
away (`apps/connect/src/tunnel-do.ts:44`), so it can't happen.
- **Wrong length.** A "this is 500 bytes" header on a body that isn't
500 bytes would break things. Checked both places that set it — they
always match what actually gets sent.
- **Redirects.** The old code told `fetch` not to follow them. Node's
client doesn't follow them anyway. Same behavior.
- **Reused memory.** The new code hands out views into Node's internal
buffer. Safe here, because it's copied out synchronously before Node can
reuse it.

No daemon protocol version bump needed — the wire format didn't change,
no worker code changed, and the already-deployed relay handles these
headers fine.

**One thing tests can't cover:** whether Cloudflare's runtime passes a
zipped response through untouched. I confirmed it by hand (step 2
below). Running it locally uses the same runtime but skips Cloudflare's
CDN layer, so local testing isn't a full substitute.

## How to check it yourself

Node 22, pnpm 9, clean checkout:

```bash
pnpm install --offline --frozen-lockfile
pnpm run ensure-native-modules
pnpm exec turbo run test typecheck --filter=@bb/tunnel-client --filter=bb-plugin-connect --force
pnpm exec turbo run test --filter=@bb/app --force -- src/hooks/cache-owners/cache-owner-registry.test.ts src/hooks/queries/thread-queries.test.tsx src/components/secondary-panel/ThreadMetadataContent.test.tsx src/views/thread-detail/ThreadDetailSecondaryContent.test.tsx
pnpm exec turbo run typecheck --filter=@bb/app --force
```

Then open DevTools → Network and load a thread through bb connect:

1. The thread and timeline requests start together, and the thread isn't
requested twice.
2. The timeline response has `content-encoding: gzip` and a
`server-timing: bb_connect_origin` header.
3. The Connect host log prints timing (`originTtfbMs`, `originBodyMs`,
`responseBytes`, `contentEncoding`).
4. Nothing requests parent candidates until you click **Parent**. First
click shows "Loading threads…", then the list.
5. A root thread in a project with no other threads shows the **Parent:
None** row.

## Tests

All green. Tunnel client 6/6, connect plugin 65/65, four focused app
suites 28/28, plus typechecks. The full GitHub matrix passes — app,
server, integration, packages, checks, and macOS/Linux smoke.

The new tests are the interesting ones. They check that the timeline
request really does leave before the thread request comes back, that a
warm cache asks for a delta and merges the answer correctly, that the
duplicate thread request is skipped when fresh but still happens when
stale, and that zipped bytes cross the tunnel byte-for-byte identical.
SawyerHood added a commit that referenced this pull request Aug 10, 2026
…lection listeners (#1285)

Follow-up #1 from the #1269 tap-freeze diagnosis (primary fix: #1281).
Two re-render/tap amplifiers on the thread timeline.

## Root cause

**1. Every realtime event re-rendered every timeline row.**
`useSenderThreadMetadataById` rebuilt a fresh `Map` on every matching
query-cache event with no equality check, and its predicate also fired
on `observerResultsUpdated` (observer churn with no data change). The
map fed `TimelineRendererStaticContext`, and context identity changes
bypass `React.memo` — so every `sidebarNavigation`/`threads`/`thread`
cache event re-rendered all rows of the un-virtualized timeline, keeping
style/layout dirty and amplifying any main-thread work on mobile.

**2. Per-tap handler work was O(N messages).** Each
`SelectableMessageProse` (one per assistant message) registered 6
document-level listeners, so every tap and every `selectionchange`
dispatched N sets of handlers and scheduled up to N rAF callbacks.

## Fix

Commit 1 (`useSenderThreadMetadataById` + `ThreadTimelineRows`):
- The hook keeps the previous map reference when the rebuild is
value-equal (all metadata fields are primitives, so shallow per-entry
compare is exact).
- The sync predicate drops `observerResultsUpdated` — the map is built
exclusively from `query.state.data`, which only changes via `updated`
dispatches.
- The map moves out of the wide static context into a dedicated
`SenderThreadMetadataContext`, so a *genuine* metadata change (any
thread title anywhere in the cache) re-renders only the conversation
rows that resolve sender labels, not every row.

Commit 2 (`SelectableMessageProse`):
- One module-level registry owns a single shared set of 6 document
listeners and one shared report frame. Each instance keeps its own
selection state machine (the `hadSelection` null-once gate,
pending/last-release anchors, multi-click timer) and its node-scoped
`click`/`dblclick` listeners. An instance inside its multi-click delay
is skipped by shared report passes and reports when its own timer fires,
preserving double/triple-click semantics.

## Measurement

React Profiler over a 21-row timeline in jsdom, 10 value-equal `threads`
cache events (the shape realtime refetches produce):

| | full-timeline commits | React update work |
|---|---|---|
| before | 11 | ~136 ms |
| after | **0** | **0 ms** |

Document listeners for N assistant messages: before 6·N, after 6 total.

## Validation

- `pnpm exec turbo run typecheck --filter=@bb/app` — pass
- `pnpm exec turbo run test --filter=@bb/app --force` — 328 files, 2472
tests, all pass (including the 51 pre-existing selection/timeline
behavior tests, unchanged)
- `pnpm exec turbo run lint --filter=@bb/app` — pass
- New regression tests were each verified to fail against the pre-fix
code:
- `useSenderThreadMetadataById.test.tsx` — stable reference on equal
rebuilds, new reference + value on real changes, stability across
unrelated query keys
- `ThreadTimelineRows.render-stability.test.tsx` — profiler-counted zero
commits on equal cache events; sender title updates flow through to the
DOM
- `SelectableMessageProse.events.test.tsx` — mounting additional
messages adds zero document listeners; a selection moving between two
messages clears the old one exactly once

Timeline virtualization remains explicitly out of scope.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
amadad pushed a commit to amadad/bb that referenced this pull request Aug 14, 2026
## The problem

When you open a thread through bb connect, your browser has to talk to
your laptop through a long pipe that runs out to Cloudflare and back.
Every single question your browser asks down that pipe takes about **95
milliseconds** to get an answer, no matter how small the answer is.
That's just how long the trip takes.

So if you ask two questions one after the other, you wait ~190 ms. If
you ask them at the same time, you wait ~95 ms. The number of questions
matters way more than how big the answers are.

Opening a thread used to ask more questions than it needed to, and it
asked some of them in a line when they could have been asked all at
once.

## What this changes

**1. Stop waiting in line.** Loading a thread needs two things: the
thread itself, and its timeline. Neither one needs the other. But the
code asked for the thread, waited for the whole answer to come back, and
only then asked for the timeline. Now both go out at the same time.

**2. Stop asking twice.** Right after loading the thread, the app
immediately asked for the same thread again. Now it skips the second ask
if the first one just answered (within 5 seconds).

**3. Stop asking for things nobody looked at.** There's a "Parent"
dropdown in the thread sidebar. The app fetched the list of possible
parent threads on every thread open — even though most people never open
that dropdown. Now it only fetches when you actually click it. You'll
see a brief "Loading threads…" the first time, and a retry option if it
fails.

**4. Let answers be zipped.** The pipe had a rule that said "don't send
me compressed data." That rule made your laptop unzip everything before
pushing it through the pipe, so we shipped big uncompressed answers when
small zipped ones would do. That rule is gone.

## Did it actually work?

Yes for get-bb#1, barely for get-bb#4. I tested this against the real production
Connect — real Cloudflare, real tunnel, not a simulation.

**Asking both questions at once (change get-bb#1):**

| | how long |
|---|---:|
| Before (one after the other) | **255 ms** |
| After (both at once) | **141 ms** |

That's **114 ms faster, about 45%**. This is the change that matters.

**Zipping the answers (change get-bb#4):** barely moves the needle, ~0–10 ms.

The reason is boring: the answers are already small. Earlier work capped
how much timeline gets sent at once, so even the biggest threads on my
machine only produce 15–61 KB. Zipping shrinks that by 3–4×, which
sounds great, but the pipe moves data fast enough (~18 MB/s) that 40 KB
was never the problem. The 95 ms trip was.

Zipping still earns its place in two spots, though:
- On a slow connection (say hotel wifi at 10 Mbps), that same 40 KB *is*
worth ~35 ms per request.
- It turns the app's precompressed asset files back on. Those had been
silently disabled over Connect this whole time by the "don't send me
compressed data" rule, and they're much bigger than any API response.

**Bottom line: don't expect to feel the zipping on fast internet. Do
expect thread opens to feel snappier.**

## Things that will look different

- **A new "Parent: None" row.** If you have a project with only one
thread, the Parent row used to be hidden — the app knew there was
nothing to pick. Now it can't know that without asking, and asking is
the thing we're trying to avoid. So the row is always there for root
threads.
- **The Parent dropdown can be tabbed to** in a couple of cases where it
previously couldn't.
- **Re-opening a thread you've seen before** now asks "what's new since
X?" instead of "give me everything," and falls back to asking for
everything if the server can't answer that.

## The part to be careful about

To let zipped data through the pipe, I rewrote the piece of code that
forwards requests, swapping `fetch` for Node's lower-level HTTP client.
This touches **every** thing that goes through the tunnel — including
any dev server you've shared with `bb connect expose <port>`, not just
BB itself. It's the riskiest part of this PR.

Things that could have broken, and why they didn't:

- **Garbled responses.** Raw headers now get forwarded as-is, which is
dangerous if a "this data is chunked" header rides along with data
that's already been un-chunked. The relay already throws those headers
away (`apps/connect/src/tunnel-do.ts:44`), so it can't happen.
- **Wrong length.** A "this is 500 bytes" header on a body that isn't
500 bytes would break things. Checked both places that set it — they
always match what actually gets sent.
- **Redirects.** The old code told `fetch` not to follow them. Node's
client doesn't follow them anyway. Same behavior.
- **Reused memory.** The new code hands out views into Node's internal
buffer. Safe here, because it's copied out synchronously before Node can
reuse it.

No daemon protocol version bump needed — the wire format didn't change,
no worker code changed, and the already-deployed relay handles these
headers fine.

**One thing tests can't cover:** whether Cloudflare's runtime passes a
zipped response through untouched. I confirmed it by hand (step 2
below). Running it locally uses the same runtime but skips Cloudflare's
CDN layer, so local testing isn't a full substitute.

## How to check it yourself

Node 22, pnpm 9, clean checkout:

```bash
pnpm install --offline --frozen-lockfile
pnpm run ensure-native-modules
pnpm exec turbo run test typecheck --filter=@bb/tunnel-client --filter=bb-plugin-connect --force
pnpm exec turbo run test --filter=@bb/app --force -- src/hooks/cache-owners/cache-owner-registry.test.ts src/hooks/queries/thread-queries.test.tsx src/components/secondary-panel/ThreadMetadataContent.test.tsx src/views/thread-detail/ThreadDetailSecondaryContent.test.tsx
pnpm exec turbo run typecheck --filter=@bb/app --force
```

Then open DevTools → Network and load a thread through bb connect:

1. The thread and timeline requests start together, and the thread isn't
requested twice.
2. The timeline response has `content-encoding: gzip` and a
`server-timing: bb_connect_origin` header.
3. The Connect host log prints timing (`originTtfbMs`, `originBodyMs`,
`responseBytes`, `contentEncoding`).
4. Nothing requests parent candidates until you click **Parent**. First
click shows "Loading threads…", then the list.
5. A root thread in a project with no other threads shows the **Parent:
None** row.

## Tests

All green. Tunnel client 6/6, connect plugin 65/65, four focused app
suites 28/28, plus typechecks. The full GitHub matrix passes — app,
server, integration, packages, checks, and macOS/Linux smoke.

The new tests are the interesting ones. They check that the timeline
request really does leave before the thread request comes back, that a
warm cache asks for a delta and merges the answer correctly, that the
duplicate thread request is skipped when fresh but still happens when
stale, and that zipped bytes cross the tunnel byte-for-byte identical.
amadad pushed a commit to amadad/bb that referenced this pull request Aug 14, 2026
…lection listeners (get-bb#1285)

Follow-up get-bb#1 from the get-bb#1269 tap-freeze diagnosis (primary fix: get-bb#1281).
Two re-render/tap amplifiers on the thread timeline.

## Root cause

**1. Every realtime event re-rendered every timeline row.**
`useSenderThreadMetadataById` rebuilt a fresh `Map` on every matching
query-cache event with no equality check, and its predicate also fired
on `observerResultsUpdated` (observer churn with no data change). The
map fed `TimelineRendererStaticContext`, and context identity changes
bypass `React.memo` — so every `sidebarNavigation`/`threads`/`thread`
cache event re-rendered all rows of the un-virtualized timeline, keeping
style/layout dirty and amplifying any main-thread work on mobile.

**2. Per-tap handler work was O(N messages).** Each
`SelectableMessageProse` (one per assistant message) registered 6
document-level listeners, so every tap and every `selectionchange`
dispatched N sets of handlers and scheduled up to N rAF callbacks.

## Fix

Commit 1 (`useSenderThreadMetadataById` + `ThreadTimelineRows`):
- The hook keeps the previous map reference when the rebuild is
value-equal (all metadata fields are primitives, so shallow per-entry
compare is exact).
- The sync predicate drops `observerResultsUpdated` — the map is built
exclusively from `query.state.data`, which only changes via `updated`
dispatches.
- The map moves out of the wide static context into a dedicated
`SenderThreadMetadataContext`, so a *genuine* metadata change (any
thread title anywhere in the cache) re-renders only the conversation
rows that resolve sender labels, not every row.

Commit 2 (`SelectableMessageProse`):
- One module-level registry owns a single shared set of 6 document
listeners and one shared report frame. Each instance keeps its own
selection state machine (the `hadSelection` null-once gate,
pending/last-release anchors, multi-click timer) and its node-scoped
`click`/`dblclick` listeners. An instance inside its multi-click delay
is skipped by shared report passes and reports when its own timer fires,
preserving double/triple-click semantics.

## Measurement

React Profiler over a 21-row timeline in jsdom, 10 value-equal `threads`
cache events (the shape realtime refetches produce):

| | full-timeline commits | React update work |
|---|---|---|
| before | 11 | ~136 ms |
| after | **0** | **0 ms** |

Document listeners for N assistant messages: before 6·N, after 6 total.

## Validation

- `pnpm exec turbo run typecheck --filter=@bb/app` — pass
- `pnpm exec turbo run test --filter=@bb/app --force` — 328 files, 2472
tests, all pass (including the 51 pre-existing selection/timeline
behavior tests, unchanged)
- `pnpm exec turbo run lint --filter=@bb/app` — pass
- New regression tests were each verified to fail against the pre-fix
code:
- `useSenderThreadMetadataById.test.tsx` — stable reference on equal
rebuilds, new reference + value on real changes, stability across
unrelated query keys
- `ThreadTimelineRows.render-stability.test.tsx` — profiler-counted zero
commits on equal cache events; sender title updates flow through to the
DOM
- `SelectableMessageProse.events.test.tsx` — mounting additional
messages adds zero document listeners; a selection moving between two
messages clears the old one exactly once

Timeline virtualization remains explicitly out of scope.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
SawyerHood added a commit that referenced this pull request Aug 19, 2026
## What was wrong

The host daemon is supposed to keep the native `@parcel/watcher` addon
out of the parent process (parcel runs in a forked child). The
in-process fallback in
`packages/host-watcher/src/parcel-watcher-backend.ts` reached the addon
through `await import("./real-parcel-watcher.js")`. esbuild inlines a
dynamic import of an internal module and hoists that module's static
`import parcelWatcher from "@parcel/watcher"` to the top level of
`dist/daemon-bundle.mjs`. The daemon therefore loaded `watcher.node`
(built with `NAPI_DISABLE_CPP_EXCEPTIONS`) before `pty.node`
(`NAPI_CPP_EXCEPTIONS`) at startup. On macOS, dyld coalesces the
identical weak `Napi::details::CallbackData<...>::Wrapper` symbol across
images to watcher.node's try/catch-less copy. When `PtyFork` throws
`Napi::Error` on a failed spawn, nothing catches it, `std::terminate`
runs, and the app dies with SIGABRT.

Issue: #1873. Investigation report:
https://get-bb.github.io/reports/issues/1873.html

## What changed

- `packages/host-watcher/src/parcel-watcher-backend.ts`: the in-process
fallback now does `const { default: parcelWatcher } = await
import("@parcel/watcher")`. A dynamic import of the external package
stays a real `import("@parcel/watcher")` in the esbuild ESM bundle, so
`watcher.node` loads only when a watcher is needed in-process.
- `packages/host-watcher/src/real-parcel-watcher.ts`: kept for the
forked watcher child entry; its comment now explains why daemon-bundle
code must not dynamically import it.
- `apps/host-daemon/test/parcel-watcher-not-loaded-in-parent.test.ts`
(new): bundles `@bb/host-watcher` with the daemon bundle's esbuild
settings, then asserts (1) the output has no static top-level
`@parcel/watcher` import and still has a dynamic
`import("@parcel/watcher")`, and (2) importing that bundle in a child
`node` with a `process.dlopen` hook loads no `@parcel/watcher` addon.

No wire shapes changed, so `HOST_DAEMON_PROTOCOL_VERSION` was not
bumped. No CLI/guide/doc surfaces are affected.

## How you verified

**The new test fails before the fix** (both assertions, for the right
reason):

```
 ❯  @bb/host-daemon  test/parcel-watcher-not-loaded-in-parent.test.ts (2 tests | 2 failed) 175ms
     × does not hoist a static import of @parcel/watcher 36ms
     × does not dlopen a @parcel/watcher addon when the bundle is imported 137ms

AssertionError: static top-level import(s) of @parcel/watcher found in the bundle; watcher.node would load at daemon startup:
import parcelWatcher from "@parcel/watcher";: expected [ Array(1) ] to be null

AssertionError: @parcel/watcher addon loaded on import:
[dlopen] <repo>/node_modules/.pnpm/@parcel+watcher-linux-x64-glibc@2.5.6/node_modules/@parcel/watcher-linux-x64-glibc/watcher.node: expected [ Array(1) ] to deeply equal []
```

**After the fix:**

```
 Test Files  1 passed (1)
      Tests  2 passed (2)
```

**Real daemon bundle, rebuilt with the fix** (`pnpm exec turbo run build
--filter=@bb/host-daemon`), then run with the report's `process.dlopen`
logger and `NODE_PATH` unset:

```
$ grep -c 'from"@parcel/watcher"' dist/daemon-bundle.mjs
0
$ node --require log-dlopen.cjs dist/daemon-bundle.mjs --definitely-not-a-flag
[dlopen #1] <repo>/node_modules/.pnpm/node-pty@1.1.0/node_modules/node-pty/build/Release/pty.node
[exit] code=1 addons loaded=1
```

Before the fix the same bundle contained `import VYr
from"@parcel/watcher"` and the report's
`daemon-bundle-addon-load-order.log` shows `watcher.node` as dlopen #1
and `pty.node` as #2.

**Package suites and typecheck:** `pnpm exec turbo run test typecheck
--filter=@bb/host-daemon --filter=@bb/host-watcher --force` → 5/5 tasks
successful; `@bb/host-watcher` 45 tests passed, `@bb/host-daemon` 585
tests passed.

**macOS was not re-run.** This was verified on Linux only. On Linux the
symptom differs (the report shows the same symbol coalescing produces a
SIGSEGV analog under `RTLD_GLOBAL`, and Node's default `RTLD_LOCAL`
dlopen avoids it), so the macOS SIGABRT itself was not reproduced here.
The fix is still correct because the crash requires `watcher.node` to be
resident in the daemon process before `pty.node` loads, and the root
cause of that is the static import in the bundle. The test and the
dlopen log above show that with this change the daemon process loads no
`@parcel/watcher` addon at startup, so dyld has no watcher.node copy of
the `Wrapper` symbol to coalesce with when node-pty's image loads;
node-pty then uses its own exception-aware copy, which catches the
`Napi::Error` as intended. The forked watcher child
(`bb-parcel-watcher-child.mjs`) still loads `watcher.node`, which is the
intended isolation.

Fixes #1873

> AGENT GENERATED: by Claude Opus 5

---------

Co-authored-by: Claude <noreply@anthropic.com>
SawyerHood added a commit that referenced this pull request Sep 30, 2026
## Human comments

## What was wrong

bb cloud (the `bb-ai` plugin, #4254) only served thread titles and
commit messages. Voice input had no hosted option, so a bb without a
Codex login had no microphone. The plan left voice as phase 2
(`plans/bb-account-and-hosted-generation.md`, Part 8). The bb side
already had everything voice needs: the plugin SDK's `transcribe` hook,
the Voice input row in Settings → AI services, the server's transcribe
route, `bb voice transcribe`, and the mic's readiness check. The missing
pieces were a hosted endpoint and bb-ai implementing `transcribe`.

## What changed

- **bb-ai-gateway** (`apps/ai-gateway`): new `POST
/api/ai/v1/transcribe`.
- Takes `{audio, format, hint}` with base64 audio, capped at 10 MB, and
forwards it to OpenRouter's `POST /api/v1/audio/transcriptions` with
`provider: {zdr: true, data_collection: "deny"}`.
- Metering is shared with `/complete` through one `handleMetered` path:
the same auth, rate limit, daily budget and request log. Transcription
reserves 2¢ per call and settles to the reported cost.
- `AI_TRANSCRIBE_MODELS` (new, required var, set in `wrangler.jsonc` for
production and staging) lists the models in order. OpenRouter's
transcription endpoint has no `models` fallback, so the gateway moves to
the next model when one answers with an HTTP error, all within an 8 s
deadline. It never retries after a timeout.
- The vocabulary `hint` goes to `provider.options.groq.prompt`, the only
hint option OpenRouter documents.
- Default models: `microsoft/mai-transcribe-2`, then
`openai/whisper-large-v3-turbo`. Both have ZDR endpoints.
- **bb-account**: `bb-account.v1.fetch` accepts request bodies up to 16
MB instead of 1 MB, since base64 recordings didn't fit. Responses stay
capped at 1 MB.
- **bb-ai**: registers `transcribe`.
- It sends the recording through `bb-account.v1.fetch` with a 10 s
timeout.
- It maps the MIME type or file extension to an OpenRouter format, so
Chrome's `audio/webm;codecs=opus` becomes `webm` and Safari's
`audio/mp4` becomes `m4a`.
- It refuses recordings over 10 MB or in unknown formats without
contacting getbb.app.
- The opt-in, sign-in and used-up-budget checks are shared with
`complete`.
- bb cloud now appears for Voice input, and Automatic tries Codex, then
bb cloud.
- **Copy and docs**: `bb ai` help, the plugin README, overview and
skill, `docs/configuration.md`, and the verify-bb feature map now cover
voice input and say recordings are never stored. (Merged with #4522,
which made bb cloud on by default and removed its settings page and
disclosure, and #4525, which deleted the plan doc. The model comparison
that was going into the plan is below.)

**Model choice** (OpenRouter transcription models with ZDR endpoints,
2026-09-29): `microsoft/mai-transcribe-2` (Azure, about $0.0017/min at a
launch price that ends 2026-12-31, #1 on FLEURS and #2 on Artificial
Analysis's word-error-rate board), `openai/whisper-large-v3-turbo`
(Groq/DeepInfra, $0.0002–0.0007/min, takes a vocabulary prompt on Groq),
and Voxtral Mini Transcribe, Deepgram Nova-3 and AssemblyAI
Universal-3.5 Pro. `openai/gpt-transcribe`,
`google/gemini-3.5-transcribe` and `x-ai/grok-stt-1.0` have no ZDR
endpoint.

- **Stale docs from #4522**: the `bb-cloud-ai` skill, plugin overview,
README, `docs/configuration.md` and the verify-bb feature map still
described the removed opt-in and settings page. They now say bb cloud is
on by default once you sign in, `bb ai off` turns it off, and disabling
the plugin also stops it. The skill also covers voice input: accepted
formats, the 10 MB limit, the 10 s timeout, and `bb voice transcribe`.

No server, daemon or SDK changes. No `HOST_DAEMON_PROTOCOL_VERSION`
bump.

**Known issue, not addressed here:** through OpenRouter, MAI-Transcribe
2 answers 400 for WebM and MP4 audio but accepts the same speech as Ogg
or MP3. Chrome and Safari recordings therefore get transcribed by
Whisper after one refused MAI call. The follow-up is either to put
Whisper first in `AI_TRANSCRIBE_MODELS` (config only) or to convert
recordings to Ogg before upload.

## How you verified

- `pnpm exec turbo run typecheck test lint --filter=@bb/ai-gateway
--filter=bb-plugin-bb-ai --filter=bb-plugin-bb-account`: pass (gateway
32 tests, bb-ai 28, bb-account 59).
- New gateway tests cover:
- the exact OpenRouter request, including ZDR and the Groq hint, and the
metering and request log, which never contains audio or text;
  - falling back on an HTTP error but not on a timeout;
  - charging nothing when every model refuses;
  - input validation (base64, the 10 MB cap, format, hint size);
- the 2¢ reserve, so a nearly spent budget still allows a title but not
a recording;
  - 401 without a credential;
- a Miniflare test that sends a 4 MB body through the real Workers
runtime and D1.
- New bb-ai tests cover the fetch payload and timeout, refusals that
send nothing (off, too large, unknown format), budget exhaustion from a
transcription answer, and MIME/extension mapping.
- Local end to end against real OpenRouter (`pnpm cloud:dev` with a key,
plus `pnpm dev` signed in to a local account, `bb ai on`, and `bb
settings ai-services set voice bb`):
- `bb voice transcribe` on WebM, MP3 and M4A clips returned correct
transcripts in 1.0–1.6 s. The gateway log showed MAI answering MP3 and
Whisper answering WebM and M4A.
  - `bb ai usage` counted the requests.
- After merging `main`: typecheck, test and lint pass again (bb-ai 24
tests, gateway 32, bb-account 59).
  - Recording with the mic in the app worked.

## Rollout

Deploy the gateway (**Deploy AI Gateway**) before releasing bb-ai. A
released bb-ai against an old gateway would show the mic and fail every
recording with a 404.

> AGENT GENERATED

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ymichael added a commit that referenced this pull request Oct 30, 2026
## Human comments

## What was wrong

Several cache owners wrote a server reply or the thread-detail bootstrap
straight into the React Query cache with `setQueryData` while a fetch of
the same key could still be in flight. React Query writes fetch results
without checking whether they're older than what's cached (query-core
`query.ts`), so the older fetch landed last and put stale data back.
Effects included: an environment or host status that stayed stale after
opening a thread, a plugin settings form resetting its draft to the old
value, a cancelled plugin install flipping back to running, new plugin
update jobs dropping out of the list, an `idle` app update phase
switching off the progress poll, and the AI service selection flickering
back. These are items #2–#10 from the direct cache write audit
(@thread:thr_fjr5x8dzaz). #1 (thread tabs) is fixed separately in #6611.

## What changed

Two fixes, depending on whether the data has a version field:

- **Environment and Host keep the newer copy by `updatedAt`** (#2, #3,
#10). The bootstrap ingest (`thread-detail-cache-owner.ts`), the rename
reply (`applyEnvironmentUpdateResult`), and the
`useEnvironment`/`useHosts` fetches all compare `updatedAt`. The
fetch-side check is what catches a fetch that started before the write
and lands after it. To make it testable, those fetches now live in
`environmentQueryOptions` and `hostsQueryOptions`. Host `status` comes
from live daemon sessions and a disconnect doesn't change `updatedAt`,
so on a tie the cached copy wins over the bootstrap. The bootstrap only
fills an empty cache or replaces strictly older data. Rename replies and
fetch results still win ties.
- **`setQueryDataOverInFlightFetch`** (`cache-effect-utils.ts`) for data
with no version (#4–#9): it writes the value, and if a fetch of the key
is in flight it cancels that fetch (the cache keeps the written value)
and refetches. With nothing in flight it's a plain write. Used by
`applyPluginSettingsView`, `applyPluginInstallJob`,
`applyPluginUpdateJob`, `hydrateAppUpdateStatus`,
`hydrateSystemVersionCache` and `writeCachedAiServices`, so every caller
gets the fix. Where the audit suggested an unconditional
cancel-write-invalidate (#7) or a cancel before the write (#8, #9), this
helper refetches only when a fetch was actually in flight. That avoids
an extra request on every write. Forced version and app-update checks
update the server's cache, so the trailing unforced refetch returns the
forced result.

No wire, CLI or plugin API changes.

Not covered here: audit items #11 (plugin safe mode) and #12 (prompt
history), both low risk. Also not covered: a lint rule restricting
direct `setQueryData` calls to the helper, which would stop new call
sites from bringing the race back.

## How you verified

- New tests use real `QueryClient`/`QueryObserver` instances: start a
fetch that resolves late with older data, do the write, let the fetch
land, then check that the newer value survives (and, for the helper,
that the trailing refetch runs).
- `thread-detail-cache-owner.test.ts` (new): late environment and
host-list fetches after a bootstrap, an older bootstrap over a newer
cache, and the equal-`updatedAt` host status case.
- `environment-workspace-cache-owner.test.ts`: a late environment fetch
after a rename, and an older rename reply over a newer cache.
- `in-flight-fetch-writes.test.ts` (new): one row per cache-owner write
for #4–#9, plus helper tests for the no-fetch case (no extra request)
and a fetch of an unobserved query.
- With each source change reverted, every race test failed on the stale
data and passed once the fix was restored.
- `pnpm exec turbo run typecheck lint test --filter=@bb/app`: passed
(647 files, 6515 tests; 0 lint errors, no new warnings). After rebasing
onto `0472409243` I re-ran the three changed test files (21 tests
passed).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

> AGENT GENERATED

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants