Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f1159db
CONTRACT: fix(ui-server): commit journal, Activity and native-lease S…
schlessera Oct 24, 2026
9ac2287
Merge remote-tracking branch 'origin/main' into fix/2814-sqlite-commi…
schlessera Oct 24, 2026
a2f5567
fix(ui-server): retry an app-connection BEGIN refused beside a commit…
schlessera Oct 24, 2026
ab0c6cf
fix(ui-server): app transactions and write-worker procedures take tur…
schlessera Oct 24, 2026
600b77a
docs: the turn lock in the write-worker decision record (#2814)
schlessera Oct 24, 2026
724994a
Merge remote-tracking branch 'origin/main' into fix/2814-sqlite-commi…
schlessera Oct 24, 2026
debb85a
docs: refresh code citations after merging main (#2814)
schlessera Oct 24, 2026
e2c0633
fix(ui-server): app autocommit writes take their turn beside the writ…
schlessera Oct 24, 2026
9c58a2e
refactor(ui-server): journalCheck builds refusal checks (#2814)
schlessera Oct 24, 2026
9f78ec1
CONTRACT: docs: journal and Activity write surfaces under ruling 1 on…
schlessera Oct 24, 2026
d0cc66e
fix(ui-server): release turn authority before awaiting its Activity w…
schlessera Oct 24, 2026
d59f57b
Merge remote-tracking branch 'origin/main' into fix/2814-sqlite-commi…
schlessera Oct 24, 2026
74a2fb9
fix(ui-server): the write worker owns journal.db beside the UI databa…
schlessera Oct 24, 2026
25829f6
test(ui-server): await journal startup before asserting on it (#2814)
schlessera Oct 24, 2026
633663d
test(ui-server): a queued turn waits for the previous turn's Activity…
schlessera Oct 24, 2026
b92154f
Merge remote-tracking branch 'origin/main' into fix/2814-sqlite-commi…
schlessera Oct 24, 2026
ea17586
test(ui-server): index outcomes commit on the write worker before the…
schlessera Oct 24, 2026
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
9 changes: 9 additions & 0 deletions .changeset/sqlite-commits-off-event-loop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@schlessera/brain-ui-server": minor
"@schlessera/brain-ui-sdk": minor
"@schlessera/brain-backend-claude": patch
---

Changed: the hosted server commits turn-journal, Activity and native-lease SQLite writes on a write worker thread at `synchronous=FULL`, in submission order; the event loop awaits each commit instead of blocking in its fsync (#2814).
Changed (breaking for direct `TurnJournal` callers): journal writes that callers wait on (`bindSession`, `writeOperation`, Claude memory persistence, revert reservation, retained-version leases and retained streams) are asynchronous; `writeOperation` takes statements, and the journal exposes `ready` for its startup recovery.
Added: `StartTurnRequest.persistClaudeMemory` may return a promise; the Claude backend awaits it before persisting the next note or ending the turn.
66 changes: 43 additions & 23 deletions api-report/ui-server.txt
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ signatures (every public export and the types it is made of)
subscriptionCount(): number;
close(): void;
}
ActivityWriteMethod (packages/ui-server/src/activity/store.ts)
export type ActivityWriteMethod = "writeLifecycle" | "startSpan" | "endSpan" | "patchSpan" | "appendEvent" | "heartbeat" | "cascadeClose" | "sweepOwnOrphans" | "sweepStale" | "rollupRun" | "prune";
AgentConfig (packages/ui-server/src/config/env.ts)
export interface AgentConfig {
backend: string | null;
Expand Down Expand Up @@ -214,6 +216,12 @@ signatures (every public export and the types it is made of)
retain(): () => void;
release(): void;
}
Awaitable (packages/ui-server/src/activity/store.ts)
type Awaitable<T> = T | Promise<T>;
AwaitableActivityStore (packages/ui-server/src/activity/store.ts)
export type AwaitableActivityStore = Omit<ActivityStore, ActivityWriteMethod> & {
[K in ActivityWriteMethod]: (...args: Parameters<ActivityStore[K]>) => Awaitable<ReturnType<ActivityStore[K]>>;
};
BackendRegistry (packages/ui-server/src/agent/backend.ts)
export interface BackendRegistry {
getBackends(): Promise<AgentBackend[]>;
Expand Down Expand Up @@ -455,7 +463,7 @@ signatures (every public export and the types it is made of)
log?: Logger;
meter?: Meter;
activity?: {
store: Pick<ActivityStore, "startSpan" | "endSpan" | "rollupRun">;
store: Pick<AwaitableActivityStore, "startSpan" | "endSpan" | "rollupRun">;
onWrite?: () => void;
};
}
Expand Down Expand Up @@ -661,6 +669,15 @@ signatures (every public export and the types it is made of)
}
InboxIntake (packages/ui-server/src/inbox/intake.ts)
export type InboxIntake = ReturnType<typeof createInboxIntake>;
JournalStatement (packages/ui-server/src/brain/journal-store.ts)
export type JournalStatement = {
sql: string;
params?: SQLQueryBindings[];
} | {
check: string;
params?: SQLQueryBindings[];
refusal: string;
};
LABEL_MAX_CHARS (packages/ui-server/src/labels/normalise.ts)
const LABEL_MAX_CHARS: 32;
LABEL_RUN_NAME (packages/ui-server/src/labels/labeller.ts)
Expand Down Expand Up @@ -1260,6 +1277,18 @@ signatures (every public export and the types it is made of)
}
TurnJournal (packages/ui-server/src/brain/turn-journal.ts)
export type TurnJournal = ReturnType<typeof createTurnJournal>;
TurnJournalOptions (packages/ui-server/src/brain/turn-journal.ts)
export interface TurnJournalOptions {
brainPath: string;
dbPath: string;
objectsRoot: string;
capBytes?: number;
now?: () => number;
nativeManager: NativeJournalManager;
referencePool?: ReferenceWorktreeJournalPool;
referenceCheckpoints?: ReferenceWorktreeCheckpointLedger;
changed?: (turnId: string, sessionId: string, summary: TurnJournalSummary) => void;
}
VoiceConfig (packages/ui-server/src/config/env.ts)
export interface VoiceConfig {
provider: string | null;
Expand Down Expand Up @@ -1643,17 +1672,7 @@ signatures (every public export and the types it is made of)
log?: Logger;
}): BackendRegistry;
createTurnJournal (packages/ui-server/src/brain/turn-journal.ts)
export function createTurnJournal(db: Database, options: {
brainPath: string;
dbPath: string;
objectsRoot: string;
capBytes?: number;
now?: () => number;
nativeManager: NativeJournalManager;
referencePool?: ReferenceWorktreeJournalPool;
referenceCheckpoints?: ReferenceWorktreeCheckpointLedger;
changed?: (turnId: string, sessionId: string, summary: TurnJournalSummary) => void;
}): {
export function createTurnJournal(db: Database, options: TurnJournalOptions): {
attachWorktree(turnId: string, worktreeId: string, signal?: AbortSignal): Promise<void>;
begin(turnId: string, metadata: {
actor: "interactive" | "user" | "voice";
Expand All @@ -1664,7 +1683,7 @@ signatures (every public export and the types it is made of)
sessionId: null | string;
}, stop: (error: Error) => void, signal?: AbortSignal): Promise<void>;
bindModel(turnId: string, sessionId: null | string, model: string): boolean;
bindSession(turnId: string, sessionId: string): void;
bindSession(turnId: string, sessionId: string): Promise<void>;
claudeMemoryTarget(turnId: string, path: string): null | {
device: string;
directory: string;
Expand All @@ -1675,12 +1694,12 @@ signatures (every public export and the types it is made of)
coverPaths(turnId: string, paths: readonly string[]): void;
details(sessionId: string, turnId: string): TurnJournalDetails | null;
finish(turnId: string): Promise<void>;
finishReserved(turnId: string): void;
finishReserved(turnId: string): Promise<void>;
hasRetainedVersion(turnId: string, selected: JournalVersion): boolean;
idle(): Promise<void>;
isHeld(): boolean;
observe(): Promise<void>;
observeUserPolicy(turnId: string, approvedWrite: () => void): Promise<void>;
observeUserPolicy(turnId: string, approvedWrite: () => Promise<void> | void): Promise<void>;
onObservation(observer: (turnId: string, facts: {
changedPaths: number;
observedBytes: number;
Expand All @@ -1695,29 +1714,30 @@ signatures (every public export and the types it is made of)
beforeMode: number;
directory: string;
note: string;
}, save: () => void): void;
}, save: () => void): Promise<void>;
ready: Promise<void>;
recordClaudeMemory(turnId: string, path: string, target: {
device: string;
directory: string;
inode: string;
note: string;
}, before: JournalVersion | null, after: JournalVersion | null): void;
}, before: JournalVersion | null, after: JournalVersion | null): Promise<void>;
removalCoverage(paths: readonly string[], signal?: AbortSignal, absent?: boolean): Promise<boolean[]>;
reserveRevert(turnId: string, sourceTurnId: string, versions: readonly (JournalVersion | null)[]): void;
reserveRevert(turnId: string, sourceTurnId: string, versions: readonly (JournalVersion | null)[]): Promise<void>;
retainBefore(turnId: string, path: string, signal?: AbortSignal): Promise<JournalVersion | null>;
summary: (turnId: string) => TurnJournalSummary;
version(sessionId: string, turnId: string, sequence: number, side: "after" | "before"): null | {
bytes: (maxBytes: number) => Buffer<ArrayBufferLike>;
selected: JournalVersion;
stream(): {
stream(): Promise<{
body: ReadableStream<Uint8Array>;
bytes: number;
};
}>;
};
withRetainedRemoval<T>(turnId: string, selected: JournalVersion, apply: (copy: (fd: number) => void) => T): T;
withRetainedVersion<T>(selected: JournalVersion | null, apply: (copy: (fd: number) => void) => T): T;
withRetainedRemoval<T>(turnId: string, selected: JournalVersion, apply: (copy: (fd: number) => void) => T): Promise<T>;
withRetainedVersion<T>(selected: JournalVersion | null, apply: (copy: (fd: number) => void) => T): Promise<T>;
worktrees(turnId: string): string[];
writeOperation<T>(write: () => T): T;
writeOperation(statements: readonly JournalStatement[]): Promise<number[]>;
};
inspect (packages/ui-server/src/inbox/resolve.ts)
function inspect(principalId: string, msg: ClientInboxResolve): {
Expand Down
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ somebody already learn the hard way?*
| [action-notifications.md](action-notifications.md) | Why Action notices use fixed cross-thread batches, client-local quiet/digest schedules, new waiting episodes and durable per-destination submission/coverage history. |
| [hygiene-review.md](hygiene-review.md) | Why async hygiene review orders eligible findings by severity/known urgency, merges equivalent source reports and invalidates dispositions only on relevant evidence changes. |
| [agent-observability.md](agent-observability.md) | Runs, spans, the Activity surface, the failure inbox and the digest. |
| [sqlite-write-worker.md](sqlite-write-worker.md) | Why journal, Activity and native-lease commits run at `synchronous=FULL` on a per-database write worker thread, in submission order, while the event loop awaits them. |
| [cost-tracking.md](cost-tracking.md) | List price versus effective cost, and why unknown cost is never rendered as zero. |
| [design-feedback.md](design-feedback.md) | The design defects building the kit found, the measurement behind each, and the ruling that settled it. Cited by section number from `packages/ui-kit` source. |
| [geo-operations.md](geo-operations.md) | Why one independent concrete geo library owns shared operations, with strict travel compatibility and explicit recovered-track evidence. |
Expand Down
6 changes: 3 additions & 3 deletions docs/decisions/async-collaboration.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ requires one enforcement mechanism with different named memberships for voice
and unattended work. Keep that requirement when implementing availability
control. Existing mandatory backend conformance supports safe rejection of an
unsupported restricted request; silently ignoring it is forbidden. The current
optional inputs are (`enforceAllowedTools?: boolean`, `packages/ui-sdk/src/server/backend.ts:586`) and (`noGrantSurface?: boolean`, `packages/ui-sdk/src/server/backend.ts:610`). These are permission primitives,
optional inputs are (`enforceAllowedTools?: boolean`, `packages/ui-sdk/src/server/backend.ts:589`) and (`noGrantSurface?: boolean`, `packages/ui-sdk/src/server/backend.ts:613`). These are permission primitives,
not a claim of filesystem or network containment.

The ordinary Claude assembly loads project settings and appends bridge tools
Expand Down Expand Up @@ -204,7 +204,7 @@ permission and unwinds the turn. Already completed side effects are not undone
by an abort. Attempt staging and cleanup therefore need idempotent recovery,
with filesystem compensation outside SQLite transactions. Ordinary permission
parking still exists (`requestPermission: (req) => {`,
`packages/ui-server/src/ws/bridge.ts:310-403`); it is not a durable Action store.
`packages/ui-server/src/ws/bridge.ts:311-404`); it is not a durable Action store.

Resolution validates the stored effect again, checks current authority, records
one resolution and applies the guarded state transition in one transaction.
Expand Down Expand Up @@ -241,7 +241,7 @@ suppressed-per-model Action; unknown usage cannot become a guessed zero.
Subscription billing moves the turn counter even when spend is zero. The activity
sum exposes unpriced runs (`sumEffectiveCost`,
`packages/ui-server/src/activity/store.ts:179-194`) and settles only after execution
(`store.rollupRun(runId);`, `packages/ui-server/src/activity/recorder.ts:705`).
(`store.rollupRun(runId);`, `packages/ui-server/src/activity/recorder.ts:723`).
Retrospective totals alone cannot enforce admission.

The maintainer's [2026-10-01 ruling on #678](https://github.com/schlessera/brain-kit/issues/678#issuecomment-5926691626)
Expand Down
6 changes: 3 additions & 3 deletions docs/decisions/claude-code-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ next to it.
of this binary. The only version probe in the server is for the `brain` CLI
(`Probe the selected content CLI`,
`packages/ui-server/src/brain/client.ts:142-210`, called at
`probeBrainCliVersion(config.brainPath`, `packages/ui-server/src/app.ts:433`).
`probeBrainCliVersion(config.brainPath`, `packages/ui-server/src/app.ts:434`).
`brain doctor` runs `claude mcp list` from `PATH` (`which("claude")`,
`packages/core/src/cli/commands/doctor.ts:501-505`) — the user's own Claude
Code on their own machine, to check the MCP registration, not the server's
Expand Down Expand Up @@ -212,7 +212,7 @@ unnecessary.
`node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs`. The boot probe below
turns either into a refusal to start, for the same reason a missing backend
refuses to boot (`A missing (or unrecognized) agent backend`,
`packages/ui-server/src/app.ts:371-376`).
`packages/ui-server/src/app.ts:372-377`).
- **The published range is still a caret, and that bounds what this repo can
guarantee.** `@schlessera/brain-backend-claude` depends on `^0.3.241`, and a
host resolves it in its own lockfile. A host can bump the SDK — and so the
Expand Down Expand Up @@ -468,7 +468,7 @@ into them.
billing now stays unknown for every origin; server credentials and an
observed runtime version cannot establish a child's billing. The recorded
root-span attribute is the source of truth
(`const attrBilling`, `packages/ui-server/src/activity/store.ts:537-538`);
(`const attrBilling`, `packages/ui-server/src/activity/store.ts:554-555`);
[cost-tracking.md](cost-tracking.md#recorded-billing-is-the-rollup-source-of-truth-293)
records the 2026-09-30 ruling and frozen-history semantics.
- **Model discovery prefers the API key** and describes that as "mirroring the
Expand Down
4 changes: 2 additions & 2 deletions docs/decisions/cost-tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ math and authoritative backend cost remain available independently; aggregates
exclude unknown effective costs from their sum and count the runs as unpriced.
Explicit valid subscription/API classifications keep their existing behavior.
The rollup reads the root attribute and prices child usage for cron locally
(`const attrBilling`, `packages/ui-server/src/activity/store.ts:537-538`).
(`const attrBilling`, `packages/ui-server/src/activity/store.ts:554-555`).

This is a change to the evidence used for new computations, not a historical
backfill. First non-null billing, list cost, effective cost and estimate values
Expand Down Expand Up @@ -156,7 +156,7 @@ still satisfies the interface. The host declares the billing mode with
`CreateAppOptions.labeller.billing`; nothing infers it.

The run is written so the existing rollup prices it without a rule of its
own (`const endRun`, `packages/ui-server/src/labels/labeller.ts:235-251`). The
own (`const endRun`, `packages/ui-server/src/labels/labeller.ts:242-257`). The
reported usage and model become the root's `gen_ai.usage.per_model`, and the
declared billing is written as `brain.billing_mode` **only beside usage and a
model**. The rollup prices a subscription run at $0 whatever its tokens, so a
Expand Down
12 changes: 6 additions & 6 deletions docs/decisions/governed-writable-workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ still holds.
**Voice.** Ruling 1 makes voice `governed` too. Voice keeps its narrower
membership (`VOICE_ALLOWED_TOOLS`, `packages/ui-backend-claude/src/tool-policy.ts:120`):
no Bash, no raw `Write`/`Edit`, no archive, and approval returns false
(`const apply = createBrainApplication`, `packages/ui-server/src/ws/bridge.ts:613-618`).
(`const apply = createBrainApplication`, `packages/ui-server/src/ws/bridge.ts:614-619`).
A voice turn therefore writes only through curated capture and update, now
with a journal behind it. The writable mount gives voice no tool it lacked.
Pi still refuses voice turns.
Expand Down Expand Up @@ -1412,7 +1412,7 @@ limits from the same policy inputs and states independent hard limits.

**What exists.** The audit trail is the Activity span record: tool name, the
full input up to its cap, output and approval decisions
(`"tool_input"`, `packages/ui-server/src/activity/recorder.ts:301`).
(`"tool_input"`, `packages/ui-server/src/activity/recorder.ts:313`).

**What changes:**

Expand Down Expand Up @@ -2078,7 +2078,7 @@ every turn without a proven recipient:
- **Auto-memory.** A denied Claude worker gets no saved project
`memory/`, and no note it writes is saved, neither a new name nor a
replacement of an existing one (`omitMemory`,
`packages/ui-backend-claude/src/worker-state.ts:284-284`). Its notes stay
`packages/ui-backend-claude/src/worker-state.ts:285-285`). Its notes stay
in its tmpfs and are discarded with it, so no note can collide with a
saved one and refuse the turn's transcript. The decision is taken from
the host's actual masks (`sandbox.providerMasks.folders`), never from a
Expand Down Expand Up @@ -3110,7 +3110,7 @@ attribution. If an existing same-session/turn taint event precedes an agent
observation, the journal retains its source ID with that change. Replay never
infers taint from a later event, and absence of evidence does not assert complete
untrusted-input detection. User history operations are not tainted agent writes
(`async function observe`, `packages/ui-server/src/brain/turn-journal.ts:222-285`).
(`async function observe`, `packages/ui-server/src/brain/turn-journal.ts:249-312`).

Claude auto-memory persists outside the authoritative brain. Its validated
native copy-out wraps each changed direct `.md` note in host journal retention:
Expand All @@ -3134,7 +3134,7 @@ providers or a claim of capturing intervening external writes. The copy-out
boundary is `const write`, `packages/ui-backend-claude/src/worker-state.ts:210-224`;
retention/finalization is `persistClaudeMemory`,
`packages/ui-server/src/brain/turn-journal.ts:462-497`; destination-bound recovery
is `const target`, `packages/ui-server/src/brain/turn-revert.ts:106-116`.
is `const target`, `packages/ui-server/src/brain/turn-revert.ts:108-118`.

### Concurrent edits

Expand Down Expand Up @@ -3167,7 +3167,7 @@ exclusive "mine" or "theirs" ownership. A conflict requires two distinct
observed after-versions sharing an overlapping turn; one version remains shared
attribution. User choices use the existing authenticated history operation,
comparing current raw state with the reviewed latest observation before effects
(`resolveSelection`, `packages/ui-server/src/brain/turn-revert.ts:49-72`).
(`resolveSelection`, `packages/ui-server/src/brain/turn-revert.ts:50-73`).
Keep both creates the other version beside the selected file with sync's
numbered `-remote` convention, using exclusive descriptor-rooted creation,
existing CAS leases and explicit coverage for its final generated name
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:847`), the API guard follows
public ceremonies (`authGuard`, `packages/ui-server/src/app.ts:868`), and the
WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:1057`).
(`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`).
Static serving is conditional (`if (options.staticRoot)`,
`packages/ui-server/src/app.ts:1077-1098`). That order establishes access;
`packages/ui-server/src/app.ts:1086-1107`). That order establishes access;
it does not select compatibility status.

A hermetic real-app launch, with temporary brain root, in-memory database,
Expand Down
Loading
Loading