Skip to content

fix(ui-server): commit the writer gate on the write worker - #2949

Merged
schlessera merged 3 commits into
mainfrom
fix/2850-writer-gate-offloop-044
Oct 26, 2026
Merged

schlessera merged 3 commits into
mainfrom
fix/2850-writer-gate-offloop-044

Conversation

@schlessera

@schlessera schlessera commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

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.db write worker at synchronous=FULL (gateProcedures, packages/ui-server/src/brain/writer-gate.ts; worker entry writer-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 still BEGIN 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. A HostFallbackRefusedError keeps its class across the worker.

What used to follow those writes synchronously now waits for them:

  • Release. 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's settled, storage removal, turn revert and shutdown (gate.settled()). A caller that does not wait never leaves a rejection unhandled.
  • Host fallback (refusal before payload). markUnpinnedHostWriter and 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.
  • Admission. retainFolders, assertPeerFolderPlans and rememberTypeFolders are awaited in governed-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:

  • The ungated check moves too. Keeping it app-side would have split the gate's last-valid policy history between two readers.
  • Its BEGIN IMMEDIATE stays. 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.
  • Release is awaitable, not fire-and-forget. StreamingExecution.settled and the cron runner promise that the admission has been released.
  • The post-await boundary recheck closes a new window that awaiting the downgrade would otherwise have opened.

No product decision was needed: existing fallback behaviour is kept.

Proof

Head 18113b2ab: origin/main c670449d3 (which now includes #2933) merged into the branch, no force-push. The only conflict was docs/decisions/sqlite-write-worker.md, where both slices' bullets are kept. Every heavy run went through the host slot wrapper.

Gate (on 18113b2ab) Result
bun run check:pr --base origin/main passed (755/755)
bun run test packages/ui-server 4056/4056
bun run test ./tests 1966/1966
focused: writer-gate-offloop, writer-gate, host-fallback-gates, private-index, ws-reference-worktrees, ws-turn-journal, app-writes-offloop 105/105
tsc --noEmit, bun run lint, decision-citations clean; 44/44
native hosted proof, all four cells, on this head no load: passed; 3 fsync writers: passed (both with the deliberate retained-index-namespace failure verified)

Earlier receipts, on the pre-merge head:

Head 151a36912: the change efb382a14 plus one test-only commit, on 2c83cd2f1. Every heavy run went through the host slot wrapper.

Gate Result
bun run check:pr --base origin/main on 151a36912 (and before on efb382a14) passed (754/754)
bun run test packages/ui-server on 151a36912 4006/4006
bun run test packages/ui-server on efb382a14 4004 pass, 2 fail of 4006. Both are post-turn reads that raced the turn's now-awaited gate release, and the test-only commit fixes them: ws-reference-worktrees (bridge authority read once generations retired) and ws-turn-journal (roadblocks read at the journal's complete frame; the same fix as #2933). Each now waits for the turn's slot to end, 3/3 after, and passes on origin/main as well.
bun run test ./tests on efb382a14 1959/1959
tsc --noEmit, bun run lint, decision-citations clean; 44/44
native hosted proof (prove-native-host.ts, all four cells, under flock on the fixture lock, dd … conv=fsync writers outside the lock), tree identical to efb382a14 no load: passed; 3 fsync writers: passed (both with the deliberate retained-index-namespace failure verified)

Focused tests (packages/ui-server/tests/writer-gate-offloop.test.ts, new; one new case in private-index.test.ts). App-connection triggers refuse writes to writer_gate, writer_gate_turn_waiters and provider_type_folders; the worker refuses any below FULL. The cases:

  • Every gate operation commits on the worker.
  • A release submitted while a slow worker commit holds the queue: not committed on the loop, does not settle before its commit, and counts for the process's next acquisition without a poll.
  • The downgrade settles only once its row says unpinned_host.
  • On both run and launch, a boundary that turns isolated while the downgrade waits refuses the host writer: the child never spawns and the hold is released.
  • A policy refusal keeps its class across the worker.
  • A type-folder record that fails refuses admission and releases its hold.

Existing tests now await the downgrade where they then assert its effect.

Mutations (each applied, its named test run, restored)

# Mutation Fails
G1 gate transactions on the app connection APP_CONNECTION_WRITE writer_gate
G2 a gate release commits below FULL SQLITE_WRITE_BELOW_FULL
G3 release submitted after the next acquisition GATE_NEXT_ACQUISITION_SEES_EARLIER_RELEASE
G4 release settles before its commit GATE_RELEASE_SETTLES_AFTER_ITS_COMMIT
G5 downgrade settles before its commit GATE_UNPINNED_COMMITTED_BEFORE_SETTLING
G6 run skips the post-downgrade boundary check GATE_HOST_REFUSED_WHEN_BOUNDARY_CHANGED_DURING_ADMISSION_run
G7 launch skips it …_launch
G8 refusal loses its class GATE_REFUSAL_CLASS_KEPT_ACROSS_WORKER
G9 type-folder record failure ignored TYPE_FOLDER_RECORD_FAILURE_REFUSES_ADMISSION
G10 host admission not awaited before spawn GATE_HOST_REFUSED_WHEN_BOUNDARY_CHANGED_DURING_ADMISSION_run

G2 was first written as a PRAGMA inside 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.

  • Before (origin/main 2c83cd2f1): 3 windows ≥ 250 ms after ready.
    • The writer gate's acquisition: 382 ms, waiting 380 ms behind a worker writeLifecycle (writer-gate.ts:211 ← governed-turn.ts:148).
    • A turn boundary (catalog, fix(ui-server): commit the session catalog on the write worker #2933): 400 ms.
    • The notifier tick: 261 ms.
    • App-connection turns > 20 ms from the gate: acquisition ×4 (max 388 ms) and release ×1 (221 ms, own commit).
  • After (this change): no app-connection turn from writer-gate.ts at all. The 2 windows after ready are an auth-session write (principals.ts, 398 ms, waiting behind writeLifecycle) 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

  • 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 writer gate; 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/writer-gate-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. (WriterGate, GateHold, markUnpinnedHostWriter and HostBoundary.runSync 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: 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's holders() diagnostic.

🤖 Generated with Claude Code

schlessera and others added 2 commits October 25, 2026 22:07
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]>
…offloop-044

# Conflicts:
#	docs/decisions/sqlite-write-worker.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