Skip to content

fix(ui-server): commit the session catalog on the write worker - #2933

Merged
schlessera merged 5 commits into
mainfrom
fix/2850-session-catalog-writes-044
Oct 25, 2026
Merged

schlessera merged 5 commits into
mainfrom
fix/2850-session-catalog-writes-044

Conversation

@schlessera

@schlessera schlessera commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

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_info stub and result), 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 the ui.db write worker at synchronous=FULL, in submission order with the journal's, Activity's, governance, scope and budget writes (ws/session-catalog-writes.ts). So do the two writes session_info makes 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 read sessions right after session_info, and several app-connection writes reference that row (FK or UPDATE … WHERE id). The design (reviewed by gpt-6-astra before implementation; its findings are folded in below) has three parts:

  1. Synchronous reads apply pending effects. The catalog keeps each submitted, unsettled write's own effect per session, in order: an upsert's COALESCE provider/backend and existence, a label, an acceptance and a dispatch. getStoredBackendId, getStoredProviderId, hasSession, sessionLabel and latestWork answer 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 keeps session_info's workerProfile frame, admission's preference(), recovery's one-instant snapshot, routing and the session_info reconnect frames synchronous and unchanged; no frame is delayed.
  2. Joiners await the tail. 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 and retry_status/retry_turn await it before reading.
  3. Acceptance keeps a synchronous revision. The catalog assigns it where it accepts: one above the highest it has issued or seen committed. The upsert stores it explicitly instead of 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 writes session_work. The rejected alternative, an async revision, would have left queue entries and dispatchWork without 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 clearRetryRequest still 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_info profile bind or record that fails is logged instead of throwing out of emit, and a retry advertised while its row was pending refuses on reservation if the deferred store then fails.

Proof

Head ad017ddd8: origin/main a19ce13ff merged into the branch (no force-push). Only doc citations conflicted; they were re-pointed. #2935's last-handoff_receipt assertions in ws-handoff.test.ts are kept. Every heavy run below went through the host slot wrapper.

Gate (on ad017ddd8) Result
bun run check:pr --base origin/main passed (766/766)
bun run test packages/ui-server 4006/4006
bun run test ./tests 1959/1959
focused catalog/barrier files plus ws-handoff, ws-observability, ws-turn-journal 219/219
tsc --noEmit, bun run lint, decision-citations clean; 44/44

Earlier receipts, on the pre-merge head c876e0bb7 (the change 1a9d1e210 plus two test-only commits, on 72b222fef):

Gate Result
bun run check:pr --base origin/main on c876e0bb7 (and before on 1a9d1e210) passed (766/766)
tsc --noEmit, bun run lint, tests/decision-citations.test.ts on 1a9d1e210 clean; 44/44
pack smoke bun scripts/check-packages.ts on 1a9d1e210 (three new worker-loaded modules) passed
focused files on 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-replay 194/194
bun run test packages/ui-server on c876e0bb7, through the host slot wrapper 3995/3995. (An earlier run on the pre-rebase tree under a load average of ~600 had 17 external-speech timeouts 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) 1270/1270, including provider-history.test.ts
bun run test ./tests on c876e0bb7, through the host slot wrapper 1959/1959. (The earlier overloaded run had 8 timeouts; at low load those files passed on this head and on origin/main. That run is also where api-surface caught a method inserted under prepareHistory's @internal comment, fixed before review.)
native hosted proof (prove-native-host.ts, all four cells, under flock on the fixture lock, dd … conv=fsync writers outside the lock) on a tree identical to 1a9d1e210 no load: passed; 3 fsync writers: passed (both with the deliberate retained-index-namespace failure verified)

Focused 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:

  • While a slow worker commit holds the queue, the stub, profile bind/record, proposal bind, acceptances, dispatch, sources, boundary, failure, state room, Claude usage, result upsert, label, cost and handoff are all submitted. The row is not committed on the loop, but every synchronous read already answers from submission. Everything then commits on the worker, after an earlier Activity write, with the stub before the rows that reference it.
  • /api/sessions and /api/sessions/:id wait for pending writes; the latter selects the non-default backend from a pending row.
  • A failed acceptance unorders the session and its revision is not reissued.
  • A deferred retry is stored after its row, or not at all once invalidated.
  • publishHistory joins a pending message source.
  • Only the newest acceptance decides ordering.

Test changes that follow the async writes. ws-observability.test.ts awaits settled() before reading a failed write's warning, which is now reported when its promise settles. ws-turn-journal.test.ts read the turn's roadblocks at the journal's complete frame. 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.ts and session-routes-handoff.test.ts await 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's session_info commits 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 on main). Before, the loop stall throttled the fake. A real worker is frozen by check() 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)

# Mutation Fails
C1 catalog writes run on the app connection CATALOG_WRITES_COMMIT_ON_WORKER_AT_FULL (APP_CONNECTION_WRITE sessions, … session_work)
C2 the session upsert commits below FULL same, SQLITE_WRITE_BELOW_FULL
C3 pending rows do not answer backend/provider reads CATALOG_PENDING_ROW_READS
C4 profile reads ignore pending bind/record PROFILE_PENDING_READ
C5 a pending bind leaves its draft usable PROFILE_PENDING_DRAFT_CONSUMED
C6 profile bind/record commit on the app connection CATALOG_STUB_BEFORE_ITS_REFERENCES
C7 proposal bind commits on the app connection CATALOG_PROFILE_BEFORE_PROPOSAL_BIND
C8 the session list does not wait SESSION_LIST_WAITS_FOR_CATALOG
C9 the history route picks its backend before the wait SESSION_HISTORY_WAITS_FOR_ITS_BACKEND_ROW
C10 a failed acceptance's revision is issued again WORK_REVISION_NOT_REISSUED
C11 a deferred retry is stored after an invalidation RETRY_INVALIDATED_BEFORE_IT_WAS_STORED
C12 a retry is stored before its session row commits RETRY_ADVERTISED_WHILE_ROW_PENDING (FK failure)
C13 history replay does not wait HISTORY_JOINS_PENDING_CATALOG_WRITES
C14 an older acceptance's outcome reorders the session WORK_OLDER_ACCEPTANCE_DOES_NOT_REORDER
C15 a pending label is not read back LABEL_PENDING_READ
C16 the fake model is not held by the host's pause ws-scope-caps SCOPE_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.

  • Before (origin/main 1411f0c55): 5 windows ≥ 250 ms after ready; 3 are this PR's writers. The stub's own commit was 551 ms (session-catalog.ts:343 ← bridge.ts:242). persistSession was 264 ms, waiting 239 ms behind a worker writeLifecycle (: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).
  • After (this change, 3 runs): no window ≥ 250 ms after ready. There is no app-connection turn from session-catalog.ts's upserts, session-work.ts, message-sources.ts or boundaries. What is left from the catalog are the two writers that stay: takePendingLocalExchanges (≤ 45 ms, waiting) and the retry clear (44 ms).

Checklist

  • Focused local preflight/behavioral receipts and complete selected hosted proof passed; actual head/base/job checkout evidence is in Proof.
  • Acceptance criteria from the issue are all met, or the gap is named below. (Met for the writers moved here; the remaining groups are listed below.)
  • The PR and completed/advanced issues have verified release milestones under docs/process/github.md.
  • A changeset is included, or this changes nothing a consumer can see. (.changeset/session-catalog-off-event-loop.md)
  • No personal data anywhere in the diff — the leakage gate covers the whole tree.
  • The integration contract index or relevant component is updated in this same commit, or nothing in the machine surface moved. (Nothing moved: SessionCatalog, createSessionProfiles, createPolicyProposals and WsHost.historySettled are internal; api-report unchanged.)
  • Docs under docs/ reflect the change, or nothing documented moved. (docs/decisions/sqlite-write-worker.md; shifted and re-pointed citations.)
  • Provider history stays append-only (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

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]>
@schlessera schlessera added this to the 0.44.0 milestone Oct 25, 2026
schlessera and others added 2 commits October 25, 2026 18:41
…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]>
schlessera and others added 2 commits October 25, 2026 20:55
…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
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.

1 participant