Repository navigation
Fix env-daemon CI flakes - #1
Merged
Merged
Conversation
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>
5 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Validation