Skip to content

fix(ui-server): commit turn budget writes on the write worker - #2896

Merged
schlessera merged 1 commit into
mainfrom
fix/2850-turn-budgets-offloop-044
Oct 25, 2026
Merged

schlessera merged 1 commit into
mainfrom
fix/2850-turn-budgets-offloop-044

Conversation

@schlessera

@schlessera schlessera commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

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 targets main, 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, session bind, completed-response observe, relayed-provider observeProvider, warnings and the SDK backstop) now commits on the ui.db write worker at synchronous=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:

  • The backend's priced usage. onUsage still 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.
  • Relayed provider admission. providerAdmission waits 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's admit hook may now return a promise, which it awaits.
  • The turn's outcome. A turn awaits its pending budget writes, and what it does with them, before it decides its outcome, so a block that commits after the backend has returned still ends the turn with budget_exceeded.
  • Session binding. A failed bind makes the turn's accounting unavailable: its next observation stops the turn with budget_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 its main refreshes (no force-push; only doc-citation conflicts, re-pointed). Final head 50354cf50, onto 6814f2ce5.

Gate Result
bun run check:pr --base origin/main on 50354cf50 passed (948/948)
tsc --noEmit, bun run lint, decision-citations on 50354cf50 clean; 44/44
focused files on 50354cf50: 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-journal 126/126
bun run test packages/ui-server packages/ui-sdk on 39ade52d2 (one main commit earlier, #2888) 5204/5204
bun run test packages/ui-backend-claude packages/ui-backend-pi on 39ade52d2 1270/1270
bun run test ./tests on 39ade52d2 1950 pass, 4 fail of 1954. The 4 are native-diagram-inputs, which fails identically on origin/main.
pack smoke on 39ade52d2 passed
native hosted proof on 39ade52d2, no load / 3 fsync writers both passed, all four cells (the deliberate retained-index-namespace failure verified)

The 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's 365e82960, on b192ca0c0 (#2830 merged). Per the stack rule, check:pr ran once, on this top layer, against origin/main.

Local gates

Gate Result
bun run check:pr --base origin/main passed (948/948)
bun scripts/check-packages.ts (pack smoke) passed
tsc --noEmit, bun run lint, tests/decision-citations.test.ts clean; 44/44
bun run test packages/ui-server packages/ui-sdk 5125/5125
bun run test packages/ui-backend-claude packages/ui-backend-pi 1254/1254, including provider-history.test.ts
bun run test ./tests 1946 pass, 7 fail, 1 error of 1953. integration-command (1 + the posix_spawn 'git' error) and native-diagram-inputs (4) fail identically on origin/main. planning-observer (1) and bound-browser-documents (1) pass when rerun, 29/29 on both this head and origin/main.
Focused rerun: turn-budgets, ws-turn-budgets, app-writes-offloop 21/21

Focused tests. app-writes-offloop.test.ts adds a budget case, under the same guards as #2884. The app connection refuses writes to turn_budget_usage, turn_budget_days, turn_budget_warnings and provider_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 when providerAdmission answers. turn-budgets.test.ts adds a case: a failed binding stops the turn's accounting. ws-turn-budgets.test.ts adds a late mode on the real Claude adapter path: the blocking report commits 400 ms after the backend has returned, and the turn must still end with budget_exceeded and keep its partial work. The existing budget tests now await the async ledger and use expectRejection instead of toThrow; their assertions are unchanged.

Mutations (each applied on this head, its named tests run, restored)

# Mutation Fails
B1 budget writes run on the app connection the new budget case, on APP_CONNECTION_WRITE turn_budget_usage
B2 observe commits below FULL same case, SQLITE_WRITE_BELOW_FULL
B3 providerAdmission does not wait for pending budget writes BUDGET_ADMISSION_WAITS_FOR_PENDING_WRITES
B4 a failed binding leaves the accounting available BUDGET_FAILED_BIND_STOPS_ACCOUNTING
B5 the turn's outcome does not wait for its budget writes ws-turn-budgets late mode: no BUDGET_EXCEEDED failure frame, because the turn completed
B6 the provider relay does not await admit turn-provider-relay.test.ts, FIRST_SEARCH_RELAYED

B5 survived on the first pass: no test held a block's commit past the backend's return. The late mode was added for it and now kills it.

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 this head:

Load Result
none passed (all four cells; the deliberate retained-index-namespace failure verified)
3 fsync writers, run 1 claude UID 0 failed schedule Git publication inherits active native peer folder admission with "invalid worker mount record": #2701, the failure #2884's run 1 also hit
3 fsync writers, run 2 passed, all four cells

Lag attribution (same method and cell as #2884: native pi UID 1000 under 3 fsync writers, 3 runs on this head).

  • Before (origin/main b192ca0c0, 2 runs): the budget writers had two windows ≥ 250 ms after ready, both their own FULL commits: begin (turn-budgets.ts:167, 302 ms) and observe through onUsage (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 admission warnings ×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).
  • After (this head, 3 runs): no app-connection turn from turn-budgets.ts or the onUsage path. The windows ≥ 250 ms left after ready are the session catalog (261 ms), the writer gate (227 ms own commit) and auth sessions (principals.ts), which are later groups.

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 budget writers; 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/turn-budgets-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: TurnBudgets, the relay's admit hook and HostBoundaryOptions.providerRelay are internal.)
  • 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, 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

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]>
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