Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/turn-budgets-off-event-loop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@schlessera/brain-ui-server": patch
"@schlessera/brain-ui-sdk": patch
---

Changed: the hosted server commits every turn-budget ledger write (admission, session binding, completed-response and relayed-provider usage, warnings and the SDK backstop) on its SQLite write worker at `synchronous=FULL`, in submission order, instead of on the event loop (#2850). A relayed provider request is admitted only once every earlier budget write has committed.
Changed: the provider relay awaits a promise returned from its `admit` hook.
10 changes: 5 additions & 5 deletions docs/decisions/governed-writable-workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,7 @@ the key and forwards it as the turn's own egress (G5). A key goes only to its
built-in first-party origin, or to an exact origin the operator lists for that
key in `BRAIN_UI_CLI_PROVIDER_ORIGINS`, under the 2026-10-23 ruling on #1673;
the backends' origin rulings grant the CLI nothing by analogy
(`startProviderRelay`, `packages/ui-sdk/src/server/provider-relay.ts:252-257`).
(`startProviderRelay`, `packages/ui-sdk/src/server/provider-relay.ts:255-260`).
The inference relay's placeholder is not a key: the launcher names a key that
holds it as withheld, so a relayed Claude API-key turn's `anthropic-haiku`
reaches the host's key instead of failing authentication. Video and the `jev`
Expand Down Expand Up @@ -1443,7 +1443,7 @@ unmet rather than relaxed.

**Why.** Every governed turn queues an automatic index refresh after it
(`host.governedTurns?.refresh(host.workerLock)`,
`packages/ui-server/src/ws/run-session.ts:909`). The refresh usually finishes
`packages/ui-server/src/ws/run-session.ts:930`). The refresh usually finishes
while the next turn runs, so that turn's finish observation found a rewritten
`brain.db` and copied a whole new version of it into retained objects, inside
the observation's worker hold. #2831 measured 36 versions of a ~120 MB index in
Expand Down Expand Up @@ -1568,15 +1568,15 @@ covers everything.
non-video completions, Anthropic completions, and OpenAI and Gemini images.
Each upstream attempt is recorded once, with the units the provider reported,
and counts toward the turn, conversation and UTC-day scopes
(`observeProvider`, `packages/ui-server/src/brain/turn-budgets.ts:236-253`).
(`observeProvider`, `packages/ui-server/src/brain/turn-budgets.ts:195-209`).
The receipt's coverage names the relayed providers. Pricing follows the
[cost-tracking rules](cost-tracking.md#relayed-cli-provider-requests-1673):
unpriced tokens count toward the token stops, and missing usage is unknown and
never blocks by itself. An attempt is recorded as unknown spend just before it
leaves and completed when it settles, so a crash leaves it disclosed. Before
each request, and again just before dispatch, the relay asks whether any scope
is blocked and refuses with `budget_exceeded` if one is
(`providerAdmission`, `packages/ui-server/src/brain/turn-budgets.ts:224-228`).
(`providerAdmission`, `packages/ui-server/src/brain/turn-budgets.ts:326-330`).
A request that crosses a block does not stop the turn by itself, under the
ruling: each further relayed request is refused, and the agent's next
completed response, which sees the same scopes, stops the turn as any usage
Expand Down Expand Up @@ -1616,7 +1616,7 @@ rows remain historical inputs with explicit legacy attribution. Completed
responses contribute once to their server-observed UTC day; legacy history
uses its recorded start day. A terminal rollup never adds the live ledger a
second time. The concrete ledger is (`createTurnBudgets`,
`packages/ui-server/src/brain/turn-budgets.ts:64-252`).
`packages/ui-server/src/brain/turn-budgets.ts:228-350`).
Known priced lower bounds survive an unpriced model joining;
unknown usage alone never blocks. A token fallback remains enforceable.

Expand Down
8 changes: 4 additions & 4 deletions docs/decisions/http-api-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ explicit supported rows, not silently excluded by this distinction.

The source audit on 2026-09-30 used
`e0875269b21b707482545be9e94901acb897893b`. The public health mount precedes auth
(`createHealthRoutes`, `packages/ui-server/src/app.ts:856`), the API guard follows
public ceremonies (`authGuard`, `packages/ui-server/src/app.ts:877`), and the
WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:1066`).
(`createHealthRoutes`, `packages/ui-server/src/app.ts:857`), the API guard follows
public ceremonies (`authGuard`, `packages/ui-server/src/app.ts:878`), and the
WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:1067`).
Static serving is conditional (`if (options.staticRoot)`,
`packages/ui-server/src/app.ts:1086-1107`). That order establishes access;
`packages/ui-server/src/app.ts:1087-1108`). That order establishes access;
it does not select compatibility status.

A hermetic real-app launch, with temporary brain root, in-memory database,
Expand Down
4 changes: 2 additions & 2 deletions docs/decisions/managed-worktrees.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ unknown; what happens next is in [hygiene and cleanup](#hygiene-and-cleanup).
Each governed or strict turn whose journal is recording opens one worker
generation, and every worker of that turn binds the generation's root-owned
directory whole at `/tmp/brain-worktrees`, inside the turn's own `/tmp`
(`// Managed worktrees (#2430): a journaled turn's workers bind one empty generation.`, `packages/ui-server/src/ws/run-session.ts:566-578`;
(`// Managed worktrees (#2430): a journaled turn's workers bind one empty generation.`, `packages/ui-server/src/ws/run-session.ts:567-579`;
`// Managed worktrees (#2430): the whole access generation`, `packages/ui-sdk/src/server/worker-launcher.ts:848-859`).
A granted worktree appears there as `/tmp/brain-worktrees/<worktree id>/` on the
worker's next path lookup; the worker cannot create entries beside it, and a
Expand Down Expand Up @@ -199,7 +199,7 @@ The host side is `createReferenceTools`
start, the host records its principal and what those workers bind: the public
view, the principal's private view and the turn's worker generation, if one
opened
(`// Reference tools (#2432): the turn's workers bind the principal's source`, `packages/ui-server/src/ws/run-session.ts:579-585`).
(`// Reference tools (#2432): the turn's workers bind the principal's source`, `packages/ui-server/src/ws/run-session.ts:580-586`).
A call carries only a repository, a revision or a worktree identity. The bridge
adds the turn's own principal, and a call for a turn the host did not prepare,
or under another principal, refuses
Expand Down
8 changes: 4 additions & 4 deletions docs/decisions/session-principals.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,13 @@ Both reviewers verified every row below against the source.
| Cookie verify | `auth.ts:265-296` | strict `^(\d+)\.(\d+)$`, safe integers, future/expired rejection, `cookieEpoch === sessionsEpoch(db)` |
| Global epoch | `auth.ts:302-318` | `settings` row; missing = 0; non-integer **throws** |
| Global revoke | `auth.ts:321-330` | `setSetting` (overflow-guarded) + `clients.closeAll(1008, …)` |
| Auth routes | `authRoutes`, `app.ts:862` | mounted **before** the guard at `authGuard(authMode`, `app.ts:877`; logout guards itself (`resolveCookiePrincipal(c, auth, deps.db)`, `auth.ts:808`) |
| Passkey management | `passkeyManagementRoutes`, `app.ts:887` | mounted **after** the guard ("Mount AFTER the auth guard") |
| WS guard | `"/ws"`, `app.ts:1066-1083` | origin → `isWsAuthorized` (returns a boolean, `auth.ts:221-237`) → capacity → upgrade |
| Auth routes | `authRoutes`, `app.ts:863` | mounted **before** the guard at `authGuard(authMode`, `app.ts:878`; logout guards itself (`resolveCookiePrincipal(c, auth, deps.db)`, `auth.ts:808`) |
| Passkey management | `passkeyManagementRoutes`, `app.ts:888` | mounted **after** the guard ("Mount AFTER the auth guard") |
| WS guard | `"/ws"`, `app.ts:1067-1084` | origin → `isWsAuthorized` (returns a boolean, `auth.ts:221-237`) → capacity → upgrade |
| WS upgrade | `ws/connection.ts:262-264` | `createWsUpgrade` ignores the request context |
| WS admission | `ws/connection.ts:84` | `clients.add(ws)` — the socket's identity is unknown |
| Turn record | `ws/run-session.ts:136-144` | recorder built from `{turnId, sessionId, billing}`; `RunningTurn` (`ws/turns.ts:55-77`) has no actor |
| Follow-up queue | `QueuedFollowUp`, `ws/turns.ts:61-103`; `slot.queue.push(entry)`, `run-session.ts:1100` | queued entries re-mint `turnId` in the same slot and may come from another socket |
| Follow-up queue | `QueuedFollowUp`, `ws/turns.ts:61-103`; `slot.queue.push(entry)`, `run-session.ts:1121` | queued entries re-mint `turnId` in the same slot and may come from another socket |
| Password login | `acquirePasswordVerification(key)`, `auth.ts:749` | argon2id verify, failure counting, in-flight reservation, then `issueSessionCookie` |
| Passkey login | `issueLoginSession`, `packages/ui-server/src/middleware/passkeys.ts:448` | assertion verified, then `issueSessionCookie`; `row.id` is in scope |
| Passkey **registration** | `INSERT INTO passkey_credentials`, `passkeys.ts:547` | inserts a credential; calls **neither** helper |
Expand Down
11 changes: 10 additions & 1 deletion docs/decisions/sqlite-write-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,17 @@ thread waits for it.
summary the client hears of is sent once its row commits. A process without
a write worker, such as the cron runner, runs the same procedures on its own
connection.
- **Turn budgets followed** (#2850). Every ledger write (admission,
session binding, completed-response and relayed-provider observations,
warnings, the SDK backstop) commits on the worker, with the receipt it
computes read inside the same transaction
(`budgetLedger`, `packages/ui-server/src/brain/turn-budgets.ts`). A report
is priced where it arrives, so the backend still gets the turn's list price
at once; its warnings and any block follow the commit. A relayed provider
request is admitted only after every budget write submitted before it has
committed, and a turn's outcome waits for its budget writes.
- **Other app-connection writers stay on the loop for now**: the writer gate,
turn budgets, the session catalog and message sources, the Activity
the session catalog and message sources, the Activity
notifier and push ticks, auth sessions, inbox and settings. Each commits at
FULL on the loop, and while a worker procedure holds its turn, such a write
also waits there for the worker's commit to finish, including a journal
Expand Down
2 changes: 1 addition & 1 deletion docs/hosting/paid-cli-provider-qualification.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ restart/retry controls, rather than relying on a worker-supplied counter.

Budget coverage excluded provider/tool spend until #1673; it now names the
relayed CLI providers and nothing else
(`TURN_BUDGET_COVERAGE`, `packages/ui-server/src/brain/turn-budgets.ts:45`).
(`TURN_BUDGET_COVERAGE`, `packages/ui-server/src/brain/turn-budgets.ts:46`).
Only qualified recorded units may advance that disclosure. Before activation,
prove refusal before the next dispatch once the applicable known budget is
exhausted, alongside native cancellation/cleanup and deduplication. Exact advance
Expand Down
4 changes: 2 additions & 2 deletions docs/plans/async-collaboration-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ Stated before the requirements because five of them derive from it.
that is the shape to copy, including its `close()` lifecycle.
- R19. **The cron backstop has independent authorization before the general guard.**
Mount it on the existing listener before
(`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:877`).
(`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:878`).
Authorize a boot-minted ephemeral token, rotated each boot and stored in a
0600 runtime file, with the actual socket address as an additional check.
Proxy headers cannot authorize it. The poke succeeds in every auth mode
Expand Down Expand Up @@ -256,7 +256,7 @@ Stated before the requirements because five of them derive from it.
(`export function createTurnRecorder(`, `packages/ui-server/src/activity/recorder.ts:125-203`).
- R25. **Autonomous work gets its own pool** (`MAX_AUTONOMOUS_RUNS`, default 2) — but a second
counter alone does not deliver "interactive always wins". The host cap applies only when
starting WS sessions (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1310-1322`), and an autonomous
starting WS sessions (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1331-1343`), and an autonomous
turn can hold a path write lock while an interactive turn waits or is denied at 30 seconds
(`export function createTurnLockBinding(`, `packages/ui-backend-claude/src/turn-lock.ts:27-119`). Required: an admission controller
with reserved interactive capacity and hybrid yield at an explicit denial-risk
Expand Down
6 changes: 3 additions & 3 deletions docs/plans/async-collaboration.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ system gate. It is not a prerequisite to rebuild or a new independent package.
owns manual triggers/history; it does not tick the inbox.
- **Two connections:** (`export function createUiDb(`, `packages/ui-server/src/db/client.ts:27-44`) sets WAL,
foreign keys and a 5-second busy timeout. Claims are immediate transactions.
- **Auth mounting:** (`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:877`) follows public routes;
- **Auth mounting:** (`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:878`) follows public routes;
(`export function authGuard(`, `packages/ui-server/src/middleware/auth.ts:189-249`) binds principals in each auth mode.
An internal poke needs independent token authorization before this guard.
- **Permission parking:** (`requestPermission: (req) => {`, `packages/ui-server/src/ws/bridge.ts:354-448`). Timeout unwind is
Expand All @@ -156,7 +156,7 @@ system gate. It is not a prerequisite to rebuild or a new independent package.
- **Share provenance/limits:** (`const result = await stageShareAt(`, `packages/ui-server/src/inbox/intake.ts:106`) assigns the source in server code.
(`export const SHARE_MAX_TEXT_BYTES =`, `packages/ui-sdk/src/protocol.ts:2348`) bounds text, not binary uploads;
extracted T1 context needs its own byte/token bound.
- **Interactive locks:** (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1310-1322`) gates WS starts;
- **Interactive locks:** (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1331-1343`) gates WS starts;
(`export function createTurnLockBinding(`, `packages/ui-backend-claude/src/turn-lock.ts:27-119`) owns tool locks.
Shared-target priority and cooperative yield belong to the keyed lock and backend lifecycle.

Expand Down Expand Up @@ -1274,7 +1274,7 @@ full-v1 enablement; predicates and schema tests cannot substitute for it.

**Approach:** **hybrid — reserve capacity normally, yield only at denial risk.**
`MAX_AUTONOMOUS_RUNS` (default 2) is necessary but not sufficient: the host cap applies only at
WS session start (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1310-1322`) and an autonomous turn can hold a path write lock
WS session start (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1331-1343`) and an autonomous turn can hold a path write lock
while an interactive turn waits or is denied at 30s
(`export function createTurnLockBinding(`, `packages/ui-backend-claude/src/turn-lock.ts:27-119`).

Expand Down
4 changes: 2 additions & 2 deletions docs/plans/navigation-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,9 +397,9 @@ implementation. Any PR moving these lines owns their citation repairs.

- Activity resolution: (`.get("/activity/runs", (c) => {`, `packages/ui-server/src/routes/activity.ts:143-281`).

- API authorization: (`app.use("/api/*", authGuard(authMode, auth, db));`, `packages/ui-server/src/app.ts:877`).
- API authorization: (`app.use("/api/*", authGuard(authMode, auth, db));`, `packages/ui-server/src/app.ts:878`).

- Queued acceptance: (`// Queue it as the session's next turn; report queued immediately.`, `packages/ui-server/src/ws/run-session.ts:1077-1122`).
- Queued acceptance: (`// Queue it as the session's next turn; report queued immediately.`, `packages/ui-server/src/ws/run-session.ts:1098-1143`).

- Approval target: (`function mutateLastAssistant(`, `packages/ui-react/src/stores/chat-state.ts:1224-1254`).

Expand Down
11 changes: 7 additions & 4 deletions packages/ui-sdk/src/server/provider-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,11 @@ export interface ProviderRelayOptions {
credential?(name: string): string | undefined;
/** The concrete turn proxy: share its destination admission, byte ceiling and connection records. */
egress?: Pick<Awaited<ReturnType<typeof startEgressProxy>>, "relayed">;
/** Called before each dispatch. A refusal is answered with `budget_exceeded` and nothing is sent. */
admit?(): { allowed: true } | { allowed: false; message: string };
/**
* Called before each dispatch, and awaited. A refusal is answered with
* `budget_exceeded` and nothing is sent.
*/
admit?(): { allowed: true } | { allowed: false; message: string } | Promise<{ allowed: true } | { allowed: false; message: string }>;
/** One receipt per dispatched attempt. A throw is reported through `failed`, never to the worker. */
observe?(receipt: ProviderRelayReceipt): void;
refused?(refusal: ProviderRelayRefusal): void;
Expand Down Expand Up @@ -315,7 +318,7 @@ export function startProviderRelay(options: ProviderRelayOptions = {}): Provider
// Header placement trims surrounding whitespace: redact and send the same value.
const secret = credential(key)?.trim();
if (!secret || /[\r\n]/.test(secret)) return refuse(403, "credential_unavailable", `The host has no ${key} to relay`);
const budget = options.admit?.() ?? { allowed: true as const };
const budget = await options.admit?.() ?? { allowed: true as const };
if (!budget.allowed) return refuse(403, "budget_exceeded", budget.message);
const target = new URL(path, origin);
const operation = options.egress?.relayed(request.method, target, request.signal);
Expand Down Expand Up @@ -393,7 +396,7 @@ export function startProviderRelay(options: ProviderRelayOptions = {}): Provider
};
if (operation && !operation.charge(headerBytes(`POST ${path} HTTP/1.1`, headers))) return denied("max_bytes_per_turn");
// Admission again, after every wait: a scope may have blocked meanwhile.
const now = options.admit?.() ?? { allowed: true as const };
const now = await options.admit?.() ?? { allowed: true as const };
if (!now.allowed) {
operation?.finish("denied", "budget_exceeded");
return refuse(403, "budget_exceeded", now.message);
Expand Down
3 changes: 2 additions & 1 deletion packages/ui-server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,8 @@ export async function createApp(options: CreateAppOptions = {}): Promise<BrainUi
index: lock => brain.index({ lock }).catch(ignoreShutdown), report: message => brainLog.emit({ severityText: "WARN", body: message }),
exposures: createProviderExposures(db), ...(config.providerRecipientOrigins ? { recipientOrigins: config.providerRecipientOrigins } : {}), indexBatch });
bootCleanup.push(() => governedTurns.close());
const budgets = createTurnBudgets(db, pricing);
// Budget writes commit on the write worker (#2850).
const budgets = createTurnBudgets(db, pricing, Date.now, writer);
relayBudgets = budgets;
const sessionProfiles = createSessionProfiles(db, governedTurns, budgets, { hardTimeoutMs: () => host.turnTimeoutMs });
const journal = createTurnJournal(db, { nativeManager, brainPath: config.brainPath, dbPath: config.dbPath, objectsRoot: journalRoot, writer,
Expand Down
2 changes: 1 addition & 1 deletion packages/ui-server/src/brain/host-boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export interface HostBoundaryOptions {
*/
providerRelay?: {
origins?: ReadonlyMap<string, readonly string[]>;
admit(turnId: string | null): { allowed: true } | { allowed: false; message: string };
admit(turnId: string | null): { allowed: true } | { allowed: false; message: string } | Promise<{ allowed: true } | { allowed: false; message: string }>;
observe(turnId: string | null, attempt: ProviderRelayReceipt): void;
};
profile?: "governed" | "strict";
Expand Down
Loading
Loading