Repository navigation
fix(ui-server): commit the writer gate on the write worker - #2949
Merged
Merged
Conversation
The hosted server no longer commits the brain's writer gate on its app connection (#2850). Acquisition, the turn-waiter queue, release, folder retention, peer-plan checks, the unpinned-host downgrade, the ungated host check and recorded type folders run their transactions on the ui.db write worker at synchronous=FULL, each still BEGIN IMMEDIATE, so they stay serialized against the cron runner's gate on its own connection. One policy reader on the worker serves every observation the gate makes, and a host fallback refusal keeps its class across the worker. A release is submitted at once and settles once it has committed; withGate, a turn's release, a launch's settled and shutdown wait for it. A host process spawns only after its downgrade has committed, and only if the boundary is still the host fallback afterwards. A type folder translation that cannot be recorded refuses the turn. holders(), a diagnostic, still reads on the gate's own connection. Refs #2850 Co-Authored-By: Claude Opus 5.5 <[email protected]>
A turn's gate release now commits on the write worker before its slot ends (#2850), so reference authority and roadblocks are read after the turn is no longer active rather than at its earlier milestones. Refs #2850 Co-Authored-By: Claude Opus 5.5 <[email protected]>
15 tasks done
…offloop-044 # Conflicts: # docs/decisions/sqlite-write-worker.md
schlessera
marked this pull request as ready for review
October 26, 2026 00:40
This was referenced Oct 26, 2026
Merged
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
Fourth slice of #2850: the brain's writer gate (group 2 of the remaining inventory). Independent of #2933 (session catalog); the two touch different files apart from
docs/decisions/sqlite-write-worker.md.What changed
The hosted server no longer commits the writer gate on its app connection. Every gate transaction now runs on the
ui.dbwrite worker atsynchronous=FULL(gateProcedures,packages/ui-server/src/brain/writer-gate.ts; worker entrywriter-gate-writes.ts): acquisition, the turn-waiter queue, release, folder retention, peer-plan checks, the unpinned-host downgrade, the ungated host check and recorded type folders. The SQL and transaction bodies are the ones that ran inline. Each transaction is stillBEGIN IMMEDIATE, so it stays serialized against the cron runner's gate, which keeps committing on its own connection through the same procedures. One policy reader, held where the transactions run, serves every policy observation the gate makes, as one reader per gate did before. AHostFallbackRefusedErrorkeeps its class across the worker.What used to follow those writes synchronously now waits for them:
release()submits at once, so every later gate write of this process sees it, and returns a promise that settles once it has committed. Whatever promises the hold is gone waits for it:withGate, a governed turn's release, a launch'ssettled, storage removal, turn revert and shutdown (gate.settled()). A caller that does not wait never leaves a rejection unhandled.markUnpinnedHostWriterand the ungated check settle after their transactions commit. Every host spawn awaits them, then rechecks the abort signal and that the boundary is still the host fallback before spawning. A turn that gains worker history while the downgrade waits is refused, not run on the host.runSync(test-only callers) becomes async for the same reason.retainFolders,assertPeerFolderPlansandrememberTypeFoldersare awaited ingoverned-turn.begin. A type-folder translation that cannot be recorded now refuses the turn instead of being lost for later writers.holders(), a diagnostic with no production caller, still reads (and prunes) on the gate's own connection.Why this shape
The design was reviewed by gpt-6-astra before implementation; its findings shaped it:
BEGIN IMMEDIATEstays. The check is not read-only (it prunes), and IMMEDIATE serializes it against another process's gate writer mid-commit. A deferred snapshot could see the state before that commit.StreamingExecution.settledand the cron runner promise that the admission has been released.No product decision was needed: existing fallback behaviour is kept.
Proof
Head
18113b2ab:origin/mainc670449d3(which now includes #2933) merged into the branch, no force-push. The only conflict wasdocs/decisions/sqlite-write-worker.md, where both slices' bullets are kept. Every heavy run went through the host slot wrapper.18113b2ab)bun run check:pr --base origin/mainbun run test packages/ui-serverbun run test ./testswriter-gate-offloop,writer-gate,host-fallback-gates,private-index,ws-reference-worktrees,ws-turn-journal,app-writes-offlooptsc --noEmit,bun run lint,decision-citationsEarlier receipts, on the pre-merge head:
Head
151a36912: the changeefb382a14plus one test-only commit, on2c83cd2f1. Every heavy run went through the host slot wrapper.bun run check:pr --base origin/mainon151a36912(and before onefb382a14)bun run test packages/ui-serveron151a36912bun run test packages/ui-serveronefb382a14ws-reference-worktrees(bridge authority read once generations retired) andws-turn-journal(roadblocks read at the journal'scompleteframe; the same fix as #2933). Each now waits for the turn's slot to end, 3/3 after, and passes onorigin/mainas well.bun run test ./testsonefb382a14tsc --noEmit,bun run lint,decision-citationsprove-native-host.ts, all four cells, underflockon the fixture lock,dd … conv=fsyncwriters outside the lock), tree identical toefb382a14Focused tests (
packages/ui-server/tests/writer-gate-offloop.test.ts, new; one new case inprivate-index.test.ts). App-connection triggers refuse writes towriter_gate,writer_gate_turn_waitersandprovider_type_folders; the worker refuses any below FULL. The cases:unpinned_host.runandlaunch, a boundary that turns isolated while the downgrade waits refuses the host writer: the child never spawns and the hold is released.Existing tests now await the downgrade where they then assert its effect.
Mutations (each applied, its named test run, restored)
APP_CONNECTION_WRITE writer_gateSQLITE_WRITE_BELOW_FULLGATE_NEXT_ACQUISITION_SEES_EARLIER_RELEASEGATE_RELEASE_SETTLES_AFTER_ITS_COMMITGATE_UNPINNED_COMMITTED_BEFORE_SETTLINGrunskips the post-downgrade boundary checkGATE_HOST_REFUSED_WHEN_BOUNDARY_CHANGED_DURING_ADMISSION_runlaunchskips it…_launchGATE_REFUSAL_CLASS_KEPT_ACROSS_WORKERTYPE_FOLDER_RECORD_FAILURE_REFUSES_ADMISSIONGATE_HOST_REFUSED_WHEN_BOUNDARY_CHANGED_DURING_ADMISSION_runG2 was first written as a
PRAGMAinside the transaction, which SQLite refuses, so it was re-aimed at the autocommit release. G3 first failed only as a 30 s timeout; a bounded race now names it.Lag attribution (#2416 method with the turn wait joined, as on #2884/#2896/#2933; diagnostic not committed). Native pi UID 1000 cell, 3 fsync writers, 3 runs each.
origin/main2c83cd2f1): 3 windows ≥ 250 ms afterready.writeLifecycle(writer-gate.ts:211←governed-turn.ts:148).writer-gate.tsat all. The 2 windows afterreadyare an auth-session write (principals.ts, 398 ms, waiting behindwriteLifecycle) and session work (session-work.ts, 268 ms, fix(ui-server): commit the session catalog on the write worker #2933). Both belong to other groups.Checklist
.changeset/writer-gate-off-event-loop.md)WriterGate,GateHold,markUnpinnedHostWriterandHostBoundary.runSyncare internal.)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: the session catalog (#2933), auth sessions (
principals.ts), the Activity notifier and push ticks, then retained retries, local exchanges, saved worker preferences, policy proposal inserts and saves,history-store,governed-manifest, schedules, inbox and settings, plus the gate'sholders()diagnostic.🤖 Generated with Claude Code