Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/quiet-metadata-admission.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@schlessera/brain-ui-server": minor
---

Cancel queued module metadata when its HTTP caller disconnects or as soon as the app starts closing, so `/api/status` no longer holds `close()` open behind a busy output lock. Admitted metadata drains within the close grace period, like other shared runs, with database ownership retained until handlers settle. Add an optional admission-cancellation signal to `BrainClient.modules`.
4 changes: 3 additions & 1 deletion api-report/ui-server.txt
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,9 @@ signatures (every public export and the types it is made of)
export interface BrainClient {
hygiene?(args: readonly string[]): Promise<unknown>;
cliCommand(): string[];
modules?(): Promise<{
modules?(opts?: {
signal?: AbortSignal;
}): Promise<{
enabled: ConfiguredModule[];
available: unknown[];
}>;
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 @@ -63,8 +63,8 @@ next to it.
- **Updated.** Nothing in the tree installs, updates, pins or reads the version
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:121-187`, called at
`probeBrainCliVersion(config.brainPath`, `packages/ui-server/src/app.ts:413`).
`packages/ui-server/src/brain/client.ts:121-189`, called at
`probeBrainCliVersion(config.brainPath`, `packages/ui-server/src/app.ts:415`).
`brain doctor` runs `claude mcp list` from `PATH` (`which("claude")`,
`packages/core/src/cli/commands/doctor.ts:495-499`) — 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:358-363`).
`packages/ui-server/src/app.ts:360-365`).
- **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
2 changes: 1 addition & 1 deletion docs/decisions/governed-writable-workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -2073,7 +2073,7 @@ files, `.claude/`, `.agents/`, `.pi/`, `.mcp.json`, and
- The core CLI loads `brain.config.ts` or `brain.config.json` at the canonical
brain cwd (`loadUserConfig`, `packages/core/src/lib/config.ts:590-624`).
Host commands bind that cwd to the selected brain (`function hostSpawn`,
`packages/ui-server/src/brain/host-boundary.ts:430-441`).
`packages/ui-server/src/brain/host-boundary.ts:433-444`).
- Bun startup uses root `bunfig.toml` and `package.json`; retained literal
preload/module inputs can identify a nested module's nearest package scope.
The inventory walks parent directories only within the originating journal
Expand Down
18 changes: 14 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:766`), the API guard follows
public ceremonies (`authGuard`, `packages/ui-server/src/app.ts:787`), and the
WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:969`).
(`createHealthRoutes`, `packages/ui-server/src/app.ts:770`), the API guard follows
public ceremonies (`authGuard`, `packages/ui-server/src/app.ts:791`), and the
WebSocket admission is separate (`"/ws"`, `packages/ui-server/src/app.ts:973`).
Static serving is conditional (`if (options.staticRoot)`,
`packages/ui-server/src/app.ts:989-1010`). That order establishes access;
`packages/ui-server/src/app.ts:993-1014`). That order establishes access;
it does not select compatibility status.

A hermetic real-app launch, with temporary brain root, in-memory database,
Expand Down Expand Up @@ -80,6 +80,16 @@ at response creation: existing SSE and journal owners still govern streaming
response producers. A stalled client or producer cancellation cannot make
shutdown wait for unbounded additional request bytes.

Module metadata for status and the modules list opts into caller and app-lifetime
admission cancellation. A queued lookup settles without launching after its
caller disconnects or as soon as shutdown begins. An admitted lookup is a shared
run: it keeps its publication and cleanup through shutdown's grace period and is
then cancelled with the other shared runs (#2473). Status continues to omit
unavailable optional renderer metadata, while already completed results retain
their ordinary successful response. Other HTTP work, streaming producers and
governed refresh retain their existing lifetimes; the dispatch join still
precedes state disposal.

## Versioning and changes

Supported HTTP methods, paths, authorization requirements, accepted inputs,
Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/runtime-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ reciprocal host-version negotiation is involved.

| Existing boundary | How this design uses it |
| --- | --- |
| `probeBrainCliVersion` / `brainCliCommand` (`export async function probeBrainCliVersion`, `packages/ui-server/src/brain/client.ts:122-187`) | Reuse executable selection, wrapper, deadline and cleanup; add host floor composition, strict comparison and invocation verification without duplicating the 0.33.0 constant. |
| `probeBrainCliVersion` / `brainCliCommand` (`export async function probeBrainCliVersion`, `packages/ui-server/src/brain/client.ts:122-189`) | Reuse executable selection, wrapper, deadline and cleanup; add host floor composition, strict comparison and invocation verification without duplicating the 0.33.0 constant. |
| `installedAgentSdkVersion` (`export function installedAgentSdkVersion`, `packages/ui-backend-claude/src/runtime-probe.ts:55-61`) | Retain importer-relative resolution; validate package name/version and the owner's manifest range. |
| `selectedSpawn` / `probeClaudeRuntime` (`export function selectedSpawn`, `packages/ui-backend-claude/src/runtime-probe.ts:69-96`; `export async function probeClaudeRuntime`, `packages/ui-backend-claude/src/runtime-probe.ts:127-147`) | Keep SDK-selected command capture, native/JS override argv, environment, timeout and group cleanup; add strict identity validation and composed runtime floors. |
| `probeBackendRuntimes` (`export async function probeBackendRuntimes`, `packages/ui-server/src/agent/backend.ts:392-439`) | Forward validated host requirements; retain active-backend selection and separate unmeasured-pair logging. |
Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/scheduled-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Source inspected at `b5c30c180df5584584f261b446c4d440380d2441`:
(`else if (item.status === "scheduled"`, `packages/ui-server/src/inbox/actions.ts:188-189`).
The runtime ticks every minute (`INBOX_TICK_MS`, `packages/ui-server/src/inbox/runtime.ts:11-13`).
- Production app wiring supplies recovery and budgets, without a dispatcher
(`// Recovery/heartbeat only.`, `packages/ui-server/src/app.ts:430-446`).
(`// Recovery/heartbeat only.`, `packages/ui-server/src/app.ts:432-448`).
A scripted nonempty ready-item test still reports no claim, attempt or
Activity root. That proves disabled wiring, not safe execution of a schedule.
- Headless plumbing checks backend support, a usable principal, lifetime and
Expand Down
6 changes: 3 additions & 3 deletions docs/decisions/session-principals.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ 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:772` | mounted **before** the guard at `authGuard(authMode`, `app.ts:787`; logout guards itself (`resolveCookiePrincipal(c, auth, deps.db)`, `auth.ts:808`) |
| Passkey management | `passkeyManagementRoutes`, `app.ts:797` | mounted **after** the guard ("Mount AFTER the auth guard") |
| WS guard | `"/ws"`, `app.ts:969-986` | origin → `isWsAuthorized` (returns a boolean, `auth.ts:221-237`) → capacity → upgrade |
| Auth routes | `authRoutes`, `app.ts:776` | mounted **before** the guard at `authGuard(authMode`, `app.ts:791`; logout guards itself (`resolveCookiePrincipal(c, auth, deps.db)`, `auth.ts:808`) |
| Passkey management | `passkeyManagementRoutes`, `app.ts:801` | mounted **after** the guard ("Mount AFTER the auth guard") |
| WS guard | `"/ws"`, `app.ts:973-990` | 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:134-142` | recorder built from `{turnId, sessionId, billing}`; `RunningTurn` (`ws/turns.ts:55-77`) has no actor |
Expand Down
21 changes: 21 additions & 0 deletions docs/integration-contract/package-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3609,3 +3609,24 @@ the same identity is allowed; changing an established identity or patching a
terminal span returns false without modifying the row. The recorder persists
this identity in both the queryable session column and the existing attributes.
This additive API ships in a minor release.

## Module metadata admission cancellation (additive, #2457)

`BrainClient.modules({ signal })` accepts an optional abort signal. It cancels
metadata waiting for execution admission, including a required CLI version
probe. Once an execution owns its admission locks, its worker, output
publication and cleanup drain normally. Cancellation between a version probe
and the module lookup prevents the latter from starting. Calls without an
option preserve their existing behavior and result shape.

The internal host boundary carries a separate admission signal so existing
execution cancellation, including search worker termination and output drain,
keeps its semantics. The optional `BrainClient.modules` input is an additive
public TypeScript change and ships in a minor.

`createApp()` passes each `/api/status` and `/api/modules` request's signal,
so a caller that disconnects cancels its queued metadata. `close()` cancels
every metadata request still waiting for admission as soon as it begins.
Admitted metadata is a shared run: it drains within `close()`'s 10 s grace
period and is then cancelled with the other shared runs (#2473). The response
shapes and status codes are unchanged.
2 changes: 1 addition & 1 deletion 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:787`).
(`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:791`).
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
2 changes: 1 addition & 1 deletion 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:25-37`) 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:787`) follows public routes;
- **Auth mounting:** (`app.use("/api/*", authGuard(`, `packages/ui-server/src/app.ts:791`) 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:281-375`). Timeout unwind is
Expand Down
2 changes: 1 addition & 1 deletion docs/plans/navigation-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ 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:787`).
- API authorization: (`app.use("/api/*", authGuard(authMode, auth, db));`, `packages/ui-server/src/app.ts:791`).

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

Expand Down
2 changes: 1 addition & 1 deletion docs/plans/storage-accounting.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Source anchors, checked against the reviewed commit:
The completed #94 and #398 provide existing stats/maintenance foundations.
- C: snapshot fields (`snapshotOf`, `packages/core/src/lib/stats-history.ts:132-172`),
thinning (`thin`, `packages/core/src/lib/stats-history.ts:232-244`).
- D: effective path/journal resolution (`createApp`, `packages/ui-server/src/app.ts:279-315`),
- D: effective path/journal resolution (`createApp`, `packages/ui-server/src/app.ts:281-317`),
WAL store (`createUiDb`, `packages/ui-server/src/db/client.ts:25-37`), and
private locations (`statePaths`, `packages/ui-server/src/brain/operational-state.ts:107-113`).
- E: live/archive paths (`transcriptPaths`, `packages/ui-backend-claude/src/transcript-archive.ts:29-38`).
Expand Down
2 changes: 1 addition & 1 deletion docs/plans/storage-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Use maintenance workers for repository code, the CLI and Git. Host routes may
perform narrowly validated descriptor operations, as the journal/revert do;
they must not evaluate brain configuration, hooks, filters or executables.
The existing brain client supplies boundary execution
(`async function execBrain`, `packages/ui-server/src/brain/client.ts:231-283`).
(`async function execBrain`, `packages/ui-server/src/brain/client.ts:240-294`).
Do not turn the new surface into a host command runner or an extension seam.

Reuse existing authorization. A requested reversible, fully journaled edit
Expand Down
2 changes: 1 addition & 1 deletion docs/process/evidence/test-wait-review-partition.json
Original file line number Diff line number Diff line change
Expand Up @@ -1999,7 +1999,7 @@
"basis": "Embedded `brain` CLI body written by brainWithCli or an inline fake bin; the test spawns it through the brain client and never waits for the literal. The hold exists so the cancellation path has a live child to kill. No completed elapsed minimum is implied by the literal.",
"noElapsedMinimumImplied": true,
"raceWinner": "controller.abort() after the host PID is seen wins; the 60000 ms sleep never completes in a passing run",
"holdEnd": "SIGKILL: the client's execBrain passes killSignal SIGKILL with the abort signal (packages/ui-server/src/brain/client.ts:263-265). The test asserts the rejection arrives only after the child stops answering signal 0."
"holdEnd": "SIGKILL: the client's execBrain passes killSignal SIGKILL with the abort signal (packages/ui-server/src/brain/client.ts:274-276). The test asserts the rejection arrives only after the child stops answering signal 0."
},
"observation": null,
"followUp": null
Expand Down
9 changes: 8 additions & 1 deletion packages/ui-react/tests/external-speech-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,14 @@ if (worker) {
browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage", "--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"] });
}, 180_000);

afterAll(async () => { await browser?.close(); server?.stop(true); speechServer?.stop(true); await app?.close(); });
afterAll(async () => {
const started = performance.now();
await browser?.close(); server?.stop(true); speechServer?.stop(true);
// The #2457 receipt: app.close() must not wait on queued status metadata.
const closing = performance.now();
await app?.close();
process.stderr.write(`[external-speech] teardown: browser ${(closing - started).toFixed(1)} ms, app.close() ${(performance.now() - closing).toFixed(1)} ms\n`);
});
}

async function open(proveBackend = false): Promise<Page> {
Expand Down
22 changes: 14 additions & 8 deletions packages/ui-server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,10 @@ export interface BrainUiApp {
/**
* Refuse new HTTP requests and drain admitted handlers before disposing of
* their operational state. Stalled request bodies are cancelled on close.
* In-flight brain CLI reads and plain index runs get a 10 s grace period and
* are then cancelled; every other run keeps its existing shutdown behaviour.
* Module metadata still waiting for execution admission is cancelled at
* once. In-flight brain CLI reads, metadata and plain index runs get a 10 s
* grace period and are then cancelled; every other run keeps its existing
* shutdown behaviour.
*/
close(): Promise<void>;
}
Expand Down Expand Up @@ -449,9 +451,11 @@ export async function createApp(options: CreateAppOptions = {}): Promise<BrainUi
const intake = createInboxIntake(db, config.brainPath, observability.logger("inbox"));
bootCleanup.push(() => intake.close());
await inbox.ready; await intake.reconcile();
// Aborted when close()'s grace period ends: cancels in-flight shared CLI runs.
const shutdown = new AbortController();
const brain = createBrainClient({ brainPath: config.brainPath, exec: config.exec, minimumVersion: brainCliMinimum, log: brainLog, boundary, shutdown: shutdown.signal });
// Aborted when close() begins: cancels module metadata still waiting for
// admission. Aborted when close()'s grace period ends: cancels in-flight
// shared CLI runs, admitted metadata included.
const closeBegun = new AbortController(), shutdown = new AbortController();
const brain = createBrainClient({ brainPath: config.brainPath, exec: config.exec, minimumVersion: brainCliMinimum, log: brainLog, boundary, closing: closeBegun.signal, shutdown: shutdown.signal });
// After a curated application, a credential-free worker indexed the change
// without provider keys (#1648). The credentialed reindex that fills in its
// vectors waits for the writer gate; one at a time, and one more if more
Expand Down Expand Up @@ -810,7 +814,7 @@ export async function createApp(options: CreateAppOptions = {}): Promise<BrainUi
"/api",
createStatusRoutes({
sourceCommit: config.sourceCommit,
getContentRenderers: async () => brain.modules ? declaredContentRenderers((await brain.modules()).enabled) : undefined,
getContentRenderers: async signal => brain.modules ? declaredContentRenderers((await brain.modules({ signal })).enabled) : undefined,
getCronStatus: () => cron.getCronStatus(),
isTurnActive: () => host.coordinator.isTurnActive(),
// Undefined when the injected consumer cannot be read back (a real OTel
Expand Down Expand Up @@ -1079,8 +1083,10 @@ export async function createApp(options: CreateAppOptions = {}): Promise<BrainUi
// Refuse new requests immediately; every remaining owner drains even
// when a native deletion reply is unknown and its durable row stays.
host.closing = true;
// In-flight work gets a short grace period; then shared CLI runs (reads
// and plain index) are cancelled so they cannot hold close open (#2473).
// Queued module metadata is cancelled now (#2457). In-flight work gets a
// short grace period; then shared CLI runs (reads and plain index) are
// cancelled so they cannot hold close open (#2473).
closeBegun.abort(new Error("Server shutting down"));
const grace = setTimeout(() => shutdown.abort(new Error("Server shutting down")), CLOSE_GRACE_MS);
const failures: unknown[] = [];
const attempt = async (close: () => void | Promise<void>) => { try { await close(); } catch (error) { failures.push(error); } };
Expand Down
Loading
Loading