Repository navigation
fix(ui-server): commit turn budget writes on the write worker - #2896
Merged
Merged
Conversation
7 of 8 tasks
Layer 2 of #2850, re-parented onto main after #2884's squash merge. The tree is byte-identical to the proven head 50354cf. Co-Authored-By: Claude Opus 5.5 <[email protected]>
schlessera
force-pushed
the
fix/2850-turn-budgets-offloop-044
branch
from
October 25, 2026 08:10
50354cf to
1e1b09e
Compare
schlessera
marked this pull request as ready for review
October 25, 2026 08:10
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
Second slice of #2850: the turn-budget ledger. Stacked on #2884 (
fix/2850-governance-scope-offloop-044); this PR's own change is the top commit. It targetsmain, so until #2884 merges its diff also shows #2884's commit.What changed
The hosted server no longer commits a turn-budget row on its app connection. Every ledger write (admission
begin, sessionbind, completed-responseobserve, relayed-providerobserveProvider,warningsand the SDKbackstop) now commits on theui.dbwrite worker atsynchronous=FULL, in submission order with the journal, Activity, governance and scope writes. Each transaction body is the one that ran inline, and it still computes the receipt from rows read inside the transaction (budgetLedger). Pricing stays where the report arrives: a report is priced and validated at once (an invalid one still throws there), and the clock is taken at submission.What used to follow those writes synchronously now waits for them:
onUsagestill returns the turn's list price at once (TurnBudgets.listPrice), because that is a pure function of the report and the turn's frozen rates. The receipt, warnings, usage frame and any block follow the commit, and they are judged against the turn as it is then: a turn that was cancelled, timed out or already stopped is never relabelled.providerAdmissionwaits until every budget write submitted before it has committed, so a request is never admitted against totals that a pending report would block. The relay'sadmithook may now return a promise, which it awaits.budget_exceeded.bindmakes the turn's accounting unavailable: its next observation stops the turn withbudget_unavailable, rather than the session scope silently missing the turn.The cron runner keeps committing on its own connection (no write worker), through the same procedures.
Why this shape
The usage hook's return value is the one synchronous consumer. Pricing it on the loop keeps the backend contract unchanged, and leaves only the commit and the receipt off the loop. Alternatives were computing the whole receipt on the app connection from committed rows plus in-memory pending usage, which duplicates the ledger, or making the backend usage hook async across both adapters. Admission waits on a single tail for all budget writes because a maintenance run's day scope depends on every turn's writes, not only its own.
Proof
Refresh onto
main. This branch merged #2884 after each of itsmainrefreshes (no force-push; only doc-citation conflicts, re-pointed). Final head50354cf50, onto6814f2ce5.bun run check:pr --base origin/mainon50354cf50tsc --noEmit,bun run lint,decision-citationson50354cf5050354cf50:app-writes-offloop,turn-egress,turn-scope*,ws-scope-caps,interactive-share,ws-main-model,turn-budgets,ws-turn-budgets,provider-relay-budgets,turn-provider-relay,claude-memory-journal,turn-journalbun run test packages/ui-server packages/ui-sdkon39ade52d2(onemaincommit earlier, #2888)bun run test packages/ui-backend-claude packages/ui-backend-pion39ade52d2bun run test ./testson39ade52d2native-diagram-inputs, which fails identically onorigin/main.39ade52d239ade52d2, no load / 3 fsync writersThe last merge added only #2888 (Claude memory save ordering in the journal), so its journal tests were in the focused run on
50354cf50.Head
67b7cc9eb, on #2884's365e82960, onb192ca0c0(#2830 merged). Per the stack rule,check:prran once, on this top layer, againstorigin/main.Local gates
bun run check:pr --base origin/mainbun scripts/check-packages.ts(pack smoke)tsc --noEmit,bun run lint,tests/decision-citations.test.tsbun run test packages/ui-server packages/ui-sdkbun run test packages/ui-backend-claude packages/ui-backend-piprovider-history.test.tsbun run test ./testsintegration-command(1 + theposix_spawn 'git'error) andnative-diagram-inputs(4) fail identically onorigin/main.planning-observer(1) andbound-browser-documents(1) pass when rerun, 29/29 on both this head andorigin/main.turn-budgets,ws-turn-budgets,app-writes-offloopFocused tests.
app-writes-offloop.test.tsadds a budget case, under the same guards as #2884. The app connection refuses writes toturn_budget_usage,turn_budget_days,turn_budget_warningsandprovider_relay_usage; the worker refuses them below FULL. Every ledger method commits on the worker after an earlier Activity write. A blocking report held behind a slow worker commit is already counted whenproviderAdmissionanswers.turn-budgets.test.tsadds a case: a failed binding stops the turn's accounting.ws-turn-budgets.test.tsadds alatemode on the real Claude adapter path: the blocking report commits 400 ms after the backend has returned, and the turn must still end withbudget_exceededand keep its partial work. The existing budget tests now await the async ledger and useexpectRejectioninstead oftoThrow; their assertions are unchanged.Mutations (each applied on this head, its named tests run, restored)
APP_CONNECTION_WRITE turn_budget_usageobservecommits below FULLSQLITE_WRITE_BELOW_FULLproviderAdmissiondoes not wait for pending budget writesBUDGET_ADMISSION_WAITS_FOR_PENDING_WRITESBUDGET_FAILED_BIND_STOPS_ACCOUNTINGws-turn-budgetslatemode: noBUDGET_EXCEEDEDfailure frame, because the turn completedadmitturn-provider-relay.test.ts,FIRST_SEARCH_RELAYEDB5 survived on the first pass: no test held a block's commit past the backend's return. The
latemode was added for it and now kills it.Native hosted proof (
prove-native-host.ts, all four cells, underflockon the fixture lock,dd … conv=fsyncwriters outside the lock), on a tree identical to this head:schedule Git publication inherits active native peer folder admissionwith "invalid worker mount record": #2701, the failure #2884's run 1 also hitLag attribution (same method and cell as #2884: native pi UID 1000 under 3 fsync writers, 3 runs on this head).
origin/mainb192ca0c0, 2 runs): the budget writers had two windows ≥ 250 ms afterready, both their own FULL commits:begin(turn-budgets.ts:167, 302 ms) andobservethroughonUsage(run-session.ts:675, 366 ms). App-connection turns > 20 ms from the budget writers:onUsage/observe×7 (max 366 ms),begin×3 (max 302 ms),bind×6 and admissionwarnings×3 (max 156 ms, waiting). On fix(ui-server): commit governance events and turn scope on the write worker #2884's head they were still there (observe×11,bind×6,begin×3).turn-budgets.tsor theonUsagepath. The windows ≥ 250 ms left afterreadyare the session catalog (261 ms), the writer gate (227 ms own commit) and auth sessions (principals.ts), which are later groups.Checklist
.changeset/turn-budgets-off-event-loop.md)TurnBudgets, the relay'sadmithook andHostBoundaryOptions.providerRelayare 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, by the after-run attribution: the session catalog with message sources and session work (
session-catalog.ts,session-work.ts: up to 147 ms), the writer gate (227 ms own commit), auth sessions (principals.ts, the #2850 finding: 185 ms, mostly waiting), the Activity notifier tick (219 ms, waiting), and the rest of the inventory. Each is a following PR on this issue. The loaded native run's one claude UID 0 failure is #2701.🤖 Generated with Claude Code