Patch sidebar rows from status-changed pushes instead of refetching - #2169
Merged
Conversation
SawyerHood
force-pushed
the
bb/fix-1302-sidebar-bootstrap
branch
from
August 21, 2026 07:38
9beae8b to
1e1e56f
Compare
SawyerHood
marked this pull request as ready for review
August 21, 2026 07:51
SawyerHood
force-pushed
the
bb/fix-1302-sidebar-bootstrap
branch
from
August 21, 2026 16:48
1e1e56f to
766f192
Compare
SawyerHood
marked this pull request as draft
August 21, 2026 17:12
SawyerHood
force-pushed
the
bb/fix-1302-sidebar-bootstrap
branch
from
August 21, 2026 17:18
766f192 to
1774612
Compare
SawyerHood
marked this pull request as ready for review
August 21, 2026 17:48
Every thread lifecycle transition broadcast a bare `status-changed` dirty flag, and the app's only possible response was to invalidate the single sidebarNavigation query plus every cached thread list. That is a full GET /api/v1/sidebar-bootstrap (about 1 KB per unarchived thread, 134 KB on the seeded database) twice per turn, for every thread that runs a turn. The notification now carries the post-transition row fields (status, runtime, activity, latestAttentionAt, updatedAt) as `metadata.statusChange`. The server builds it in one place (`buildThreadStatusChangeMetadata`), which also resolves the runtime from host connectivity and the status-gated plan-mode and goal activity with the same helpers as the list endpoints, so the db writer no longer notifies and `applyLoggedThreadLifecycleEvent` owns the push. The app patches the cached list rows and the sidebar bootstrap in place, restarts any list fetch already in flight so a stale snapshot cannot overwrite the patch, and keeps the refetch path only for pushes without the row (older servers, writers inside a transaction that buffer their notifications). While the document is hidden, a later `status-changed` that carries no row snapshot replaces (drops) an earlier one, so the resume flush falls back to the refetch instead of patching the row to a stale status. Server-to-app contract only; the host daemon does not consume thread change notifications, so HOST_DAEMON_PROTOCOL_VERSION is unchanged. Old clients ignore the new field (lenient inbound parse) and keep refetching; a client that does not know a future status value drops just the field and refetches. Fixes #1302 Co-Authored-By: Claude <noreply@anthropic.com>
SawyerHood
force-pushed
the
bb/fix-1302-sidebar-bootstrap
branch
from
August 21, 2026 22:03
1774612 to
e012365
Compare
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.
What was wrong
Every thread lifecycle transition broadcast
status-changedas a bare dirty flag (metadata carried at mostprojectId). The app's registry rule for it (dirtyActiveThreadListQueries, flushimmediate) invalidated the singlesidebarNavigationquery plus every cached thread list for the project. The sidebar query is always active (AppLayoutobserves it withstaleTime: Infinity), so each push re-downloaded the wholeGET /api/v1/sidebar-bootstrapdocument: about 1 KB per unarchived thread, 134 KB on the seeded database, twice per turn (turn start, turn end), for every thread that runs a turn. Nothing in the push let the client patch the one row that changed. Issue: #1302. Report: https://get-bb.github.io/reports/issues/1302.htmlWhat changed
Server-to-app realtime contract (no daemon change; the host daemon does not consume thread change notifications, so
HOST_DAEMON_PROTOCOL_VERSIONis unchanged):packages/domain/src/change-kinds.ts:threadChangeMetadataSchemagains optionalstatusChange: { status, runtime, activity, latestAttentionAt, updatedAt }(the list-row fields a lifecycle transition rewrites). The lenient inbound twin parses it with.catch(undefined)so a client that does not know a future status or runtime value drops just that field and falls back to a refetch.threadRuntimeStateSchemaandthreadActivityStateSchemaare now exported fromthread.ts.apps/server/src/services/threads/thread-runtime-display.ts:buildThreadStatusChangeMetadata(deps, thread)builds the metadata in one place. Runtime is resolved from host connectivity the same way list rows do. Activity (background task counts plus the plan-mode and goal counts) is built by the newbuildThreadActivityStateByThreadId, whichtoThreadListEntryResponsesnow also uses, so a pushed row and a fetched row cannot disagree. The builder therefore takes the prompt-banner deps (db,hub,providerRegistry); every caller already had them throughAppDeps/WorkSessionDeps(failThreadProvisioningandapplyTurnCompletedEventwiden theirPick).packages/db/src/data/threads.ts:applyThreadLifecycleEvent(db, args)no longer takes a notifier or notifies. The db package cannot resolve the runtime (host connectivity lives in the hub), so the server wrapperapplyLoggedThreadLifecycleEvent(lifecycle-outcome.ts) now owns thestatus-changedpush and attaches the metadata. This covers turn start (run.startedfrom the daemon'sturn/started), turn end (turn/completed), provisioning, reconciliation and failure paths.thread-send.ts,queued-messages.ts,parent-system-messages.ts: the three post-commit producers that activate a thread now carry the activated row out of the transaction (activeThread: Thread | nullreplacesthreadBecameActive: boolean) and attach the metadata.queued-messages.tsalso drops a redundantstatus-changednotify that fired inside the transaction, before commit; the post-commit notify on the next line already sent the same kind.packages/domain/src/plugin-sdk-version.ts+packages/plugin-sdk/package.json: no longer changed by this PR. The newstatusChangefield does change the SDK's bundled types anddist/provider-bridge.js, so the npm version guard (check-npm-version-guard.mjs) needs an unpublished version.mainhas since moved the SDK to0.4.13, which npm has not published (npm latest is0.4.12), so this PR adoptsmain's version and the guard passes without a further bump.NotificationBuffer(stop requested, command failure, thread-start success, finalize, host-wide interruption, environment cleanup, host reconnect fan-out) still send the bare kind. The client falls back to today's refetch for those; they are not on the per-turn hot path.apps/app:realtime-cache-effects.tsmergesstatusChangeinto the dirty context. Astatus-changedmessage is last-writer-wins for it: a later message that carries no row snapshot replaces (drops) an earlier one merged while the document was hidden, so the resume flush refetches instead of patching the row to the earlier, now-stale status.realtime-cache-registry.tsreplacesdirtyActiveThreadListQueriesin thestatus-changedrule withpatchThreadListStatusState: with metadata it writes the five fields into every cached thread list row and the sidebar bootstrap (updateCachedThreadListStatusStateinquery-cache.ts, same shape as the existing pending-interaction patch), invalidates only list/sidebar queries that have a fetch in flight (that fetch read the database before the transition and would overwrite the patch when it lands), and still dirties the search prefix. Without metadata it behaves exactly as before. Thread detail invalidation is unchanged (about 600 B when the thread is open).Revision after review (two findings, both fixed here):
ThreadListEntry.activitystale. The plan-mode and goal counts are server-computed, gated onstatus === "active", and were only synced by the list refetch the patch removed, so a finished plan turn kept its sidebar indicator lit. The push now carries the post-transition activity and the app patches it with the rest of the row.statusChangewhen a later barestatus-changedarrived (stop, command failure, interruption), so on resume the row was patched toactiveand never refetched.statusChangeis now last-writer-wins perstatus-changedmessage.Deviation from the report's proposal: the report suggested
status+runtimeonly.latestAttentionAtandupdatedAtare included because the lifecycle writer rewrites them and the sidebar sorts inactive rows bylatestAttentionAt;activityfor the reason above. The report's parts 2 (trim the bootstrap wire shape) and 3 (per-project sidebar keys) are not in this PR; with no refetch per turn, the payload size only matters on initial load and on membership changes.Known, pre-existing: the sidebar learns that a plan turn is active only from a list row fetched after the provider's
turn/input/acceptedlands. The turn-start push (and onmain, the turn-start refetch) is built at send time, before that event exists, andevents-appendeddoes not refetch lists, so the plan-mode glyph at turn start was already a race onmain. This PR keeps that behavior and fixes the indicator turning off at turn end. Pushing an activity patch on the accepted/goal events is a separate follow-up.How you verified
Tests added:
apps/app/src/hooks/realtime-cache-effects.test.ts: "patches cached thread list status from notification metadata instead of refetching the sidebar bootstrap". Fails onorigin/mainapp sources withAssertionError: expected "vi.fn()" to be called 1 times, but got 2 times(the sidebar query fn was refetched); passes after. The pushedstatusChangein this test carriesactivity.activePlanModeCount: 1and the row assertion covers it. Also: "refetches thread lists for a status change that carries no row metadata" (guards the fallback), "restarts a sidebar fetch already in flight so its stale snapshot cannot overwrite the patched status", and (hidden document) "refetches when a bare status-changed follows one that carried the row". The last fails on the first draft's merge withAssertionError: expected { activity: { …(5) }, …(5) } to be { activity: { …(5) }, …(5) } // Object.is equality(the idle row had been replaced by the patched active row); passes after.apps/server/test/services/threads/lifecycle-outcome.test.ts(new):applyLoggedThreadLifecycleEventbroadcastsstatus-changedwithprojectIdand the fullstatusChange(runtimeactivewith a registered daemon,waiting-for-hostwithout), nothing when the event is not applied, and "carries the status-gated plan and goal activity of the post-transition row": with an open accepted/planturn and an active goal on record,run.startedpushesactivePlanModeCount: 1, activeGoalCount: 1andrun.succeededpushesstatus: idlewithactivePlanModeCount: 0, activeGoalCount: 1. On the first draft's server builder the broadcast is rejected by the strict schema (ZodError: Invalid input: expected object, received undefinedforactivity); onmainthe first assertion fails because the db notify carried onlyprojectId.packages/domain/test/change-kinds.test.ts: maximal fixture extended (the parity guard requires it) plus "drops a status change a stale client cannot parse but keeps the message".packages/db/test/data/thread-lifecycle.test.ts: the notify assertion moved to the server test; call sites updated for the new signature (alsotests/integration/fake/recovery/idle-error-reconciliation.test.ts, which polls the API and does not depend on the push; ran it, passes).Commands (on the committed tree, rebased on current
origin/main,git status --porcelainempty):pnpm exec turbo run typecheck(whole repo):Tasks: 72 successful, 72 total.pnpm exec turbo run test --filter=@bb/domain --filter=@bb/db --filter=@bb/server --filter=@bb/app --continue:Tasks: 9 successful, 10 total. domain 136 passed; db 405 passed; app 3160 passed (3 skipped); server 1825 passed, 2 failed:test/internal/internal-skill-trees.test.ts(mode: 420vs436, a local umask 0002 artifact unrelated to this change, passes withumask 022) andtest/services/plugins/plugin-update.test.ts"waits one full interval" (Test timed out in 5000msunder the full parallel run; passes alone, 27/27).Manual check on my dev instance (scratch project, one codex thread, a dedicated headless Chromium profile with a
window.fetchlogger installed after load, thenPOST /api/v1/threads/:id/sendwith a/plan Reply only with ok.command mention from the driving script, sampling the sidebar row's indicator labels every 100 ms):tell): 19 requests, 2 ×GET /api/v1/sidebar-bootstrapat 134,865 B and 134,861 B (96% of bytes), plus child/fork list refetches at turn start and end.sidebar-bootstrapcalls, no thread list refetches; the turn traffic is the thread detail (588/584 B), timeline deltas, outline, prompt history, PR state and read receipt. The sidebar row showedThread working123 ms after the send and cleared it at turn end (2.3 s), from the pushed patch alone. At 1.5 sGET /threads?projectId=reportedstatus: active, activePlanModeCount: 1; after the turn the row carried no stale plan indicator. Log saved at/tmp/bb-fix-batch/issues/1302/revise-plan-turn-api-log.json.Fixes #1302
Independent verification
Verified round 2 at head
1e1e56ff7(rebased onorigin/mainc942421a4;git merge-base --is-ancestor origin/main HEADtrue, GitHub reports MERGEABLE) in a fresh worktree.Commands:
git fetch origin main && git fetch origin bb/fix-1302-sidebar-bootstrap && git checkout -b verify-1302-r2 FETCH_HEAD;pnpm install --frozen-lockfile --prefer-offline;pnpm exec turbo run build.git checkout origin/main -- <13 non-test source files>thenpnpm exec vitest run src/hooks/realtime-cache-effects.test.ts(apps/app): 1 failed / 56 passed,AssertionError: expected "vi.fn()" to be called 1 times, but got 2 times("patches cached thread list status from notification metadata instead of refetching the sidebar bootstrap").pnpm exec vitest run test/services/threads/lifecycle-outcome.test.ts(apps/server): 3 failed / 1 passed; the broadcast metadata was{ projectId }only (- "statusChange": { … }in the assertion diff),expected undefined to be 'waiting-for-host',expected undefined to deeply equal { activeGoalCount: 1, activePlanModeCount: 1, … }.realtime-cache-effects.ts(fdfaec771) checked out,-t "bare status-changed follows"fails withAssertionError: expected { activity: { …(5) }, …(5) } to be { activity: { …(5) }, …(5) } // Object.is equality.git checkout HEAD -- …, tree clean): app file 57/57, server file 4/4.pnpm exec turbo run typecheck --filter=@bb/domain --filter=@bb/db --filter=@bb/server --filter=@bb/app --filter=@bb/integration-tests --filter=@bb/mobile --filter=@bb/sdk --filter=@bb/desktop:Tasks: 12 successful, 12 total.pnpm exec turbo run test --filter=@bb/domain --filter=@bb/db --filter=@bb/server --filter=@bb/app --continue: domain 136, db 405, app 3160 (3 skipped) passed; server 1826 passed / 1 failed =test/internal/internal-skill-trees.test.ts(mode: 420vs436, the known local umask 0002 artifact; passes in CI).Repro on the fixed branch (own dev instance :18681/:26681/:34681, scratch project, one codex thread, headless Chromium with a
window.fetchlogger installed after load,pnpm bb:dev thread tell <id> "Reply only with ok."from the shell, 100 ms DOM poll of the sidebar row): three sends, each 9-10 API requests and 0GET /api/v1/sidebar-bootstrap(report on main: 19 requests, 2 bootstrap downloads = 96% of bytes). The sidebar row showedThread workingabout 100 ms after the send,Unread thread succeededat turn end and cleared after the read receipt, all from the pushed patch. No longer reproduces.Review notes: server-to-app contract only; the host daemon does not consume thread
changedmessages, so noHOST_DAEMON_PROTOCOL_VERSIONbump is needed; every inbound consumer (app, mobile, desktop, sdk) uses the lenient schema; thread lists are ordered by pin/createdAt and not filtered on status, so patching cannot change membership; allapplyThreadLifecycleEventcallers updated. Residual (documented in the body): in-transaction producers (stop, command failure, thread-start success, interruption, env cleanup, host reconnect) still push the bare kind and refetch the whole bootstrap; the plan-mode glyph at turn start stays a pre-existing race; the 138 KB payload shape and single sidebar key (report parts 2 and 3) are untouched, so a reviewer may prefer to keep #1302 open for the payload trim. Nit:thread-runtime-display.tsL268-270 is not prettier-formatted (CI does not enforce it).Rebase
Rebased onto
origin/main75d6fc4d4(was 32 commits behind atc942421a4) and squashed the two commits into one (766f1928f); the commit message keeps the original subject and body and folds in the revision-round notes (activity on the push, last-writer-wins merge).git rebaseapplied cleanly with no conflicts: none of the 32 commits on main touched the 23 files in this diff. The commits on main in the neighbouring areas (apps/server/src/services/threads,packages/domain/src,apps/app/src/hooks) are the provider v3 contract stack (#2124, #2136, #2148, #2164, #2179), the late tool-call completion fix (#2176) and the acp fork capability change (#2150); they do not touch the lifecycle writer, the thread change-kind schema, or the realtime cache registry, so the fix maps onto the new base unchanged.origin/mainstill has nostatusChangeinchange-kinds.tsorrealtime-cache-registry.ts.Re-proved on the new base (committed tree,
git status --porcelainempty):origin/main,apps/apprealtime-cache-effects.test.ts: 1 failed / 56 passed,AssertionError: expected "vi.fn()" to be called 1 times, but got 2 times;apps/serverlifecycle-outcome.test.ts: 3 failed / 1 passed (expected undefined to be 'waiting-for-host',expected undefined to deeply equal { Object (activeBackgroundAgentCount, ...) }). Pass-after: 57/57 and 4/4.pnpm exec turbo run typecheck --filter=@bb/domain --filter=@bb/db --filter=@bb/server --filter=@bb/app --filter=@bb/integration-tests --filter=@bb/mobile --filter=@bb/sdk --filter=@bb/desktop --filter=@bb/host-daemon --filter=@bb/cli:Tasks: 14 successful, 14 total.pnpm exec turbo run test --filter=@bb/domain --filter=@bb/db --filter=@bb/server --filter=@bb/app --continue: domain 27/27 files, db 28/28 files; server 1899 passed / 2 failed; app 3190 passed / 3 failed (3 skipped). The machine was under a load average of 40-70 from parallel agents: every app failure and theplugin-update.test.tsserver failure wereTest timed outin files unrelated to this change (different files on each of two runs), and each passes when rerun alone (135/135, 27/27). The one remaining server failure istest/internal/internal-skill-trees.test.ts(mode: 420vs436), the known local umask 0002 artifact that passes in CI.Independent verification (guards)
Verified head
17746125f(rebased ontoorigin/main27d1017fe;git merge-base --is-ancestor origin/main HEADtrue; GitHub reportsMERGEABLE/CLEAN) in a fresh worktree. Scope: confirm the post-verification change is only the CI-guard fix, re-prove fail-before/pass-after on the new head, confirm CI.git diff 75d6fc4d4 766f1928f(previously verified patch) vsgit diff origin/main 17746125fdiffer by exactly two hunks:packages/domain/src/plugin-sdk-version.tsPLUGIN_SDK_VERSION = "0.4.11"→"0.4.12"andpackages/plugin-sdk/package.json"version": "0.4.11"→"0.4.12". No other line of the PR changed.origin/mainandnpm view @get-bb/plugin-sdk versionare both0.4.11;@get-bb/plugin-sdk@0.4.12is 404 on npm, so the patch bump targets the next unpublished version. The commit keeps the original subject, body, andCo-Authored-Bytrailer.17746125f: all checks pass (Checks, Package Smoke x2, Tests app-1/2/3, integration, packages, server, Version Lockstep x2; Node Compatibility Smoke and iOS simulator flows skipped by design). TheCheck plugin SDK npm version guardstep logsnpm version guard: PASS — @get-bb/plugin-sdk@0.4.12 is not on npm yet. The publish job will ship this version.git checkout origin/main -- <15 non-test source files>):packages/domainchange-kinds.test.ts2 failed / 6 passed (ZodErroron the maximal strictthreadfixture,expected [ 'backgroundActivityChanged', …(4) ] to deeply equal [ …(3) ]);apps/serverlifecycle-outcome.test.ts3 failed / 1 passed (assertion diff shows the broadcastmetadatais{ projectId }only,- "statusChange": { activity, latestAttentionAt, runtime, status, updatedAt });apps/apprealtime-cache-effects.test.ts+cache-owner-registry.test.ts2 failed / 59 passed (AssertionError: expected "vi.fn()" to be called 1 times, but got 2 timesatrealtime-cache-effects.test.ts:1905).git checkout HEAD -- …,git status --porcelainempty): domain 8/8, server 4/4, app 61/61.pnpm exec turbo run typecheck --filter=@bb/domain --filter=@get-bb/plugin-sdk:Tasks: 5 successful, 5 total.pnpm exec turbo run build:Tasks: 18 successful, 18 total.GET /api/v1/sidebar-bootstrapper send) is recorded above.Residual risks unchanged from the sections above. This PR no longer carries an SDK version change;
main's unpublished0.4.13covers it.Rebase (2026-08-21)
Rebased onto
mainatd41d1abee. Onlypackages/domain/src/plugin-sdk-version.tsandpackages/plugin-sdk/package.jsonconflicted, becausemainmoved the SDK from0.4.12to0.4.13. Both were resolved tomain's values, so the version files have dropped out of this PR's diff entirely (25 changed files -> 23). No other line of the fix changed.Re-verified on the new base:
node packages/plugin-sdk/scripts/check-npm-version-guard.mjs->PASS - @get-bb/plugin-sdk@0.4.13 is not on npm yet.pnpm exec turbo run typecheck --filter=@bb/app --filter=@bb/server --filter=@bb/db --filter=@bb/domain --filter=@bb/integration-tests:Tasks: 9 successful, 9 total.pnpm exec turbo run testfor app/server/db/domain: domain 27/27 files, db 28/28, server pass;@bb/appreported one failure inPromptBoxInternal.test.tsx > selection reveal, which passes on its own re-run and touches no file in this PR (the app changes are confined tosrc/hooks/cache-owners/). Treated as load-dependent flake; CI is the arbiter.