Repository navigation
CONTRACT: fix(ui-server): commit journal, Activity and native-lease SQLite off the event loop - #2830
Merged
Merged
Conversation
…QLite off the event loop Every turn-journal, Activity and native-lease write now commits on a per-database write worker thread with its own connection at synchronous=FULL. Calls run one at a time in submission order, and the event loop awaits each commit instead of blocking in its fsync (#2814, ruling 1 on #2789). - db/write-worker.ts and write-worker-thread.ts: the writer, inline for in-memory databases. FULL is enforced before every commit on main and on attached files (for #2789's journal.db). Refusals keep their class. App-connection transactions take the write lock up front, so a worker commit cannot fail their snapshot upgrade with SQLITE_BUSY. - brain/journal-store.ts: the journal's transaction bodies, run through the unchanged createJournalAccounting on the worker connection. - activity/store-writes.ts: the off-loop Activity store and orderedWrites. A write inside the caller's transaction stays inline and atomic. - brain/native-lease-store.ts: durable lease intents. - Callers that need a commit's outcome await it: journal bindSession, writeOperation (now statements), Claude memory persistence, revert reservation, retained-version leases and streams; turn revert start; storage removal start/cancel. createTurnJournal exposes ready. - StartTurnRequest.persistClaudeMemory may return a promise; the Claude backend awaits it (additive, docs/integration-contract/package-api.md). Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ting write worker (#2814)
…ts-off-loop-044 # Conflicts: # docs/decisions/async-collaboration.md # docs/decisions/http-api-boundary.md # docs/decisions/live-conversation.md # docs/decisions/session-principals.md # docs/decisions/voice-permission.md # docs/investigations/live-conversation-investigation.md # docs/plans/async-collaboration-requirements.md # docs/plans/async-collaboration.md # docs/plans/navigation-recovery.md
…ts-off-loop-044 # Conflicts: # docs/decisions/governed-writable-workers.md # docs/decisions/http-api-boundary.md # docs/decisions/managed-worktrees.md # docs/decisions/scheduled-tasks.md # docs/decisions/session-principals.md # docs/plans/async-collaboration-requirements.md # docs/plans/async-collaboration.md # docs/plans/navigation-recovery.md # docs/plans/storage-accounting.md # docs/plans/storage-lifecycle.md # packages/ui-server/src/app.ts # packages/ui-server/src/brain/journal-accounting.ts # packages/ui-server/src/brain/turn-journal.ts # packages/ui-server/tests/storage-removal.test.ts
Since #2814 the journal's startup recovery and steering replay run behind `ready`. Two tests from #2843 assumed they finished during construction: the steering replay assertion ran before the replay, and the SIGKILL storage-removal children raced the parent fixture's startup write and refused their first checkpoint as `journal_storage_unavailable`. The decision record now names #2850 for the app-connection writers that stay on the loop, and says that they also wait for a worker commit. Co-Authored-By: Claude Opus 5.5 <[email protected]>
… writes (#2814) The session slot awaits the recorder's off-loop writes before the next queued turn starts. No test failed when that wait was removed: every store write committed before the next turn could read it. The new case holds the writes the way a slow worker commit would, and requires the second turn to start only after they commit. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This was referenced Oct 24, 2026
…ts-off-loop-044 Conflicts: - packages/ui-server/src/inbox/autonomous-turn.ts: #2851's index flush now runs before the recorder settles, so an index outcome it records in Activity commits on the write worker before the terminal transaction, as every other Activity write of the run does. - 13 docs: citation line numbers only. Took main's text and shifted each citation to its anchor's line in the merged sources. Co-Authored-By: Claude Opus 5.5 <[email protected]>
… terminal (#2814, #2835) #2851 records each deferred index outcome in Activity at the end of a turn. Its tests ran on a synchronous in-memory store, so none reached the write worker. A hosted session now runs the same flush on a file database with the worker-backed store and reads the rows after the session returns. An autonomous turn holds the flush's Activity write and requires the terminal transaction to wait for it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
schlessera
marked this pull request as ready for review
October 24, 2026 23:55
This was referenced Oct 25, 2026
schlessera
added a commit
that referenced
this pull request
Oct 25, 2026
… turn The Claude backend awaits copy-out before its turn ends (releaseTerminal -> finishWorker -> worker.finish), and persistClaudeMemory may return a promise since #2830. The journal runtime fixture still called persist as if it were synchronous and ended the turn at once (#2889). Co-Authored-By: Claude Opus 5.5 <[email protected]>
schlessera
added a commit
that referenced
this pull request
Oct 25, 2026
…ion order (#2888) * fix(ui-server): record a Claude memory save in the journal's observation order Since journal commits moved to the write worker (#2814, #2830), persistClaudeMemory awaits several commits before it records the saved note, and nothing ordered it against the turn's finish or a committing observation: - finish could remove the turn from `active` while a save was still committing, so recordClaudeMemory refused the already-saved note with "memory observation requires an active turn" and the turn ended partial with the note unconfirmed; - a save requested after finish passed the up-front `active` check, wrote the external note, and only then was refused; - an observation reads a turn's sequence before its commit and stores the result after, so a memory record in between reused an observed sequence (and its steering_change dedupe key). persistClaudeMemory and recordClaudeMemory now run through the journal's serialize order beside observations and finish. A save requested before finish is recorded before the turn ends; one requested after is refused before it saves; a record takes the next free sequence. The refusal for an inactive turn is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(ui-server): keep the memory record's public signature and add its changeset The memory target type alias leaked into the ui-server API report; spell the target inline as before. Citations follow the moved lines. Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(ui-react): await the memory copy-out before ending the fixture's turn The Claude backend awaits copy-out before its turn ends (releaseTerminal -> finishWorker -> worker.finish), and persistClaudeMemory may return a promise since #2830. The journal runtime fixture still called persist as if it were synchronous and ended the turn at once (#2889). Co-Authored-By: Claude Opus 5.5 <[email protected]> * ci: retrigger draft gates (synchronize events for c8e7c17 and 8de2ab3 started no runs) Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
This was referenced Oct 26, 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.
Closes #2814
What changed
Journal, Activity and native-lease SQLite writes now commit on a write-worker thread instead of the event loop. Each database file gets one worker with its own connection at
synchronous=FULL. The worker runs calls one at a time, in the order they were submitted. The event loop awaits each commit's outcome instead of blocking in its fsync.brain/journal-store.ts. They run through the unchangedcreateJournalAccounting. The worker attaches the journal's ownjournal.db(fix(ui-server): give the turn journal its own database file #2843) asjournal, on the same connection as the UI database, so the ledger's own attach is a no-op there.bindSession,writeOperation(which now takes statements), Claude memory persistence, revert reservation, retained-version leases and retained streams;start;startandcancel.createTurnJournalexposesready, which covers startup recovery and the steering-event replay from fix(ui-server): give the turn journal its own database file #2843. The app awaits it before serving.StartTurnRequest.persistClaudeMemorymay now return a promise, and the Claude backend awaits it. This is additive, so it carriesCONTRACT:.Why this shape
The ruling (#2789, ruling 1) keeps
synchronous=FULL, transaction order and refusal semantics. Each rejected alternative and the reason it lost are in docs/decisions/sqlite-write-worker.md:Transaction bodies therefore move into the worker as code, with their reads.
Five things the implementation found:
ui.db, an appBEGIN IMMEDIATEfailed withSQLITE_BUSYafter about 37 ms despite a 5 s busy timeout, and a deferred read-then-write transaction can lose its snapshot upgrade to a worker commit. The first native run hit this as a governed-turndatabase is locked. So each outermost app transaction, and each autocommit write, waits until the worker is between procedures (takeTurns). The cost of this is under "Anything left open".expect(promise).rejects/.resolveswaits in a nested event loop that does not deliver worker messages, so an assertion on a promise that needs a worker round trip never settles. Affected tests useexpectRejectioninstead; this is documented indocs/process/testing.md.ready. The replay test asserted before the replay ran. The SIGKILL storage-removal children raced the parent fixture's startup write: the child's first journal write found a reader on the journal's WAL and refused withjournal_storage_unavailable. Both tests nowawait journal.ready; production already awaits it before serving.Proof
Head
ea1758624. It mergesorigin/mainatd2bb3b301inb92154f43; no force-push. Earlier,d59f57b70mergedc696f995c(#2843).inbox/autonomous-turn.ts: CONTRACT: fix(ui-server): defer and batch the index update after host applications #2851's index flush now runs beforerecorder.settled(). The flush records its outcome in Activity, so that write commits on the worker before the terminal transaction, like the run's other Activity writes.ea1758624adds two tests:ACTIVITY_INDEX_OUTCOME_COMMITTED_OFF_LOOP).AUTONOMOUS_TERMINAL_WAITS_FOR_INDEX_OUTCOME).1f032f95b(2 commits). A trial merge is textually clean, andtscandtests/decision-citations.test.ts(44/44) pass on it. I did not push it, because it would void the proof below.Local gates on
ea1758624bun run check:pr --base origin/mainbun scripts/check-packages.ts(pack smoke)tsc --noEmit,bun run lintbun run test packages/ui-server packages/ui-sdkui-sdkmaintenance-temp-budget(3),ui-sdkmask-scratch(1) andui-serverhttp-supported-mounts(1). They ran while native proofs,ddwriters and a mutation batch loaded the host. Rerun alone, those three files pass 135/135 twice on this head and twice on maind2bb3b301.bun run test packages/ui-backend-claudeprovider-history.test.tsbun run test ./testsnative-diagram-inputs,integration-commandandunit-workers/changed-unit-workers. The same 7 fail on maind2bb3b301, and did onc696f995c, so this branch did not introduce them.packages/ui-server/tests/journal-accounting.test.tsorigin/main; 10/10Native hosted proof on
ea1758624.prove-native-host.tsran all four cells under the exclusive fixture lock. Itsdd if=/dev/zero bs=1M count=1024 conv=fsyncwriters ran outside the lock./api/healthper cell (first / warm), msHOSTED_ADMISSION_HEALTH_WITHIN_1_SECONDpassed in both, with the bound unchanged. The onlyerror:line in either is the expected, verified automatic-index failure reproduction. The previous head,633663dab, had passed the same proof both ways, with a highest probe of 786 ms under load. Earlier single-cell runs on the first merged head had timed out a probe in 2 of 6.Lag attribution (the #2416 method). The native pi UID 1000 cell ran under three fsync writers, measured with the 20 ms timer-drift probe and
bun --cpu-profat 500 µs. Windows ≥ 250 ms after boot, categorised by sampled frames:d2bb3b301)d2bb3b301)ea1758624)ea1758624)c696f995c(2 runs)633663dab(2 runs)The host was less loaded this time, so these before runs show fewer windows than the earlier pair, which is kept above for comparison. The after runs also record each app-connection write's wait for its turn and each worker call's duration. That diagnostic is not committed. Per the ruling on #2814, waits of this kind belong to #2850. Two of the four after windows are such waits:
bindModel, which is a journal commit;writeLifecycle.Mutation receipts. M1–M20 were re-run on
ea1758624, each against its named suite, then restored; each fails:bindModelrefuses the active turn's bindingACTIVITY_SLOT_WAITS_FOR_RECORDER_WRITES)Merge-specific mutations:
await recorder?.settled()fails "turn-end index outcomes commit on the write worker before the turn's terminal". Dropping the turn-endsettleIndex()fails the same test onACTIVITY_INDEX_OUTCOME_COMMITTED_OFF_LOOP.recorder.settled(), or droppingsettled(), fails "an autonomous turn's index outcome commits before the transaction that writes its terminal" onAUTONOMOUS_TERMINAL_WAITS_FOR_INDEX_OUTCOME. Dropping the barrier fails the same test because the flush never runs.The #2843 test fixes, without
await journal.ready:JOURNAL_STARTUP_REPLAYS_STEERING;STORAGE_REMOVAL_REAL_CHILD_BOUNDARY, and the child's stderr shows itsjournal_storage_unavailablerefusal.Checklist
.changeset/sqlite-commits-off-event-loop.md)docs/integration-contract/package-api.md, persistClaudeMemory)docs/reflect the change, or nothing documented moved.docs/decisions/provider-history.md): the provider-history guard passes, or nothing on a turn's path changed.Anything left open
ui.dbwrites. The ledger'sBEGIN IMMEDIATEtakes the write lock on every attached file, and the JS turn covers every worker procedure. So a journal commit tojournal.dbalso holds back app writes toui.db; after r1 measured one such wait at 384 ms. Taking only the journal file's lock would need the ledger's fresh-checkpoint check and its governance-event writes reworked. It is recorded on Epic: nothing on the hosted server's event loop waits for a ui.db fsync #2850.origin/mainhas moved 2 commits since the merge (see Proof). The trial merge is clean.🤖 Generated with Claude Code