Repository navigation
fix(ui-server): commit the session catalog on the write worker - #2933
Merged
Merged
Conversation
The hosted server no longer commits the session catalog on its app connection (#2850). The session row, accepted work, turn boundaries, message sources, turn failures, the state room, Claude usage accounting, handoff links, labels and session costs commit on the ui.db write worker at synchronous=FULL, in submission order with the journal's, Activity's, governance, scope and budget writes. So do the two session_info writes that reference the new row: binding a draft's worker profile and recording the admitted one, and binding the turn's policy proposals. Synchronous reads on the turn path apply every pending write's own effect to the committed row, so a write counts from its submission as it did when it committed inline; each effect stops counting when its write settles. History joins, the session list and history routes, handoff sources, the policy proposal route and retry reads await settled() first. An acceptance's revision is assigned where it is accepted, never reissued after a failure, and only the newest acceptance orders the session. Retained retries and local exchanges stay on the app connection. Refs #2850 Co-Authored-By: Claude Opus 5.5 <[email protected]>
…arning Catalog writes settle off the event loop (#2850), so a failed write's warning is reported once its promise settles. Refs #2850 Co-Authored-By: Claude Opus 5.5 <[email protected]>
Roadblocks are classified after the journal completes and commit on the write worker; the session's slot ends only once they have (#2850). With the catalog's writes now on the same queue, reading them at the journal's complete frame raced their commit. Refs #2850 Co-Authored-By: Claude Opus 5.5 <[email protected]>
15 tasks done
…log-writes-044 # Conflicts: # docs/decisions/governed-writable-workers.md # docs/decisions/http-api-boundary.md # docs/decisions/session-principals.md # docs/plans/async-collaboration-requirements.md # docs/plans/async-collaboration.md # docs/plans/navigation-recovery.md
Refs #2850 Co-Authored-By: Claude Opus 5.5 <[email protected]>
schlessera
marked this pull request as ready for review
October 25, 2026 20:39
This was referenced Oct 25, 2026
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.
Refs #2850
Third slice of #2850: the session catalog, with session work and message sources (group 1 of the remaining inventory).
What changed
The hosted server no longer commits the session catalog on its app connection. The session row (
session_infostub andresult), accepted work and its dispatch, turn boundaries, message sources and effective thinking levels, turn failures, the state room, Claude usage accounting (with its title and unstreamed-usage spans), handoff links, pill labels and session costs commit on theui.dbwrite worker atsynchronous=FULL, in submission order with the journal's, Activity's, governance, scope and budget writes (ws/session-catalog-writes.ts). So do the two writessession_infomakes that reference the new row: binding a draft's worker profile and recording the admitted one (ws/session-profile-writes.ts), and binding the turn's policy proposals (brain/policy-proposal-writes.ts). Each procedure runs the statement or transaction that ran inline, and the worker's order puts each after the row it references. Each write's clock is taken where it is submitted.Why this shape — the read-your-writes barrier
/api/sessions, resume and the turn path readsessionsright aftersession_info, and several app-connection writes reference that row (FK orUPDATE … WHERE id). The design (reviewed by gpt-6-astra before implementation; its findings are folded in below) has three parts:COALESCEprovider/backend and existence, a label, an acceptance and a dispatch.getStoredBackendId,getStoredProviderId,hasSession,sessionLabelandlatestWorkanswer from the committed row with those applied, so a write counts from its submission, exactly as when it committed inline. Each effect leaves when its own write settles, so a failed one stops counting at once, and only an upsert makes a row exist. Session profiles do the same for their bind/record, and the draft a bind consumes reads as gone. That keepssession_info'sworkerProfileframe, admission'spreference(), recovery's one-instant snapshot, routing and thesession_inforeconnect frames synchronous and unchanged; no frame is delayed.catalog.settled()resolves once every catalog write submitted before it has settled. The worker settles in FIFO order, so waiting on the latest is enough.host.historySettled()adds proposal binds.publishHistory(session_resume, connect snapshot),GET /api/sessions,GET /api/sessions/:id(before it picks the backend from the row, and again before joining), handoff source history, the policy proposal route andretry_status/retry_turnawait it before reading.revision + 1. A failed write's revision is never issued again, and only the newest acceptance's outcome orders or unorders the session (acceptRequest). No other process writessession_work. The rejected alternative, an async revision, would have left queue entries anddispatchWorkwithout one.Kept on the app connection (next slices): retained retries and local exchanges. A worker-side retry invalidation could not stay fail-closed across a restart (astra finding 2), so
clearRetryRequeststill refuses the input whose invalidation failed. A failure retained while its session's row is still pending is stored once that row commits, unless an input invalidated it meanwhile (generation check). Saved worker preferences (PUT …/profile) and policy proposal inserts and saves also stay. A proposal saved while the stub is pending keeps a null session until the bind, which commits after the stub, fills it.Behaviour that changed only in a failure path: a
session_infoprofile bind or record that fails is logged instead of throwing out ofemit, and a retry advertised while its row was pending refuses on reservation if the deferred store then fails.Proof
Head
ad017ddd8:origin/maina19ce13ffmerged into the branch (no force-push). Only doc citations conflicted; they were re-pointed. #2935's last-handoff_receiptassertions inws-handoff.test.tsare kept. Every heavy run below went through the host slot wrapper.ad017ddd8)bun run check:pr --base origin/mainbun run test packages/ui-serverbun run test ./testsws-handoff,ws-observability,ws-turn-journaltsc --noEmit,bun run lint,decision-citationsEarlier receipts, on the pre-merge head
c876e0bb7(the change1a9d1e210plus two test-only commits, on72b222fef):bun run check:pr --base origin/mainonc876e0bb7(and before on1a9d1e210)tsc --noEmit,bun run lint,tests/decision-citations.test.tson1a9d1e210bun scripts/check-packages.tson1a9d1e210(three new worker-loaded modules)1a9d1e210:app-writes-offloop,ws-scope-caps,ws-handoff,session-routes-handoff,ws-session-recovery,session-recovery-http,ws-retry-turn,governance-policy,policy-proposal,claude-usage-accounting,claude-title-accounting,ws-message-source,ws-pill-labels,ws-turn-failure-replaybun run test packages/ui-serveronc876e0bb7, through the host slot wrapperexternal-speechtimeouts and the two real failures fixed by the test-only commits.)bun run test packages/ui-backend-claude packages/ui-backend-pi(pre-rebase tree)provider-history.test.tsbun run test ./testsonc876e0bb7, through the host slot wrapperorigin/main. That run is also whereapi-surfacecaught a method inserted underprepareHistory's@internalcomment, fixed before review.)prove-native-host.ts, all four cells, underflockon the fixture lock,dd … conv=fsyncwriters outside the lock) on a tree identical to1a9d1e210Focused tests (
packages/ui-server/tests/app-writes-offloop.test.ts, six new cases, same guards as #2884/#2896). The app connection refuses writes to the moved tables (APP_CONNECTION_WRITE), and the worker refuses any below FULL and logs commit order. The cases:/api/sessionsand/api/sessions/:idwait for pending writes; the latter selects the non-default backend from a pending row.publishHistoryjoins a pending message source.Test changes that follow the async writes.
ws-observability.test.tsawaitssettled()before reading a failed write's warning, which is now reported when its promise settles.ws-turn-journal.test.tsread the turn's roadblocks at the journal'scompleteframe. Roadblocks are classified after the journal completes, and the slot ends only once they commit (await recorder?.settled()). With the catalog's writes on the same queue, that read raced the commit by ~50 ms, so the test now waits for the slot to end; 3/3 after.ws-handoff.test.tsandsession-routes-handoff.test.tsawait the now-async handoff link.ws-scope-caps.test.ts(claude dictation): its in-process fake model held its third call only once the client saw the pause summary. With the catalog'ssession_infocommits now queued ahead of the scope writes, that frame arrived after the fake's 100 ms sleep (first journal frame 160 ms after the first call vs 66 ms onmain). Before, the loop stall throttled the fake. A real worker is frozen bycheck()synchronously, so the fake now also holds while the host has the turn paused. The exact-count assertion is unchanged (mutation C16).Mutations (each applied on the head, its named test run, restored; logs kept locally)
CATALOG_WRITES_COMMIT_ON_WORKER_AT_FULL(APP_CONNECTION_WRITE sessions,… session_work)SQLITE_WRITE_BELOW_FULLCATALOG_PENDING_ROW_READSPROFILE_PENDING_READPROFILE_PENDING_DRAFT_CONSUMEDCATALOG_STUB_BEFORE_ITS_REFERENCESCATALOG_PROFILE_BEFORE_PROPOSAL_BINDSESSION_LIST_WAITS_FOR_CATALOGSESSION_HISTORY_WAITS_FOR_ITS_BACKEND_ROWWORK_REVISION_NOT_REISSUEDRETRY_INVALIDATED_BEFORE_IT_WAS_STOREDRETRY_ADVERTISED_WHILE_ROW_PENDING(FK failure)HISTORY_JOINS_PENDING_CATALOG_WRITESWORK_OLDER_ACCEPTANCE_DOES_NOT_REORDERLABEL_PENDING_READws-scope-capsSCOPE_ADAPTER_PAUSES_WITH_EXACT_FINITE_CEILING(count 3)C1, C2 and C9 survived the first pass: C1 and C2 failed only on a later assertion, and C9's request ran after the list had already settled the row. An explicit no-failure assertion and a separate held window now kill them on the named assertions.
Lag attribution (#2416 method with the turn wait joined, as on #2884/#2896: 20 ms timer-drift probe,
bun --cpu-prof, every app-connection turn > 20 ms and every worker call > 20 ms logged; diagnostic not committed). Native pi UID 1000 cell, 3 fsync writers, 3 runs each.origin/main1411f0c55): 5 windows ≥ 250 ms afterready; 3 are this PR's writers. The stub's own commit was 551 ms (session-catalog.ts:343←bridge.ts:242).persistSessionwas 264 ms, waiting 239 ms behind a workerwriteLifecycle(:362←bridge.ts:283).dispatchWork's own commit was 398 ms (session-work.ts:66←run-session.ts:487). The other 2 are the writer gate (309, 389 ms). App-connection turns > 20 ms from this group:dispatchWork×3 (max 396 ms), boundaries ×7 (max 173),persistSession×6 (max 248), state room ×4, acceptance ×3, stub ×1 (470).ready. There is no app-connection turn fromsession-catalog.ts's upserts,session-work.ts,message-sources.tsor boundaries. What is left from the catalog are the two writers that stay:takePendingLocalExchanges(≤ 45 ms, waiting) and the retry clear (44 ms).Checklist
.changeset/session-catalog-off-event-loop.md)SessionCatalog,createSessionProfiles,createPolicyProposalsandWsHost.historySettledare internal;api-reportunchanged.)docs/reflect the change, or nothing documented moved. (docs/decisions/sqlite-write-worker.md; shifted and re-pointed citations.)docs/decisions/provider-history.md): the provider-history guard passes, or nothing on a turn's path changed.Anything left open
Still on the app connection, in measured order: the writer gate (309/389 ms windows before, waiting behind worker
writeLifecycle), auth sessions (principals.ts), the Activity notifier and push ticks, then retained retries, local exchanges, saved worker preferences and policy proposal inserts and saves,history-store,governed-manifest, schedules, inbox and settings. Each is a following PR on this issue.🤖 Generated with Claude Code